fix(client): compute "today" from the local calendar, not UTC

This commit is contained in:
jubnl
2026-08-25 00:44:42 +02:00
parent 67b71a52d1
commit efcdbd94fb
20 changed files with 171 additions and 115 deletions
+30
View File
@@ -59,3 +59,33 @@ describe('computeJourneyLifecycle', () => {
expect(computeJourneyLifecycle('published', null, TODAY)).toBe('live')
})
})
/**
* The midnight-window regression: "today" must be the LOCAL calendar date.
* The old code derived it from toISOString() (the UTC date), so between local
* midnight and the UTC rollover a journey running through yesterday still
* showed 'live' and one starting today showed 'upcoming'. Pinned at 00:30
* local — vacuous on a UTC machine, biting on any TZ ahead of UTC.
*/
describe('computeJourneyLifecycle — local today at the midnight window', () => {
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 25, 0, 30)) // local 2026-08-25 00:30
})
afterAll(() => {
vi.useRealTimers()
})
it('a journey spanning the local today is live', () => {
expect(computeJourneyLifecycle('active', '2026-08-25', '2026-08-26')).toBe('live')
})
it('a journey that ended yesterday (local) is completed', () => {
expect(computeJourneyLifecycle('active', '2026-08-20', '2026-08-24')).toBe('completed')
})
it('single boundaries follow the local date too', () => {
expect(computeJourneyLifecycle('active', '2026-08-25', null)).toBe('live') // starts today → live
expect(computeJourneyLifecycle('active', null, '2026-08-24')).toBe('completed') // ended yesterday
})
})
+5 -3
View File
@@ -1,3 +1,5 @@
import { localIsoDate } from './localDate'
export type JourneyLifecycle = 'archived' | 'live' | 'upcoming' | 'completed' | 'draft'
export function computeJourneyLifecycle(
@@ -8,7 +10,7 @@ export function computeJourneyLifecycle(
if (status === 'archived') return 'archived'
if (tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
const today = localIsoDate()
if (tripDateMin <= today && today <= tripDateMax) return 'live'
if (tripDateMin > today) return 'upcoming'
return 'completed'
@@ -20,11 +22,11 @@ export function computeJourneyLifecycle(
// Single boundary: only start or only end
if (tripDateMin && !tripDateMax) {
const today = new Date().toISOString().split('T')[0]
const today = localIsoDate()
return tripDateMin > today ? 'upcoming' : 'live'
}
if (!tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
const today = localIsoDate()
return tripDateMax < today ? 'completed' : 'live'
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { localIsoDate } from './localDate'
/**
* "Today" must be the user's local calendar date. `toISOString()` is the UTC
* date — between local midnight and the UTC rollover it is YESTERDAY for any
* TZ ahead of UTC, which misclassified trips/journeys in that window. On a UTC
* machine local == UTC, so the distinction is vacuous there; these tests pin
* the clock at 00:30 local, where any positive offset separates the two.
*/
describe('localIsoDate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 25, 0, 30)) // local 2026-08-25 00:30
})
afterEach(() => {
vi.useRealTimers()
})
it('formats the local calendar date, zero-padded', () => {
expect(localIsoDate()).toBe('2026-08-25')
expect(localIsoDate(new Date(2026, 0, 3, 12))).toBe('2026-01-03')
})
it('defaults to now', () => {
expect(localIsoDate()).toBe(localIsoDate(new Date()))
})
})
+16
View File
@@ -0,0 +1,16 @@
/**
* The user's LOCAL calendar date as 'YYYY-MM-DD'.
*
* Never derive "today" from `toISOString().split('T')[0]` — that is the UTC
* date, which disagrees with the wall clock between local midnight and the UTC
* rollover (for a UTC+2 user, from 00:00 to 02:00 every night). Trip and
* journey dates are wall-clock dates, so every "is this today/past/upcoming"
* comparison must use this instead.
*
* (Converting a Date you deliberately built in UTC — `new Date(s + 'T00:00:00Z')`
* round-tripped through date arithmetic — is a different, self-consistent
* pattern and fine as it is.)
*/
export function localIsoDate(d: Date = new Date()): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}