diff --git a/CHANGELOG.md b/CHANGELOG.md index 5507773..33d8c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,14 @@ Rewritten in TypeScript. This release is **breaking** — see the migration note `DD-MMM-YYYYHH:mm`. v2 read that as `2001-01-18T17:53`; it is `2017-01-18T23:53`. * `cesco` — Indonesian month names are expanded to full English names, so the format needs `MMMM` rather than `MMM`. +* `ups` — UPS reports a 12-hour clock as `10:53 P.M.`, but the format string read it as + `HH:mm`, so every afternoon event was recorded twelve hours early. Reported in #35 by + @aldin-alagic, whose fix could not be merged once the sources moved to `src/`. +* `usps` — whitespace inside the date cell is collapsed before parsing, so runs of spaces + no longer break it. Also from #35. * Added a sweep test asserting every courier's checkpoints carry a parseable timestamp — - the per-courier tests only ever checked `number` and `status`. + the per-courier tests only ever checked `number` and `status`. Timestamps that parse but + land on the wrong hour are now asserted explicitly for the couriers above. ## Notes * Added a "Reporting a broken courier" guide to the README and a matching GitHub issue diff --git a/src/courier/ups.ts b/src/courier/ups.ts index fd4729e..873e551 100644 --- a/src/courier/ups.ts +++ b/src/courier/ups.ts @@ -74,6 +74,18 @@ function parseToken(setCookie: string | string[] | undefined): string { return '' } +/** + * UPS reports a 12-hour clock as "10:53 P.M.". Reading it as `HH:mm` silently shifts + * every afternoon event back by twelve hours, so the meridiem has to be parsed — and the + * periods stripped first, since dayjs's `A` token only matches "PM". + * + * Reported in #35 by @aldin-alagic. + */ +function parseTime(date: string, time: string | undefined): string { + const meridiem = (time ?? '').replace(/\./g, '') + return dayjs(`${date} ${meridiem}`, 'MM/DD/YYYY h:mm A').format('YYYY-MM-DDTHH:mmZ') +} + function parse(detail: TrackDetail): TraceResult { const checkpoints: Checkpoint[] = [] @@ -88,9 +100,7 @@ function parse(detail: TrackDetail): TraceResult { location: activity.location, message, status: message.includes('DELIVERED') ? STATUS.DELIVERED : STATUS.IN_TRANSIT, - time: dayjs([activity.date, activity.time].join(' '), 'MM/DD/YYYY HH:mm').format( - 'YYYY-MM-DDTHH:mmZ' - ) + time: parseTime(activity.date, activity.time) }) } diff --git a/src/courier/usps.ts b/src/courier/usps.ts index 31b7674..55ebe88 100644 --- a/src/courier/usps.ts +++ b/src/courier/usps.ts @@ -21,9 +21,11 @@ function trackingInfo(number: string): TrackingRequest { } } -// The date cell comes in three shapes: "March 16, 2024,1:55 pm" (no space after the -// second comma), "March 7, 2024,9:15 pm" (unpadded day) and "March 13, 2024" (no time). -const DATE_FORMATS = ['MMMM D, YYYY,h:mm a', 'MMMM D, YYYY'] +// The date cell has appeared in several shapes over the years: "March 16, 2024,1:55 pm" +// (no space after the second comma), "March 7, 2024,9:15 pm" (unpadded day), +// "March 13, 2024" (no time) and, per #35, runs of whitespace in the middle of the +// string. Whitespace is collapsed first, then these formats cover the rest. +const DATE_FORMATS = ['MMMM D, YYYY, h:mm a', 'MMMM D, YYYY,h:mm a', 'MMMM D, YYYY'] function toStatus(message: string): Checkpoint['status'] { if (message.includes('Delivered')) { @@ -52,14 +54,9 @@ function parse(html: string): TraceResult { location: $el.find('.tb-location').text().trim(), message, status: toStatus(message), - time: dayjs( - $el - .find('.tb-date') - .text() - .trim() - .replace(/[\t\n]/g, ''), - DATE_FORMATS - ).format('YYYY-MM-DDTHH:mm') + time: dayjs($el.find('.tb-date').text().replace(/\s+/g, ' ').trim(), DATE_FORMATS).format( + 'YYYY-MM-DDTHH:mm' + ) } }) diff --git a/test/checkpoint-time.test.ts b/test/checkpoint-time.test.ts index c1d9cdb..2f59606 100644 --- a/test/checkpoint-time.test.ts +++ b/test/checkpoint-time.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import dayjs from '../src/dayjs.js' import { COURIER, type CourierCode, courier } from '../src/index.js' import prepare from './fixtures/prepare.js' @@ -73,6 +74,35 @@ describe('checkpoint timestamps', () => { assert.equal(result.checkpoints[12]?.time, '2024-03-07T21:15') }) + it('parses UPS afternoon times as PM', async () => { + const ups = courier(COURIER.UPS.CODE) + prepare(ups, 'DELIVEREDUPS') + + const result = await ups.trace('DELIVEREDUPS') + + // The fixture's first activity is "09/29/2020" + "12:29 P.M.". Reading the clock as + // 24-hour silently moved every P.M. event back twelve hours (#35). + assert.match(result.checkpoints[0]?.time ?? '', /T12:29/) + + const evening = result.checkpoints.find((c) => c.time.includes('2020-09-28')) + assert.ok(evening, 'expected an activity from 28 September') + // "10:53 P.M." must be 22:53, not 10:53. + assert.match(evening.time, /T22:53/) + }) + + it('parses USPS dates padded with extra whitespace', async () => { + // Reported in #35: the page has shipped runs of spaces inside the date string. + const spaced = 'November 17, 2017, 3:08 pm' + assert.equal( + dayjs(spaced.replace(/\s+/g, ' ').trim(), [ + 'MMMM D, YYYY, h:mm a', + 'MMMM D, YYYY,h:mm a', + 'MMMM D, YYYY' + ]).format('YYYY-MM-DDTHH:mm'), + '2017-11-17T15:08' + ) + }) + it('parses Royal Mail day-first dates', async () => { const royalmail = courier(COURIER.ROYALMAIL.CODE) prepare(royalmail, 'LBTRANSIT')