From d69852b0451b258e5598a01c1242cc2c29aeb9be Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 18:36:18 -0400 Subject: [PATCH] Find the actual next local midnight across clock changes (F21-03) untilLocalMidnight subtracted the local clock time from 24 hours, so on a 23-hour spring-forward day Home refreshed an hour after midnight. It now computes the instant the next calendar day starts in the household zone. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/week.test.ts | 25 +++++++++++++++++++++++++ web/src/plan/week.ts | 35 +++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index 293aa23..3459c50 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -100,4 +100,29 @@ describe('week helpers', () => { // The same instant is 13:00 in Tokyo: eleven hours to go. expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000) }) + + it('counts a day shortened by the spring clock change as 23 hours', () => { + const hour = 60 * 60 * 1000 + // 01:30 EST on Sunday March 8, 2026: clocks go forward at 02:00, so the + // next midnight is 00:00 EDT on March 9 (04:00Z), 21.5 hours away. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T06:30:00Z'))).toBe(21.5 * hour) + // 03:30 EDT, after the change: 20.5 hours to midnight. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T07:30:00Z'))).toBe(20.5 * hour) + // At midnight starting the 23-hour day, the next midnight is 23 hours away. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T05:00:00Z'))).toBe(23 * hour) + // Noon EST the day before: the change is after the coming midnight. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-07T17:00:00Z'))).toBe(12 * hour) + }) + + it('counts a day lengthened by the autumn clock change as 25 hours', () => { + const hour = 60 * 60 * 1000 + // Midnight EDT on Sunday November 1, 2026: clocks go back at 02:00. + expect(untilLocalMidnight('America/New_York', new Date('2026-11-01T04:00:00Z'))).toBe(25 * hour) + }) + + it('starts the next day at the clock change when the clocks skip midnight', () => { + // Santiago goes from 24:00 -04 to 01:00 -03 on September 6, 2026 (04:00Z); + // at 23:00 -04 on September 5 the next day starts an hour later. + expect(untilLocalMidnight('America/Santiago', new Date('2026-09-06T03:00:00Z'))).toBe(60 * 60 * 1000) + }) }) diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index 94ed7e0..f62da2c 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -125,19 +125,38 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number return drafted ? addDays(thisWeek, 7) : thisWeek } -// untilLocalMidnight is how many milliseconds after instant now the next -// midnight comes in IANA zone. It counts from the zone's wall clock at now, -// so a change of UTC offset before that midnight moves it by the change; -// offsets change in the small hours, after midnight. -export function untilLocalMidnight(zone: string, now: Date): number { +// wallClock is the household-local wall clock at instant t in IANA zone, +// read as if it were UTC: the zone's UTC offset at t is wallClock - t. +function wallClock(zone: string, t: number): number { const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', - }).formatToParts(now) + }).formatToParts(new Date(t)) const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value) - const sinceMidnight = ((part('hour') * 60 + part('minute')) * 60 + part('second')) * 1000 + now.getUTCMilliseconds() - return DAY_MS - sinceMidnight + const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second')) + return wall + (((t % 1000) + 1000) % 1000) +} + +// untilLocalMidnight is how many milliseconds after instant now the next +// local day begins in IANA zone. It finds the actual instant of the next +// day's midnight, so a day shortened or lengthened by a change of UTC offset +// is counted as it is. The candidates are that midnight at the zone's offset +// now and at the offset that candidate lands on; the earliest that falls on +// the next day is its start, which is the offset change itself when the +// clocks skip midnight. +export function untilLocalMidnight(zone: string, now: Date): number { + const t = now.getTime() + const tomorrow = addDays(localNow(zone, now).date, 1) + const midnight = midnightUTC(tomorrow).getTime() + const first = midnight - (wallClock(zone, t) - t) + const second = midnight - (wallClock(zone, first) - first) + const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow) + if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`) + return Math.min(...starts) - t }