diff --git a/lib/components/narrative/line-itin/connected-itinerary-body.js b/lib/components/narrative/line-itin/connected-itinerary-body.js
index 5c6ba0148..ce38a6f73 100644
--- a/lib/components/narrative/line-itin/connected-itinerary-body.js
+++ b/lib/components/narrative/line-itin/connected-itinerary-body.js
@@ -12,6 +12,7 @@ import styled from 'styled-components'
import { showLegDiagram } from '../../../actions/map'
import { setViewedTrip } from '../../../actions/ui'
import TransitLegSubheader from './connected-transit-leg-subheader'
+import RealtimeTimeColumn from './realtime-time-column'
import TripDetails from '../connected-trip-details'
import TripTools from '../trip-tools'
@@ -67,6 +68,7 @@ class ConnectedItineraryBody extends Component {
toRouteAbbreviation={noop}
TransitLegSubheader={TransitLegSubheader}
TransitLegSummary={TransitLegSummary}
+ TimeColumnContent={RealtimeTimeColumn}
/>
diff --git a/lib/components/narrative/line-itin/realtime-time-column.js b/lib/components/narrative/line-itin/realtime-time-column.js
new file mode 100644
index 000000000..3cf031fee
--- /dev/null
+++ b/lib/components/narrative/line-itin/realtime-time-column.js
@@ -0,0 +1,139 @@
+import { isTransit } from '@opentripplanner/core-utils/lib/itinerary'
+import {
+ legType,
+ timeOptionsType
+} from '@opentripplanner/core-utils/lib/types'
+import { formatTime } from '@opentripplanner/core-utils/lib/time'
+import PropTypes from 'prop-types'
+import React from 'react'
+import styled from 'styled-components'
+
+const TimeText = styled.div``
+
+const TimeStruck = styled.div`
+ text-decoration: line-through;
+`
+
+const TimeBlock = styled.div`
+ line-height: 1em;
+ margin-bottom: 4px;
+`
+
+const TimeColumnBase = styled.div``
+
+const StatusText = styled.div`
+ color: #bbb;
+ font-size: 80%;
+ line-height: 1em;
+`
+
+const DelayText = styled.span`
+ display: block;
+ white-space: nowrap;
+`
+
+// Reusing stop viewer colors.
+const TimeColumnOnTime = styled(TimeColumnBase)`
+ ${TimeText}, ${StatusText} {
+ color: #5cb85c;
+ }
+`
+const TimeColumnEarly = styled(TimeColumnBase)`
+ ${TimeText}, ${StatusText} {
+ color: #337ab7;
+ }
+`
+const TimeColumnLate = styled(TimeColumnBase)`
+ ${TimeText}, ${StatusText} {
+ color: #d9534f;
+ }
+`
+
+/**
+ * This component displays the scheduled departure/arrival time for a leg,
+ * and, for transit legs, displays any delays or earliness where applicable.
+ */
+export default function RealtimeTimeColumn ({
+ isDestination,
+ leg,
+ timeOptions
+}) {
+ const time = isDestination ? leg.endTime : leg.startTime
+ const formattedTime = time && formatTime(time, timeOptions)
+ const isTransitLeg = isTransit(leg.mode)
+
+ // For non-real-time legs, show only the scheduled time,
+ // except for transit legs where we add the "scheduled" text underneath.
+ if (!leg.realTime) {
+ return (
+ <>
+ {formattedTime}
+ {isTransitLeg && scheduled}
+ >
+ )
+ }
+
+ // Delay in seconds.
+ const delay = isDestination ? leg.arrivalDelay : leg.departureDelay
+ // Time is in milliseconds.
+ const originalTime = time - delay * 1000
+ const originalFormattedTime =
+ originalTime && formatTime(originalTime, timeOptions)
+
+ // TODO: refine on-time thresholds.
+ // const isOnTime = delay >= -60 && delay <= 120;
+ const isOnTime = delay === 0
+
+ let statusText
+ let TimeColumn = TimeColumnBase
+ if (isOnTime) {
+ statusText = 'on time'
+ TimeColumn = TimeColumnOnTime
+ } else if (delay < 0) {
+ statusText = 'early'
+ TimeColumn = TimeColumnEarly
+ } else if (delay > 0) {
+ statusText = 'late'
+ TimeColumn = TimeColumnLate
+ }
+
+ // Absolute delay in rounded minutes, for display purposes.
+ const delayInMinutes = Math.abs(
+ Math.round((isDestination ? leg.arrivalDelay : leg.departureDelay) / 60)
+ )
+
+ let renderedTime
+ if (!isOnTime) {
+ // If the transit vehicle is not on time, strike the original scheduled time
+ // and display the updated time underneath.
+ renderedTime = (
+
+ {!isOnTime && {originalFormattedTime}}
+ {formattedTime}
+
+ )
+ } else {
+ renderedTime = {formattedTime}
+ }
+
+ return (
+
+ {renderedTime}
+
+ {/* Keep the '5 min' string on the same line. */}
+ {!isOnTime && {delayInMinutes} min}
+ {statusText}
+
+
+ )
+}
+
+RealtimeTimeColumn.propTypes = {
+ isDestination: PropTypes.bool.isRequired,
+ leg: legType.isRequired,
+ timeOptions: timeOptionsType
+}
+
+RealtimeTimeColumn.defaultProps = {
+ timeOptions: null
+}
diff --git a/package.json b/package.json
index cf7d1992a..8e1aa9ac6 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@opentripplanner/geocoder": "^1.0.2",
"@opentripplanner/humanize-distance": "^0.0.22",
"@opentripplanner/icons": "^1.0.1",
- "@opentripplanner/itinerary-body": "^1.0.2",
+ "@opentripplanner/itinerary-body": "^1.1.0",
"@opentripplanner/location-field": "^1.0.2",
"@opentripplanner/location-icon": "^1.0.0",
"@opentripplanner/park-and-ride-overlay": "^1.0.1",
diff --git a/yarn.lock b/yarn.lock
index 517f5470a..e49301f97 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1331,12 +1331,12 @@
"@opentripplanner/core-utils" "^1.2.0"
prop-types "^15.7.2"
-"@opentripplanner/itinerary-body@^1.0.2":
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/@opentripplanner/itinerary-body/-/itinerary-body-1.0.2.tgz#f280932a13723f49bac92c79feb83a088d14329b"
- integrity sha512-3x8UvtkL3WmUeNTeWsWTJJtR2FqcWU42xw4833+6pMMzPoS2U4bYzSbw7ucQT64vqcoRQ0ykUjE7as32W+VgGg==
+"@opentripplanner/itinerary-body@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@opentripplanner/itinerary-body/-/itinerary-body-1.1.0.tgz#6fabc389ad25f6f14db5a5861603a127530e0d9e"
+ integrity sha512-svu2A0z+CnL5vrkDXuaMjJaDoSC4d52utzdGfgkXdfMaK9prO1D/6SHWiVILXmaDJExdBAoF3mxSuA7xPItTTA==
dependencies:
- "@opentripplanner/core-utils" "^1.2.0"
+ "@opentripplanner/core-utils" "^2.1.0"
"@opentripplanner/humanize-distance" "^0.0.22"
"@opentripplanner/icons" "^1.0.0"
"@opentripplanner/location-icon" "^1.0.0"