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.
|
// 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)
|
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
|
return drafted ? addDays(thisWeek, 7) : thisWeek
|
||||||
}
|
}
|
||||||
|
|
||||||
// untilLocalMidnight is how many milliseconds after instant now the next
|
// wallClock is the household-local wall clock at instant t in IANA zone,
|
||||||
// midnight comes in IANA zone. It counts from the zone's wall clock at now,
|
// read as if it were UTC: the zone's UTC offset at t is wallClock - t.
|
||||||
// so a change of UTC offset before that midnight moves it by the change;
|
function wallClock(zone: string, t: number): number {
|
||||||
// offsets change in the small hours, after midnight.
|
|
||||||
export function untilLocalMidnight(zone: string, now: Date): number {
|
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
timeZone: zone,
|
timeZone: zone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
hourCycle: 'h23',
|
hourCycle: 'h23',
|
||||||
}).formatToParts(now)
|
}).formatToParts(new Date(t))
|
||||||
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
|
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()
|
const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second'))
|
||||||
return DAY_MS - sinceMidnight
|
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