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

OneBusAway / wayfinder / 23218770346

17 Mar 2026 10:11PM UTC coverage: 79.922%. First build
23218770346

Pull #393

github

web-flow
Merge 3bdeb980a into 484020f95
Pull Request #393: Server timezone fixes

1775 of 1966 branches covered (90.28%)

Branch coverage included in aggregate %.

109 of 134 new or added lines in 9 files covered. (81.34%)

11305 of 14400 relevant lines covered (78.51%)

4.39 hits per line

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

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

5
// Time formats
1✔
6
export const utcTimeFormat = new Intl.DateTimeFormat(undefined, {
1✔
7
        hour: 'numeric',
1✔
8
        minute: '2-digit',
1✔
9
        hour12: true,
1✔
10
        timeZone: 'UTC'
1✔
11
});
1✔
12

13
export const localTimeFormat = new Intl.DateTimeFormat(undefined, {
1✔
14
        hour: 'numeric',
1✔
15
        minute: '2-digit',
1✔
16
        hour12: true
1✔
17
});
1✔
18

19
export const fourDigitTimeFormat = new Intl.DateTimeFormat(undefined, {
1✔
20
        hour: '2-digit',
1✔
21
        minute: '2-digit',
1✔
22
        hour12: true
1✔
23
});
1✔
24

25
// Date formats
1✔
26
export const apiDateFormat = new Intl.DateTimeFormat('en-US', {
1✔
27
        year: 'numeric',
1✔
28
        month: '2-digit',
1✔
29
        day: '2-digit'
1✔
30
});
1✔
31

32
export const apiTimeFormat = new Intl.DateTimeFormat('en-US', {
1✔
33
        hour: 'numeric',
1✔
34
        minute: '2-digit',
1✔
35
        hour12: true
1✔
36
});
1✔
37

38
/**
1✔
39
 * Format milliseconds since Unix epoch to a given time zone and format
1✔
40
 *
1✔
41
 * @example
1✔
42
 * msToTimeString(1705395900000)  // Returns '1:05 AM' assuming the local timezone is America/Los_Angeles
1✔
43
 * msToTimeString(1705395900000, 'UTC')  // Returns '9:05 AM'
1✔
44
 * msToTimeString(1705395900000, 'America/New_York')  // Returns '4:05 AM'
1✔
45
 * msToTimeString(1705395900000, 'America/New_York', fourDigitTimeFormat)  // Returns '04:05 AM'
1✔
46
 *
1✔
47
 * @param {number} ms - Milliseconds since Unix epoch
1✔
48
 * @param {string} [timeZone] - IANA timezone. Defaults to the local timezone.
1✔
49
 * @param {Intl.DateTimeFormat} [dateTimeFormat] - Intl.DateTimeFormat to use for formatting. Defaults to localTimeFormat.
1✔
50
 * @returns {string} Time in the given format
1✔
51
 */
1✔
52
export function msToTimeString(
1✔
53
        ms,
24✔
54
        timeZone = getLocalTimeZone(),
24✔
55
        dateTimeFormat = localTimeFormat
24✔
56
) {
24✔
57
        if (!Number.isFinite(ms)) return 'N/A';
24✔
58
        const instant = Temporal.Instant.fromEpochMilliseconds(ms);
12✔
59
        const plainTime = instant.toZonedDateTimeISO(timeZone).toPlainTime();
12✔
60
        return dateTimeFormat.format(plainTime);
12✔
61
}
12✔
62

63
/**
1✔
64
 * Format milliseconds since Unix epoch to "HH:mm AM/PM" format
1✔
65
 * Dates are in local timezone
1✔
66
 *
1✔
67
 * @example
1✔
68
 * (Assuming the local timezone is America/Los_Angeles)
1✔
69
 * msToLocalArrivalDepartureTimeString(1705425300000)  // Returns '09:15 AM'
1✔
70
 *
1✔
71
 * @param {number} ms - Milliseconds since Unix epoch
1✔
72
 * @returns {string} Time in "HH:mm AM/PM" format
1✔
73
 */
1✔
74
export function msToLocalArrivalDepartureTimeString(ms) {
1✔
75
        return msToTimeString(ms, getLocalTimeZone(), fourDigitTimeFormat);
11✔
76
}
11✔
77

78
/**
1✔
79
 * Show the time in "h:mm AM/PM" format for a given number of seconds since midnight.
1✔
80
 *
1✔
81
 * @param {number} secondsSinceMidnight - Number of seconds since midnight
1✔
82
 * @returns {string} Time in "h:mm AM/PM" format
1✔
83
 *
1✔
84
 * @example
1✔
85
 * formatSecondsFromMidnight(38280)  // Returns '10:38 AM'
1✔
86
 */
1✔
87
export function formatSecondsFromMidnight(secondsSinceMidnight) {
1✔
88
        if (!Number.isFinite(secondsSinceMidnight)) return '';
13✔
89

90
        const midnight = new Temporal.PlainTime();
7✔
91
        const time = midnight.add({ seconds: secondsSinceMidnight });
7✔
92

93
        return utcTimeFormat.format(time);
7✔
94
}
7✔
95

96
/**
1✔
97
 * Helper to format departure time for pill display
1✔
98
 * Accepts an optional translator function for i18n support
1✔
99
 * @param {Object} opts - Options object containing departureType, departureTime, and departureDate
1✔
100
 * @param {string} [opts.departureType] - Departure type ('departAt' | 'arriveBy' | 'now')
1✔
101
 * @param {string} [opts.departureTime] - Departure time in 'HH:mm' format
1✔
102
 * @param {string} [opts.departureDate] - Departure date in 'YYYY-MM-DD' format
1✔
103
 * @param {Function} [translator] - Optional translator function for i18n support
1✔
104
 * @returns {string|null} Formatted departure time string, or null if departureType is 'now'
1✔
105
 *
1✔
106
 * @example
1✔
107
 * formatDepartureDisplay({ departureType: 'departAt', departureTime: '09:00', departureDate: null })  // Returns 'Depart 9:00 AM'
1✔
108
 * formatDepartureDisplay({ departureType: 'arriveBy', departureTime: '17:00', departureDate: '2025-06-15' }, translator)  // Returns 'Arrive 5:00 PM, Today' (assuming today is 2025-06-15)
1✔
109
 */
1✔
110
export function formatDepartureDisplay(opts, translator = null) {
1✔
111
        if (opts.departureType === 'now') return null;
12✔
112

113
        const timeStr = opts.departureTime || '';
12✔
114
        const dateStr = opts.departureDate || '';
12✔
115

116
        // Use translator if provided, otherwise fall back to English
12✔
117
        const prefix =
12✔
118
                opts.departureType === 'arriveBy'
12✔
119
                        ? translator
12✔
120
                                ? translator('trip-planner.arrive')
3✔
121
                                : 'Arrive'
3✔
122
                        : translator
12✔
123
                                ? translator('trip-planner.depart')
8✔
124
                                : 'Depart';
12✔
125

126
        if (timeStr) {
12✔
127
                const formattedTime = parseTimeInput(timeStr);
9✔
128
                if (!formattedTime) return prefix;
9✔
129

130
                let dateSuffix = '';
8✔
131
                if (dateStr) {
9✔
132
                        const today = Temporal.Now.plainDateISO();
6✔
133
                        const tomorrow = today.add({ days: 1 });
6✔
134

135
                        if (dateStr === today.toJSON()) {
6✔
136
                                const todayLabel = translator ? translator('trip-planner.today') : 'Today';
2!
137
                                dateSuffix = `, ${todayLabel}`;
2✔
138
                        } else if (dateStr === tomorrow.toJSON()) {
6✔
139
                                const tomorrowLabel = translator ? translator('trip-planner.tomorrow') : 'Tomorrow';
2✔
140
                                dateSuffix = `, ${tomorrowLabel}`;
2✔
141
                        } else {
2✔
142
                                dateSuffix = `, ${dateStr}`;
2✔
143
                        }
2✔
144
                }
6✔
145

146
                return `${prefix} ${formattedTime}${dateSuffix}`;
8✔
147
        }
8✔
148

149
        return prefix;
2✔
150
}
2✔
151

152
/**
1✔
153
 * Parse HTML time input (HH:mm, 24-hour) to OTP format (h:mm AM/PM).
1✔
154
 *
1✔
155
 * Uses Temporal.PlainTime.from to parse the time string to avoid timezone issues.
1✔
156
 * If the time string is already in "h:mm AM/PM" format, it is returned unchanged.
1✔
157
 *
1✔
158
 * @param {string} timeString - Time in "HH:mm" format (24-hour)
1✔
159
 * @returns {string|null} Time in "h:mm AM/PM" format, or null if invalid
1✔
160
 *
1✔
161
 * @example
1✔
162
 * parseTimeInput('14:30')  // Returns '2:30 PM'
1✔
163
 * parseTimeInput('00:00')  // Returns '12:00 AM'
1✔
164
 * parseTimeInput('12:00')  // Returns '12:00 PM'
1✔
165
 * parseTimeInput('09:05')  // Returns '9:05 AM'
1✔
166
 */
1✔
167
export function parseTimeInput(timeString) {
1✔
168
        if (!timeString || typeof timeString !== 'string') {
44✔
169
                return null;
5✔
170
        }
5✔
171

172
        // Try to parse as already-converted format
39✔
173
        const matchAlreadyConverted = timeString.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
39✔
174
        if (matchAlreadyConverted) {
44✔
175
                return timeString;
2✔
176
        }
2✔
177

178
        // Try to parse as 24-hour format
37✔
179
        const match24Hour = timeString.match(/^(\d{2}):(\d{2})$/);
37✔
180
        if (!match24Hour) {
44✔
181
                return null;
7✔
182
        }
7✔
183

184
        try {
30✔
185
                const time = Temporal.PlainTime.from(timeString);
30✔
186
                return apiTimeFormat.format(time);
30✔
187
        } catch {
44✔
188
                return null;
3✔
189
        }
3✔
190
}
44✔
191

192
/**
1✔
193
 * Parse HTML date input (YYYY-MM-DD) to OTP format (MM-DD-YYYY).
1✔
194
 *
1✔
195
 * Uses Temporal.PlainDate.from() for validation and parsing.
1✔
196
 *
1✔
197
 * @param {string} dateString - Date in "YYYY-MM-DD" format
1✔
198
 * @returns {string|null} Date in "MM-DD-YYYY" format, or null if invalid
1✔
199
 *
1✔
200
 * @example
1✔
201
 * parseDateInput('2026-01-14')  // Returns '01-14-2026'
1✔
202
 * parseDateInput('2026-12-31')  // Returns '12-31-2026'
1✔
203
 */
1✔
204
export function parseDateInput(dateString) {
1✔
205
        if (!dateString || typeof dateString !== 'string') {
28✔
206
                return null;
5✔
207
        }
5✔
208

209
        const match = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
23✔
210
        if (!match) {
28✔
211
                return null;
4✔
212
        }
4✔
213

214
        try {
19✔
215
                const dateTime = Temporal.PlainDate.from(dateString);
19✔
216
                if (dateTime.year < 2000 || dateTime.year > 2100) {
28✔
217
                        return null;
2✔
218
                }
2✔
219
                return apiDateFormat.format(dateTime).replaceAll('/', '-');
13✔
220
        } catch {
28✔
221
                return null;
4✔
222
        }
4✔
223
}
28✔
224

225
/**
1✔
226
 * Format a 24-hour hour to 12-hour format
1✔
227
 *
1✔
228
 * @param {number} hour - Hour in 24-hour format
1✔
229
 * @returns {number|null} Hour in 12-hour format, or null if invalid
1✔
230
 *
1✔
231
 * @example
1✔
232
 * convert24HourTo12Hour(0)  // Returns 12
1✔
233
 * convert24HourTo12Hour(12)  // Returns 12
1✔
234
 * convert24HourTo12Hour(14)  // Returns 2
1✔
235
 * convert24HourTo12Hour(23)  // Returns 11
1✔
236
 */
1✔
237
export function convert24HourTo12Hour(hour) {
1✔
238
        const hourNum = typeof hour === 'string' ? Number(hour) : hour;
14✔
239
        if (!Number.isFinite(hourNum)) return null;
14✔
240
        if (hourNum < 0 || hourNum > 23) return null;
14✔
241
        if (hourNum === 0) return 12;
14✔
242
        if (hourNum > 12) return hourNum - 12;
14✔
243
        return hourNum;
2✔
244
}
2✔
245

246
/**
1✔
247
 * Format a Date object to OTP API time format: "h:mm AM/PM"
1✔
248
 *
1✔
249
 * @param {Date} date - Date object
1✔
250
 * @param {string} [timeZone] - IANA timezone (e.g. "America/Los_Angeles"). Defaults to local.
1✔
251
 * @returns {string} Time in "h:mm AM/PM" format
1✔
252
 *
1✔
253
 * @example
1✔
254
 * formatTimeForOTP(new Date('2026-01-14T14:30:00'))  // Returns '2:30 PM'
1✔
255
 */
1✔
256
export function formatTimeForOTP(date, timeZone) {
1✔
257
        if (timeZone) {
11✔
258
                return new Intl.DateTimeFormat('en-US', {
5✔
259
                        hour: 'numeric',
5✔
260
                        minute: '2-digit',
5✔
261
                        hour12: true,
5✔
262
                        timeZone
5✔
263
                }).format(date);
5✔
264
        }
5✔
265
        return apiTimeFormat.format(date);
6✔
266
}
6✔
267

268
/**
1✔
269
 * Format a Date object to OTP API date format: "MM-DD-YYYY"
1✔
270
 * Used for "Leave Now" mode where we need the current date.
1✔
271
 *
1✔
272
 * @param {Date} date - Date object
1✔
273
 * @param {string} [timeZone] - IANA timezone (e.g. "America/Los_Angeles"). Defaults to local.
1✔
274
 * @returns {string} Date in "MM-DD-YYYY" format
1✔
275
 *
1✔
276
 * @example
1✔
277
 * formatDateForOTP(new Date(2026, 0, 14))  // Returns '01-14-2026'
1✔
278
 */
1✔
279
export function formatDateForOTP(date, timeZone) {
1✔
280
        if (timeZone) {
10✔
281
                return new Intl.DateTimeFormat('en-US', {
5✔
282
                        year: 'numeric',
5✔
283
                        month: '2-digit',
5✔
284
                        day: '2-digit',
5✔
285
                        timeZone
5✔
286
                })
5✔
287
                        .format(date)
5✔
288
                        .replaceAll('/', '-');
5✔
289
        }
5✔
290
        return apiDateFormat.format(date).replaceAll('/', '-');
5✔
291
}
5✔
292

293
/**
1✔
294
 * Format a timestamp to a last updated string
1✔
295
 *
1✔
296
 * @param {number} timestamp - Timestamp in milliseconds since Unix epoch
1✔
297
 * @param {Object} translations - Object containing translation strings for minutes, seconds, and ago
1✔
298
 * @param {string} translations.min - Translation string for minutes
1✔
299
 * @param {string} translations.sec - Translation string for seconds
1✔
300
 * @param {string} translations.ago - Translation string for ago
1✔
301
 * @returns {string} Formatted last updated string
1✔
302
 *
1✔
303
 * @example
1✔
304
 * Note: The actual output of these examples depends on the current time
1✔
305
 * formatLastUpdated(1715894400000, { min: 'min', sec: 'sec', ago: 'ago' })  // Returns '1 min 30 sec ago'
1✔
306
 * formatLastUpdated(1715894400000, { min: 'minute', sec: 'second', ago: 'ago' })  // Returns '1 minute 30 second ago'
1✔
307
 */
1✔
308
export function formatLastUpdated(timestamp, translations) {
1✔
309
        if (!Number.isFinite(timestamp)) return 'N/A';
12✔
310
        const date = Temporal.Instant.fromEpochMilliseconds(timestamp);
6✔
311
        const now = Temporal.Now.instant();
6✔
312
        const { minutes, seconds } = now.since(date).round({ largestUnit: 'minute' });
6✔
313

314
        const minutesStr = minutes > 0 ? `${minutes} ${translations.min} ` : '';
12✔
315
        return `${minutesStr}${seconds} ${translations.sec} ${translations.ago}`;
12✔
316
}
12✔
317

318
/**
1✔
319
 * Convert date ("MM-DD-YYYY") + time ("h:mm AM/PM") to OffsetDateTime
1✔
320
 * ("YYYY-MM-DDThh:mm:ss±HH:MM") as required by OTP 2.x GraphQL API.
1✔
321
 *
1✔
322
 * The timezone is used to compute the correct UTC offset for the target date,
1✔
323
 * handling DST transitions correctly. When timeZone is omitted, falls back to
1✔
324
 * the server process's locale.
1✔
325
 *
1✔
326
 * @param {string} date - Date in "MM-DD-YYYY" format
1✔
327
 * @param {string} time - Time in "h:mm AM/PM" format
1✔
328
 * @param {string} [timeZone] - IANA timezone (e.g. "America/Los_Angeles"). Defaults to server locale.
1✔
329
 * @returns {string|null} OffsetDateTime string, or null if time or date format is invalid
1✔
330
 */
1✔
331
export function convertToISO8601(date, time, timeZone) {
1✔
332
        if (!date || typeof date !== 'string') return null;
54✔
333
        if (!time || typeof time !== 'string') return null;
54✔
334

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

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

341
        const month = parseInt(dateMatch[1], 10);
46✔
342
        const day = parseInt(dateMatch[2], 10);
46✔
343
        const year = parseInt(dateMatch[3], 10);
46✔
344

345
        let hour = parseInt(timeMatch[1], 10);
46✔
346
        const minute = parseInt(timeMatch[2], 10);
46✔
347
        const period = timeMatch[3].toUpperCase();
46✔
348

349
        if (period === 'AM' && hour === 12) hour = 0;
54✔
350
        else if (period === 'PM' && hour !== 12) hour += 12;
43✔
351

352
        try {
46✔
353
                const plainDateTime = Temporal.PlainDateTime.from({ year, month, day, hour, minute });
46✔
354
                // toZonedDateTime resolves DST correctly for the target date
46✔
355
                const zdt = plainDateTime.toZonedDateTime(timeZone || getLocalTimeZone());
54✔
356

357
                // Use Temporal-resolved values so DST gaps produce a valid datetime
54✔
358
                // (e.g. 2:30 AM during spring-forward resolves to 3:30 AM)
54✔
359
                const yearStr = String(zdt.year).padStart(4, '0');
54✔
360
                const monthStr = String(zdt.month).padStart(2, '0');
54✔
361
                const dayStr = String(zdt.day).padStart(2, '0');
54✔
362
                const hourStr = String(zdt.hour).padStart(2, '0');
54✔
363
                const minuteStr = String(zdt.minute).padStart(2, '0');
54✔
364

365
                return `${yearStr}-${monthStr}-${dayStr}T${hourStr}:${minuteStr}:00${zdt.offset}`;
54✔
366
        } catch (err) {
54✔
367
                if (err instanceof RangeError) return null;
1!
NEW
368
                throw err;
×
369
        }
×
370
}
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