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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user