• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

OneBusAway / wayfinder / 25970026383

16 May 2026 06:50PM UTC coverage: 80.079%. First build
25970026383

Pull #466

github

web-flow
Merge 9274fa127 into 45e206306
Pull Request #466: Fix trip planner timezone: use agency timezone instead of browser/system timezone

1827 of 2021 branches covered (90.4%)

Branch coverage included in aggregate %.

29 of 57 new or added lines in 5 files covered. (50.88%)

11487 of 14605 relevant lines covered (78.65%)

4.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.52
/src/lib/dateTimeFormat.js
1
export function getLocalTimeZone() {
1✔
2
        return new Intl.DateTimeFormat().resolvedOptions().timeZone;
111✔
3
}
111✔
4

5
/**
1✔
6
 * Convert a Temporal.PlainTime to a local Date object.
1✔
7
 * This lets us pass the result to Intl.DateTimeFormat.format() without relying
1✔
8
 * on the temporal-polyfill's Intl patch, which breaks when the browser ships
1✔
9
 * native Temporal but not native Intl–Temporal integration.
1✔
10
 *
1✔
11
 * Uses local time (not UTC) so the formatted output matches the PlainTime's
1✔
12
 * hour/minute when used with formatters that have no timeZone specified.
1✔
13
 * WARNING: Do not pass the result to a formatter with timeZone: 'UTC' — the
1✔
14
 * local-time Date will be re-interpreted in UTC, shifting the displayed hour
1✔
15
 * by the local UTC offset. For UTC formatters, use Date.UTC() directly instead
1✔
16
 * (see formatSecondsFromMidnight for an example).
1✔
17
 *
1✔
18
 * @param {Temporal.PlainTime} plainTime
1✔
19
 * @returns {Date}
1✔
20
 */
1✔
21
export function plainTimeToDate(plainTime) {
1✔
22
        return new Date(
46✔
23
                1970,
46✔
24
                0,
46✔
25
                1,
46✔
26
                plainTime.hour,
46✔
27
                plainTime.minute,
46✔
28
                plainTime.second,
46✔
29
                plainTime.millisecond
46✔
30
        );
46✔
31
}
46✔
32

33
// Time formats
1✔
34
export const utcTimeFormat = new Intl.DateTimeFormat(undefined, {
1✔
35
        hour: 'numeric',
1✔
36
        minute: '2-digit',
1✔
37
        hour12: true,
1✔
38
        timeZone: 'UTC'
1✔
39
});
1✔
40

41
export const localTimeFormat = new Intl.DateTimeFormat(undefined, {
1✔
42
        hour: 'numeric',
1✔
43
        minute: '2-digit',
1✔
44
        hour12: true
1✔
45
});
1✔
46

47
export const fourDigitTimeFormat = new Intl.DateTimeFormat(undefined, {
1✔
48
        hour: '2-digit',
1✔
49
        minute: '2-digit',
1✔
50
        hour12: true
1✔
51
});
1✔
52

53
// Date formats
1✔
54
export const apiDateFormat = new Intl.DateTimeFormat('en-US', {
1✔
55
        year: 'numeric',
1✔
56
        month: '2-digit',
1✔
57
        day: '2-digit'
1✔
58
});
1✔
59

60
export const apiTimeFormat = new Intl.DateTimeFormat('en-US', {
1✔
61
        hour: 'numeric',
1✔
62
        minute: '2-digit',
1✔
63
        hour12: true
1✔
64
});
1✔
65

66
/**
1✔
67
 * Format milliseconds since Unix epoch to a given time zone and format
1✔
68
 *
1✔
69
 * @example
1✔
70
 * msToTimeString(1705395900000)  // Returns '1:05 AM' assuming the local timezone is America/Los_Angeles
1✔
71
 * msToTimeString(1705395900000, 'UTC')  // Returns '9:05 AM'
1✔
72
 * msToTimeString(1705395900000, 'America/New_York')  // Returns '4:05 AM'
1✔
73
 * msToTimeString(1705395900000, 'America/New_York', fourDigitTimeFormat)  // Returns '04:05 AM'
1✔
74
 *
1✔
75
 * @param {number} ms - Milliseconds since Unix epoch
1✔
76
 * @param {string} [timeZone] - IANA timezone. Defaults to the local timezone.
1✔
77
 * @param {Intl.DateTimeFormat} [dateTimeFormat] - Intl.DateTimeFormat to use for formatting. Defaults to localTimeFormat.
1✔
78
 * @returns {string} Time in the given format
1✔
79
 */
1✔
80
export function msToTimeString(
1✔
81
        ms,
26✔
82
        timeZone = getLocalTimeZone(),
26✔
83
        dateTimeFormat = localTimeFormat
26✔
84
) {
26✔
85
        if (!Number.isFinite(ms)) return 'N/A';
26✔
86
        const instant = Temporal.Instant.fromEpochMilliseconds(ms);
14✔
87
        try {
14✔
88
                const plainTime = instant.toZonedDateTimeISO(timeZone).toPlainTime();
14✔
89
                return dateTimeFormat.format(plainTimeToDate(plainTime));
14✔
90
        } catch (err) {
26✔
91
                if (err instanceof RangeError) {
1✔
92
                        console.error(`msToTimeString: invalid timezone "${timeZone}", falling back to local`);
1✔
93
                        const plainTime = instant.toZonedDateTimeISO(getLocalTimeZone()).toPlainTime();
1✔
94
                        return dateTimeFormat.format(plainTimeToDate(plainTime));
1✔
95
                }
1!
96
                throw err;
×
97
        }
×
98
}
26✔
99

100
/**
1✔
101
 * Format milliseconds since Unix epoch to "HH:mm AM/PM" format
1✔
102
 * Dates are in local timezone
1✔
103
 *
1✔
104
 * @example
1✔
105
 * (Assuming the local timezone is America/Los_Angeles)
1✔
106
 * msToLocalArrivalDepartureTimeString(1705425300000)  // Returns '09:15 AM'
1✔
107
 *
1✔
108
 * @param {number} ms - Milliseconds since Unix epoch
1✔
109
 * @returns {string} Time in "HH:mm AM/PM" format
1✔
110
 */
1✔
111
export function msToLocalArrivalDepartureTimeString(ms) {
1✔
112
        return msToTimeString(ms, getLocalTimeZone(), fourDigitTimeFormat);
11✔
113
}
11✔
114

115
/**
1✔
116
 * Show the time in "h:mm AM/PM" format for a given number of seconds since midnight.
1✔
117
 *
1✔
118
 * @param {number} secondsSinceMidnight - Number of seconds since midnight
1✔
119
 * @returns {string} Time in "h:mm AM/PM" format
1✔
120
 *
1✔
121
 * @example
1✔
122
 * formatSecondsFromMidnight(38280)  // Returns '10:38 AM'
1✔
123
 */
1✔
124
export function formatSecondsFromMidnight(secondsSinceMidnight) {
1✔
125
        if (!Number.isFinite(secondsSinceMidnight)) return '';
13✔
126

127
        const midnight = new Temporal.PlainTime();
7✔
128
        const time = midnight.add({ seconds: secondsSinceMidnight });
7✔
129

130
        // Use Date.UTC so the hour/minute values survive unchanged when
7✔
131
        // utcTimeFormat (timeZone: 'UTC') re-interprets the timestamp in UTC
7✔
132
        return utcTimeFormat.format(new Date(Date.UTC(1970, 0, 1, time.hour, time.minute, time.second)));
7✔
133
}
7✔
134

135
/**
1✔
136
 * Helper to format departure time for pill display
1✔
137
 * Accepts an optional translator function for i18n support
1✔
138
 * @param {Object} opts - Options object containing departureType, departureTime, and departureDate
1✔
139
 * @param {string} [opts.departureType] - Departure type ('departAt' | 'arriveBy' | 'now')
1✔
140
 * @param {string} [opts.departureTime] - Departure time in 'HH:mm' format
1✔
141
 * @param {string} [opts.departureDate] - Departure date in 'YYYY-MM-DD' format
1✔
142
 * @param {Function} [translator] - Optional translator function for i18n support
1✔
143
 * @param {string} [timeZone] - IANA timezone (e.g. "America/Los_Angeles") for Today/Tomorrow logic. Defaults to browser's local timezone.
1✔
144
 * @returns {string|null} Formatted departure time string, or null if departureType is 'now'
1✔
145
 *
1✔
146
 * @example
1✔
147
 * formatDepartureDisplay({ departureType: 'departAt', departureTime: '09:00', departureDate: null })  // Returns 'Depart 9:00 AM'
1✔
148
 * formatDepartureDisplay({ departureType: 'arriveBy', departureTime: '17:00', departureDate: '2025-06-15' }, translator)  // Returns 'Arrive 5:00 PM, Today' (assuming today is 2025-06-15)
1✔
149
 */
1✔
150
export function formatDepartureDisplay(opts, translator = null, timeZone = undefined) {
1✔
151
        if (opts.departureType === 'now') return null;
14✔
152

153
        const timeStr = opts.departureTime || '';
14✔
154
        const dateStr = opts.departureDate || '';
14✔
155

156
        // Use translator if provided, otherwise fall back to English
14✔
157
        const prefix =
14✔
158
                opts.departureType === 'arriveBy'
14✔
159
                        ? translator
14✔
160
                                ? translator('trip-planner.arrive')
3✔
161
                                : 'Arrive'
3✔
162
                        : translator
14✔
163
                                ? translator('trip-planner.depart')
10✔
164
                                : 'Depart';
14✔
165

166
        if (timeStr) {
14✔
167
                const formattedTime = parseTimeInput(timeStr);
11✔
168
                if (!formattedTime) return prefix;
11✔
169

170
                let dateSuffix = '';
10✔
171
                if (dateStr) {
11✔
172
                        let today;
8✔
173
                        try {
8✔
174
                                today = Temporal.Now.plainDateISO(timeZone);
8✔
175
                        } catch (err) {
8!
NEW
176
                                if (err instanceof RangeError) {
×
NEW
177
                                        console.error(
×
NEW
178
                                                `formatDepartureDisplay: invalid timezone "${timeZone}", falling back to local`
×
NEW
179
                                        );
×
NEW
180
                                        today = Temporal.Now.plainDateISO();
×
NEW
181
                                } else {
×
NEW
182
                                        throw err;
×
NEW
183
                                }
×
NEW
184
                        }
×
185
                        const tomorrow = today.add({ days: 1 });
8✔
186

187
                        if (dateStr === today.toJSON()) {
8✔
188
                                const todayLabel = translator ? translator('trip-planner.today') : 'Today';
3!
189
                                dateSuffix = `, ${todayLabel}`;
3✔
190
                        } else if (dateStr === tomorrow.toJSON()) {
8✔
191
                                const tomorrowLabel = translator ? translator('trip-planner.tomorrow') : 'Tomorrow';
3✔
192
                                dateSuffix = `, ${tomorrowLabel}`;
3✔
193
                        } else {
5✔
194
                                dateSuffix = `, ${dateStr}`;
2✔
195
                        }
2✔
196
                }
8✔
197

198
                return `${prefix} ${formattedTime}${dateSuffix}`;
10✔
199
        }
10✔
200

201
        return prefix;
2✔
202
}
2✔
203

204
/**
1✔
205
 * Parse HTML time input (HH:mm, 24-hour) to OTP format (h:mm AM/PM).
1✔
206
 *
1✔
207
 * Uses Temporal.PlainTime.from to parse the time string to avoid timezone issues.
1✔
208
 * If the time string is already in "h:mm AM/PM" format, it is returned unchanged.
1✔
209
 *
1✔
210
 * @param {string} timeString - Time in "HH:mm" format (24-hour)
1✔
211
 * @returns {string|null} Time in "h:mm AM/PM" format, or null if invalid
1✔
212
 *
1✔
213
 * @example
1✔
214
 * parseTimeInput('14:30')  // Returns '2:30 PM'
1✔
215
 * parseTimeInput('00:00')  // Returns '12:00 AM'
1✔
216
 * parseTimeInput('12:00')  // Returns '12:00 PM'
1✔
217
 * parseTimeInput('09:05')  // Returns '9:05 AM'
1✔
218
 */
1✔
219
export function parseTimeInput(timeString) {
1✔
220
        if (!timeString || typeof timeString !== 'string') {
47✔
221
                return null;
5✔
222
        }
5✔
223

224
        // Try to parse as already-converted format
42✔
225
        const matchAlreadyConverted = timeString.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
42✔
226
        if (matchAlreadyConverted) {
47✔
227
                return timeString;
2✔
228
        }
2✔
229

230
        // Try to parse as 24-hour format
40✔
231
        const match24Hour = timeString.match(/^(\d{2}):(\d{2})$/);
40✔
232
        if (!match24Hour) {
47✔
233
                return null;
7✔
234
        }
7✔
235

236
        try {
33✔
237
                const time = Temporal.PlainTime.from(timeString);
33✔
238
                return apiTimeFormat.format(plainTimeToDate(time));
33✔
239
        } catch (err) {
47✔
240
                if (err instanceof RangeError) return null;
4✔
241
                throw err;
1✔
242
        }
1✔
243
}
47✔
244

245
/**
1✔
246
 * Parse HTML date input (YYYY-MM-DD) to OTP format (MM-DD-YYYY).
1✔
247
 *
1✔
248
 * Uses Temporal.PlainDate.from() for validation and parsing.
1✔
249
 *
1✔
250
 * @param {string} dateString - Date in "YYYY-MM-DD" format
1✔
251
 * @returns {string|null} Date in "MM-DD-YYYY" format, or null if invalid
1✔
252
 *
1✔
253
 * @example
1✔
254
 * parseDateInput('2026-01-14')  // Returns '01-14-2026'
1✔
255
 * parseDateInput('2026-12-31')  // Returns '12-31-2026'
1✔
256
 */
1✔
257
export function parseDateInput(dateString) {
1✔
258
        if (!dateString || typeof dateString !== 'string') {
29✔
259
                return null;
5✔
260
        }
5✔
261

262
        const match = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
24✔
263
        if (!match) {
29✔
264
                return null;
4✔
265
        }
4✔
266

267
        try {
20✔
268
                const dateTime = Temporal.PlainDate.from(dateString);
20✔
269
                if (dateTime.year < 2000 || dateTime.year > 2100) {
29✔
270
                        return null;
2✔
271
                }
2✔
272
                // Local Date (not UTC) because apiDateFormat has no timeZone specified
13✔
273
                const date = new Date(dateTime.year, dateTime.month - 1, dateTime.day);
13✔
274
                return apiDateFormat.format(date).replaceAll('/', '-');
13✔
275
        } catch (err) {
14✔
276
                if (err instanceof RangeError) return null;
5✔
277
                throw err;
1✔
278
        }
1✔
279
}
29✔
280

281
/**
1✔
282
 * Format a 24-hour hour to 12-hour format
1✔
283
 *
1✔
284
 * @param {number} hour - Hour in 24-hour format
1✔
285
 * @returns {number|null} Hour in 12-hour format, or null if invalid
1✔
286
 *
1✔
287
 * @example
1✔
288
 * convert24HourTo12Hour(0)  // Returns 12
1✔
289
 * convert24HourTo12Hour(12)  // Returns 12
1✔
290
 * convert24HourTo12Hour(14)  // Returns 2
1✔
291
 * convert24HourTo12Hour(23)  // Returns 11
1✔
292
 */
1✔
293
export function convert24HourTo12Hour(hour) {
1✔
294
        const hourNum = typeof hour === 'string' ? Number(hour) : hour;
14✔
295
        if (!Number.isFinite(hourNum)) return null;
14✔
296
        if (hourNum < 0 || hourNum > 23) return null;
14✔
297
        if (hourNum === 0) return 12;
14✔
298
        if (hourNum > 12) return hourNum - 12;
14✔
299
        return hourNum;
2✔
300
}
2✔
301

302
/**
1✔
303
 * Format a Date object to OTP API time format: "h:mm AM/PM"
1✔
304
 *
1✔
305
 * @param {Date} date - Date object
1✔
306
 * @param {string} [timeZone] - IANA timezone (e.g. "America/Los_Angeles"). Defaults to local.
1✔
307
 * @returns {string} Time in "h:mm AM/PM" format
1✔
308
 *
1✔
309
 * @example
1✔
310
 * formatTimeForOTP(new Date('2026-01-14T14:30:00'))  // Returns '2:30 PM'
1✔
311
 */
1✔
312
export function formatTimeForOTP(date, timeZone) {
1✔
313
        if (timeZone) {
11✔
314
                return new Intl.DateTimeFormat('en-US', {
5✔
315
                        hour: 'numeric',
5✔
316
                        minute: '2-digit',
5✔
317
                        hour12: true,
5✔
318
                        timeZone
5✔
319
                }).format(date);
5✔
320
        }
5✔
321
        return apiTimeFormat.format(date);
6✔
322
}
6✔
323

324
/**
1✔
325
 * Format a Date object to OTP API date format: "MM-DD-YYYY"
1✔
326
 * Used for "Leave Now" mode where we need the current date.
1✔
327
 *
1✔
328
 * @param {Date} date - Date object
1✔
329
 * @param {string} [timeZone] - IANA timezone (e.g. "America/Los_Angeles"). Defaults to local.
1✔
330
 * @returns {string} Date in "MM-DD-YYYY" format
1✔
331
 *
1✔
332
 * @example
1✔
333
 * formatDateForOTP(new Date(2026, 0, 14))  // Returns '01-14-2026'
1✔
334
 */
1✔
335
export function formatDateForOTP(date, timeZone) {
1✔
336
        if (timeZone) {
10✔
337
                return new Intl.DateTimeFormat('en-US', {
5✔
338
                        year: 'numeric',
5✔
339
                        month: '2-digit',
5✔
340
                        day: '2-digit',
5✔
341
                        timeZone
5✔
342
                })
5✔
343
                        .format(date)
5✔
344
                        .replaceAll('/', '-');
5✔
345
        }
5✔
346
        return apiDateFormat.format(date).replaceAll('/', '-');
5✔
347
}
5✔
348

349
/**
1✔
350
 * Format a timestamp to a last updated string
1✔
351
 *
1✔
352
 * @param {number} timestamp - Timestamp in milliseconds since Unix epoch
1✔
353
 * @param {Object} translations - Object containing translation strings for minutes, seconds, and ago
1✔
354
 * @param {string} translations.min - Translation string for minutes
1✔
355
 * @param {string} translations.sec - Translation string for seconds
1✔
356
 * @param {string} translations.ago - Translation string for ago
1✔
357
 * @returns {string} Formatted last updated string
1✔
358
 *
1✔
359
 * @example
1✔
360
 * Note: The actual output of these examples depends on the current time
1✔
361
 * formatLastUpdated(1715894400000, { min: 'min', sec: 'sec', ago: 'ago' })  // Returns '1 min 30 sec ago'
1✔
362
 * formatLastUpdated(1715894400000, { min: 'minute', sec: 'second', ago: 'ago' })  // Returns '1 minute 30 second ago'
1✔
363
 */
1✔
364
export function formatLastUpdated(timestamp, translations) {
1✔
365
        if (!Number.isFinite(timestamp)) return 'N/A';
12✔
366
        const date = Temporal.Instant.fromEpochMilliseconds(timestamp);
6✔
367
        const now = Temporal.Now.instant();
6✔
368
        const { minutes, seconds } = now.since(date).round({ largestUnit: 'minute' });
6✔
369

370
        const minutesStr = minutes > 0 ? `${minutes} ${translations.min} ` : '';
12✔
371
        return `${minutesStr}${seconds} ${translations.sec} ${translations.ago}`;
12✔
372
}
12✔
373

374
/**
1✔
375
 * Convert date ("MM-DD-YYYY") + time ("h:mm AM/PM") to OffsetDateTime
1✔
376
 * ("YYYY-MM-DDThh:mm:ss±HH:MM") as required by OTP 2.x GraphQL API.
1✔
377
 *
1✔
378
 * The timezone is used to compute the correct UTC offset for the target date,
1✔
379
 * handling DST transitions correctly. When timeZone is omitted, falls back to
1✔
380
 * the server process's locale.
1✔
381
 *
1✔
382
 * @param {string} date - Date in "MM-DD-YYYY" format
1✔
383
 * @param {string} time - Time in "h:mm AM/PM" format
1✔
384
 * @param {string} [timeZone] - IANA timezone (e.g. "America/Los_Angeles"). Defaults to server locale.
1✔
385
 * @returns {string|null} OffsetDateTime string, or null if time or date format is invalid
1✔
386
 */
1✔
387
export function convertToISO8601(date, time, timeZone) {
1✔
388
        if (!date || typeof date !== 'string') return null;
54✔
389
        if (!time || typeof time !== 'string') return null;
54✔
390

391
        const dateMatch = date.match(/^(\d{2})-(\d{2})-(\d{4})$/);
50✔
392
        if (!dateMatch) return null;
54✔
393

394
        const timeMatch = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
49✔
395
        if (!timeMatch) return null;
54✔
396

397
        const month = parseInt(dateMatch[1], 10);
46✔
398
        const day = parseInt(dateMatch[2], 10);
46✔
399
        const year = parseInt(dateMatch[3], 10);
46✔
400

401
        let hour = parseInt(timeMatch[1], 10);
46✔
402
        const minute = parseInt(timeMatch[2], 10);
46✔
403
        const period = timeMatch[3].toUpperCase();
46✔
404

405
        if (period === 'AM' && hour === 12) hour = 0;
54✔
406
        else if (period === 'PM' && hour !== 12) hour += 12;
43✔
407

408
        try {
46✔
409
                const plainDateTime = Temporal.PlainDateTime.from({ year, month, day, hour, minute });
46✔
410
                // toZonedDateTime resolves DST correctly for the target date
46✔
411
                const zdt = plainDateTime.toZonedDateTime(timeZone || getLocalTimeZone());
54✔
412

413
                // Use Temporal-resolved values so DST gaps produce a valid datetime
54✔
414
                // (e.g. 2:30 AM during spring-forward resolves to 3:30 AM)
54✔
415
                const yearStr = String(zdt.year).padStart(4, '0');
54✔
416
                const monthStr = String(zdt.month).padStart(2, '0');
54✔
417
                const dayStr = String(zdt.day).padStart(2, '0');
54✔
418
                const hourStr = String(zdt.hour).padStart(2, '0');
54✔
419
                const minuteStr = String(zdt.minute).padStart(2, '0');
54✔
420

421
                return `${yearStr}-${monthStr}-${dayStr}T${hourStr}:${minuteStr}:00${zdt.offset}`;
54✔
422
        } catch (err) {
54✔
423
                if (err instanceof RangeError) return null;
1!
424
                throw err;
×
425
        }
×
426
}
54✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc