diff --git a/apps/web/src/lib/live-order.test.ts b/apps/web/src/lib/live-order.test.ts new file mode 100644 index 0000000..55fa339 --- /dev/null +++ b/apps/web/src/lib/live-order.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { bySoonestEnding } from './live-order'; + +/** Order a list the way EventList does, returning the endsAt values in order. */ +function ordered(...endsAt: (string | null | undefined)[]): (string | null | undefined)[] { + return endsAt + .map((v) => ({ endsAt: v })) + .sort(bySoonestEnding) + .map((e) => e.endsAt); +} + +describe('bySoonestEnding', () => { + it('orders plain UTC timestamps soonest-ending first', () => { + expect(ordered('2026-08-10T17:00:00Z', '2026-08-10T12:00:00Z')).toEqual([ + '2026-08-10T12:00:00Z', + '2026-08-10T17:00:00Z' + ]); + }); + + it('orders an offset timestamp by its instant, not its text', () => { + // The bug: as text '2026-08-10T13:00:00+02:00' sorts AFTER + // '2026-08-10T12:00:00Z', though it ends an hour EARLIER (11:00Z). + expect(ordered('2026-08-10T12:00:00Z', '2026-08-10T13:00:00+02:00')).toEqual([ + '2026-08-10T13:00:00+02:00', + '2026-08-10T12:00:00Z' + ]); + // Pin the text comparison this replaces, so the test fails loudly if + // someone reverts to localeCompare. + expect('2026-08-10T13:00:00+02:00'.localeCompare('2026-08-10T12:00:00Z')).toBeGreaterThan(0); + }); + + it('treats the same instant written two ways as equal', () => { + expect( + bySoonestEnding({ endsAt: '2026-08-10T12:00:00Z' }, { endsAt: '2026-08-10T14:00:00+02:00' }) + ).toBe(0); + }); + + it('ignores millisecond spelling differences that text order would not', () => { + expect(ordered('2026-08-10T12:00:00Z', '2026-08-10T11:59:59.500Z')).toEqual([ + '2026-08-10T11:59:59.500Z', + '2026-08-10T12:00:00Z' + ]); + }); + + it('sorts an event with no end last rather than first', () => { + // Text order put '' ahead of everything, leading a "soonest ending" list + // with the one card that has no knowable end. + expect(ordered(undefined, '2026-08-10T12:00:00Z', null)).toEqual([ + '2026-08-10T12:00:00Z', + undefined, + null + ]); + }); + + it('is a valid comparator when several ends are unknown', () => { + // Infinity - Infinity is NaN; a comparator returning NaN corrupts the sort. + expect(bySoonestEnding({ endsAt: undefined }, { endsAt: 'not a date' })).toBe(0); + expect(ordered(undefined, 'not a date', '2026-08-10T12:00:00Z')[0]).toBe( + '2026-08-10T12:00:00Z' + ); + }); +}); diff --git a/apps/web/src/lib/live-order.ts b/apps/web/src/lib/live-order.ts new file mode 100644 index 0000000..c0db2a7 --- /dev/null +++ b/apps/web/src/lib/live-order.ts @@ -0,0 +1,39 @@ +// How the live band is ordered: soonest-ending first. +// +// A module rather than a closure inside EventList, for the reason `dedupe-by-uri` +// is one — a mis-ordered list still looks like a list, so this is exactly the +// kind of logic that needs a test rather than an eye. + +/** The instant an event ends, as a number for ordering. Absent or unparseable + * sorts LAST: a card with no knowable finish should not lead a list whose whole + * claim is what finishes soonest. */ +function endsAtInstant(event: { endsAt?: string | null }): number { + const ms = event.endsAt ? Date.parse(event.endsAt) : NaN; + return Number.isNaN(ms) ? Infinity : ms; +} + +/** + * Compare two events by WHEN THEY END — by the instant each timestamp names, + * never by its text. + * + * The distinction is not academic here. These records are hydrated from D1 + * exactly as they were written, and RFC 3339 permits a zone offset which the + * importers preserve verbatim (an .ics in Denver yields + * `2026-08-10T09:00:00-06:00`; see lib/import/ical.test.ts). Compared as + * strings, `13:00+02:00` sorts AFTER a `12:00Z` that it actually precedes by an + * hour — so the band would name the wrong event as finishing first, in a + * section whose only ordering claim is that one. + * + * The search index avoids this by normalizing at write time (see + * search/server/normalize.ts), but nothing normalizes the records that reach the + * browser: the search path indexes normalized timestamps and then hydrates the + * ORIGINALS for display. This is where that gap is closed. + */ +export function bySoonestEnding( + a: { endsAt?: string | null }, + b: { endsAt?: string | null } +): number { + const x = endsAtInstant(a); + const y = endsAtInstant(b); + return x === y ? 0 : x < y ? -1 : 1; +}