Find the actual next local midnight across clock changes (F21-03)
CI / ci (push) Successful in 9m4s
CI / ci (pull_request) Successful in 8m11s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:36:18 -04:00
co-authored by Claude Opus 5.5
parent 0a69efea5c
commit d69852b045
2 changed files with 52 additions and 8 deletions
+25
View File
@@ -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)
})
})
+27 -8
View File
@@ -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
}