refactor(roadtrip): read the wall clock minutes and today through localDate

useMRoadtrip and useMRtCorridor each kept a nowMinutes helper and their
own check whether the selected day is today. Both now use localMinutes
and isLocalToday from utils/localDate.ts, which hold the same rules.
This commit is contained in:
Maurice
2026-10-09 15:01:56 +02:00
parent 3fcfb12749
commit 60e8f1cd34
4 changed files with 67 additions and 27 deletions
@@ -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,
@@ -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])
+41 -1
View File
@@ -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)
})
})
+13
View File
@@ -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()
}