diff --git a/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts index d2a4d4c09..15ffc792b 100644 --- a/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts +++ b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts @@ -8,13 +8,7 @@ import type { RoadtripDay } from '@trek/shared/roadtrip' import type { MTripShellApi, TripPlanner } from '../MTripShell' import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel' import type { Category, Place } from '../../../../types' -import { localIsoDate } from '../../../../utils/localDate' - -/** Minutes since midnight, local time. */ -const nowMinutes = (): number => { - const d = new Date() - return d.getHours() * 60 + d.getMinutes() -} +import { isLocalToday, localMinutes } from '../../../../utils/localDate' /** The same 30 s tick the day timeline runs on, so the two never disagree by a minute. */ const TICK_MS = 30_000 @@ -52,11 +46,11 @@ export interface MRoadtripController { export function useMRoadtrip(planner: TripPlanner): MRoadtripController { const { roadtripRoutes, selectedDayId, days } = planner const vehicle = useRoadtripSettings(s => s.roadtrip_vehicle, planner.tripId) - const [minutes, setMinutes] = useState(nowMinutes) + const [minutes, setMinutes] = useState(localMinutes) const [offline, setOffline] = useState(isEffectivelyOffline) useEffect(() => { - const id = window.setInterval(() => setMinutes(nowMinutes()), TICK_MS) + const id = window.setInterval(() => setMinutes(localMinutes()), TICK_MS) return () => window.clearInterval(id) }, []) @@ -88,14 +82,13 @@ export function useMRoadtrip(planner: TripPlanner): MRoadtripController { // "Today" is the stage's own date, not the selected day's index: a trip can be // planned for next year, and a countdown on a day in March is noise. - const isToday = useMemo(() => { - const date = days.find(d => d.id === selectedDayId)?.date - if (!date) return false - // The wall clock, not UTC: `nowMinutes` above is local, and between local midnight - // and the UTC rollover the two disagree, so east of Greenwich the card hung on - // yesterday's stage for the first hours of every night. `localDate.ts` says as much. - return date.slice(0, 10) === localIsoDate() - }, [days, selectedDayId]) + // The wall clock, not UTC: the minutes above are local, and between local midnight + // and the UTC rollover the two disagree, so east of Greenwich the card hung on + // yesterday's stage for the first hours of every night. `localDate.ts` says as much. + const isToday = useMemo( + () => isLocalToday(days.find(d => d.id === selectedDayId)?.date), + [days, selectedDayId], + ) return { stage, diff --git a/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts b/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts index 5789b32a6..bff0f0083 100644 --- a/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts +++ b/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts @@ -4,13 +4,7 @@ import { stageOf, upNextStop } from '../../../../components/Roadtrip/roadtripRow import { useNetworkMode } from '../../../../hooks/useNetworkMode' import type { CorridorPoi } from '../../../../components/Roadtrip/useCorridorPois' import type { MTripShellApi, TripPlanner } from '../MTripShell' -import { localIsoDate } from '../../../../utils/localDate' - -/** Minutes since midnight, local time: the same reading the stage screen takes. */ -const nowMinutes = (): number => { - const d = new Date() - return d.getHours() * 60 + d.getMinutes() -} +import { isLocalToday, localMinutes } from '../../../../utils/localDate' export interface MRtCorridorController { /** What is being looked for. Several at once: they ride one query per box. */ @@ -94,8 +88,8 @@ export function useMRtCorridor(planner: TripPlanner, shell: MTripShellApi): MRtC const date = planner.days.find(d => d.id === planner.selectedDayId)?.date // Local, for the reason useMRoadtrip gives: the clock this is compared against is the // wall clock, and UTC disagrees with it for the first hours of every night. - const isToday = !!date && date.slice(0, 10) === localIsoDate() - const next = upNextStop(stage, nowMinutes(), isToday) + const isToday = isLocalToday(date) + const next = upNextStop(stage, localMinutes(), isToday) if (!next) return -1 return stage.stops.findIndex(s => s.assignmentId === next.row.stop.assignmentId) }, [stage, planner.days, planner.selectedDayId]) diff --git a/client/src/utils/localDate.test.ts b/client/src/utils/localDate.test.ts index 84cd314e6..2078e5ec6 100644 --- a/client/src/utils/localDate.test.ts +++ b/client/src/utils/localDate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { localIsoDate } from './localDate' +import { isLocalToday, localIsoDate, localMinutes } from './localDate' /** * "Today" must be the user's local calendar date. `toISOString()` is the UTC @@ -26,3 +26,43 @@ describe('localIsoDate', () => { expect(localIsoDate()).toBe(localIsoDate(new Date())) }) }) + +describe('localMinutes', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('counts the minutes since local midnight', () => { + expect(localMinutes(new Date(2026, 7, 25, 0, 0))).toBe(0) + expect(localMinutes(new Date(2026, 7, 25, 13, 45, 59))).toBe(825) + expect(localMinutes(new Date(2026, 7, 25, 23, 59))).toBe(1439) + }) + + it('defaults to now', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 7, 25, 0, 30)) + expect(localMinutes()).toBe(30) + }) +}) + +describe('isLocalToday', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 7, 25, 0, 30)) // local 2026-08-25 00:30 + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('matches the local calendar date, with or without a time part', () => { + expect(isLocalToday('2026-08-25')).toBe(true) + expect(isLocalToday('2026-08-25T00:00:00.000Z')).toBe(true) + }) + + it('is false for another date or no date at all', () => { + expect(isLocalToday('2026-08-24')).toBe(false) + expect(isLocalToday('')).toBe(false) + expect(isLocalToday(null)).toBe(false) + expect(isLocalToday(undefined)).toBe(false) + }) +}) diff --git a/client/src/utils/localDate.ts b/client/src/utils/localDate.ts index fbac8e539..834c65f4f 100644 --- a/client/src/utils/localDate.ts +++ b/client/src/utils/localDate.ts @@ -14,3 +14,16 @@ export function localIsoDate(d: Date = new Date()): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } + +/** Minutes since local midnight: the wall clock a day's schedule is read against. */ +export function localMinutes(d: Date = new Date()): number { + return d.getHours() * 60 + d.getMinutes() +} + +/** + * Whether a trip day's date (anything starting 'YYYY-MM-DD') is today on the wall + * clock. Local for the reason `localIsoDate` gives; a day without a date is never today. + */ +export function isLocalToday(date: string | null | undefined): boolean { + return !!date && date.slice(0, 10) === localIsoDate() +}