Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions src/courier/ups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand All @@ -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)
})
}

Expand Down
19 changes: 8 additions & 11 deletions src/courier/usps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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'
)
}
})

Expand Down
30 changes: 30 additions & 0 deletions test/checkpoint-time.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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')
Expand Down
Loading