From 9e4e2c3d24719269dc9cedc73df347f208e85184 Mon Sep 17 00:00:00 2001 From: a0666660 <69061030+a0666660@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:16:18 +0800 Subject: [PATCH] fix(timezone-converter): resolve offsets by fixed-point iteration Differential sweeps against Intl's own shortOffset rendering (418 zones x 920 instants, 384k pairs) and 3.3k A->B->A roundtrips surfaced two issues: - offsetLabel carried sub-second residue from Date milliseconds into the sign test, rendering a zero offset as UTC-0 for instants with non-zero ms; round to whole minutes first - the single-pass source-offset lookup misparsed wall times near midnight-adjacent fall-back transitions: EU end-of-DST moves the offset at 01:00 UTC, so the offset at the naive time treated as UTC (CET +1) differs from the offset at the true instant (CEST +2) and 2027-10-31 01:05:27 Europe/Malta parsed 1 hour late. Iterate the offset to a fixed point; realizable wall times converge in <= 2 steps, gap/fold times settle on an adjacent offset. The sweep and roundtrip suites stay in the tree as a differential regression (timezone-converter.differential.test.ts). --- .../timezone-converter.differential.test.ts | 111 ++++++++++ .../timezone-converter.models.ts | 205 ++++++++++-------- 2 files changed, 223 insertions(+), 93 deletions(-) create mode 100644 src/tools/timezone-converter/timezone-converter.differential.test.ts diff --git a/src/tools/timezone-converter/timezone-converter.differential.test.ts b/src/tools/timezone-converter/timezone-converter.differential.test.ts new file mode 100644 index 00000000..054567e5 --- /dev/null +++ b/src/tools/timezone-converter/timezone-converter.differential.test.ts @@ -0,0 +1,111 @@ +import { expect, it } from 'vitest'; +import { convertBetweenTimezones, listTimezones } from './timezone-converter.models'; + +// Mirror of the internal offset function (module doesn't export it). +function getTimeZoneOffsetMs(date: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(date); + const values = Object.fromEntries(parts.map(part => [part.type, part.value])); + const asUtc = Date.UTC( + Number(values.year), + Number(values.month) - 1, + Number(values.day), + Number(values.hour) % 24, + Number(values.minute), + Number(values.second), + ); + return asUtc - date.getTime(); +} + +function offsetLabel(offsetMs: number): string { + const totalMinutes = Math.round(offsetMs / 60000); + if (totalMinutes === 0) { + return 'UTC'; + } + const sign = totalMinutes < 0 ? '-' : '+'; + const abs = Math.abs(totalMinutes); + const hours = Math.floor(abs / 60); + const minutes = abs % 60; + const minutesLabel = minutes > 0 ? `:${String(minutes).padStart(2, '0')}` : ''; + return `UTC${sign}${hours}${minutes > 0 ? minutesLabel : ''}`; +} + +it('offset matches Intl shortOffset rendering across all zones and dense instants', () => { + const zones = listTimezones().filter(z => z !== 'UTC'); + const now = Date.now(); + let checked = 0; + for (const zone of zones) { + const formatter = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'shortOffset' }); + for (let dayOffset = -400; dayOffset <= 400; dayOffset += 7) { + for (const hour of [0, 3, 6, 9, 12, 15, 18, 21]) { + const date = new Date(now + dayOffset * 86400_000 + hour * 3600_000); + const mine = offsetLabel(getTimeZoneOffsetMs(date, zone)); + const intlName = formatter.formatToParts(date).find(p => p.type === 'timeZoneName')!.value; + // The tool styles zero offsets as 'UTC' while en-US shortOffset + // renders 'GMT+0'; normalize before comparing. + const normalized = /GMT\+?0$/.test(intlName) ? 'GMT' : intlName; + const expected = normalized === 'GMT' ? 'UTC' : `UTC${normalized.slice(3)}`; + expect(mine, `${zone} @ ${date.toISOString()}`).toBe(expected); + checked++; + } + } + } + expect(checked).toBeGreaterThan(300000); +}); + +it('A to B to A roundtrip is lossless across zone pairs', () => { + const zones = listTimezones(); + const now = Date.now(); + let roundtrips = 0; + for (const fromTz of zones) { + for (const toTz of zones.slice(0, 8)) { + if (fromTz === toTz) { + continue; + } + const instant = new Date(now + ((roundtrips * 3_600_000 * 7) % (400 * 86400_000))); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: fromTz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(instant); + const v = Object.fromEntries(parts.map(p => [p.type, p.value])); + const naive = `${v.year}-${v.month}-${v.day} ${v.hour}:${v.minute}:${v.second}`; + const forward = convertBetweenTimezones({ dateTime: naive, fromTz, toTz }); + const back = convertBetweenTimezones({ dateTime: forward.formatted, fromTz: toTz, toTz: fromTz }); + expect(back.formatted, `${fromTz}->${toTz} (forward ${forward.formatted})`).toBe(naive); + roundtrips++; + } + } + expect(roundtrips).toBeGreaterThan(1000); +}); + +it('resolves wall times near midnight-adjacent fall-back transitions (Malta regression)', () => { + // EU fall-back happens at 01:00 UTC, so the offset at the naive time + // treated as UTC (CET, +1) differs from the offset at the true instant + // (CEST, +2). The single-pass algorithm parsed this 1 hour late: + // 2027-10-31 01:05:27 CEST is instant 2027-10-30 23:05:27Z, which in + // UTC+3 Asmera renders as 2027-10-31 02:05:27. + const converted = convertBetweenTimezones({ dateTime: '2027-10-31 01:05:27', fromTz: 'Europe/Malta', toTz: 'Africa/Asmera' }); + expect(converted.formatted).toBe('2027-10-31 02:05:27'); + expect(converted.sourceOffset).toBe('UTC+2'); +}); + +it('gap and fold instants resolve to the valid neighborhood', () => { + const gap = convertBetweenTimezones({ dateTime: '2026-03-08 02:30:00', fromTz: 'America/New_York', toTz: 'UTC' }); + expect(gap.formatted).toMatch(/^2026-03-08 0[6-7]:30:00$/); + const fold = convertBetweenTimezones({ dateTime: '2026-11-01 01:30:00', fromTz: 'America/New_York', toTz: 'UTC' }); + expect(fold.formatted).toMatch(/^2026-11-01 0[5-6]:30:00$/); +}); diff --git a/src/tools/timezone-converter/timezone-converter.models.ts b/src/tools/timezone-converter/timezone-converter.models.ts index 62da888c..2ca109f8 100644 --- a/src/tools/timezone-converter/timezone-converter.models.ts +++ b/src/tools/timezone-converter/timezone-converter.models.ts @@ -1,93 +1,112 @@ -export interface TimezoneConversion { - /** The instant, formatted in the target timezone. */ - formatted: string - /** e.g. 'UTC+8' or 'UTC-4:30'. */ - targetOffset: string - sourceOffset: string -} - -/** Offset of a timezone at a given instant, in milliseconds. */ -function getTimeZoneOffsetMs(date: Date, timeZone: string): number { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone, - hour12: false, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).formatToParts(date); - - const values = Object.fromEntries(parts.map(part => [part.type, part.value])); - const asUtc = Date.UTC( - Number(values.year), - Number(values.month) - 1, - Number(values.day), - Number(values.hour) % 24, - Number(values.minute), - Number(values.second), - ); - return asUtc - date.getTime(); -} - -function offsetLabel(offsetMs: number): string { - if (offsetMs === 0) { - return 'UTC'; - } - const sign = offsetMs < 0 ? '-' : '+'; - const total = Math.abs(offsetMs) / 60000; - const hours = Math.floor(total / 60); - const minutes = Math.round(total % 60); - const minutesLabel = minutes > 0 ? `:${String(minutes).padStart(2, '0')}` : ''; - return `UTC${sign}${hours}${minutes > 0 ? minutesLabel : ''}`; -} - -/** Interpret a naive local datetime ('YYYY-MM-DD HH:mm:ss') in `fromTz` and - * render it in `toTz`, honoring each zone's DST rules. */ -export function convertBetweenTimezones({ dateTime, fromTz, toTz }: { dateTime: string; fromTz: string; toTz: string }): TimezoneConversion { - const normalized = dateTime.trim().replace(' ', 'T'); - const assumedUtc = Date.parse(`${normalized}Z`); - if (Number.isNaN(assumedUtc)) { - throw new TypeError('Invalid date/time (expected YYYY-MM-DD HH:mm:ss)'); - } - - const invalidZone = (name: string) => { - try { - // constructing validates the zone identifier - return !Intl.DateTimeFormat('en-US', { timeZone: name }).resolvedOptions().timeZone; - } - catch { - return true; - } - }; - if (invalidZone(fromTz)) { - throw new Error(`Unknown source timezone: ${fromTz}`); - } - if (invalidZone(toTz)) { - throw new Error(`Unknown target timezone: ${toTz}`); - } - - const sourceOffset = getTimeZoneOffsetMs(new Date(assumedUtc), fromTz); - const trueUtc = assumedUtc - sourceOffset; - const targetOffset = getTimeZoneOffsetMs(new Date(trueUtc), toTz); - - const target = new Date(trueUtc + targetOffset); - const formatted = target.toISOString().replace('T', ' ').slice(0, 19); - - return { - formatted, - targetOffset: offsetLabel(targetOffset), - sourceOffset: offsetLabel(sourceOffset), - }; -} - -/** Full IANA list on modern runtimes; a small fallback elsewhere. */ -export function listTimezones(): string[] { - const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] }; - if (typeof intl.supportedValuesOf === 'function') { - const zones = intl.supportedValuesOf('timeZone'); - return zones.includes('UTC') ? zones : ['UTC', ...zones]; - } - return ['UTC', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'America/New_York', 'America/Los_Angeles', 'Asia/Shanghai', 'Asia/Tokyo', 'Australia/Sydney']; -} +export interface TimezoneConversion { + /** The instant, formatted in the target timezone. */ + formatted: string + /** e.g. 'UTC+8' or 'UTC-4:30'. */ + targetOffset: string + sourceOffset: string +} + +/** Offset of a timezone at a given instant, in milliseconds. */ +function getTimeZoneOffsetMs(date: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(date); + + const values = Object.fromEntries(parts.map(part => [part.type, part.value])); + const asUtc = Date.UTC( + Number(values.year), + Number(values.month) - 1, + Number(values.day), + Number(values.hour) % 24, + Number(values.minute), + Number(values.second), + ); + return asUtc - date.getTime(); +} + +function offsetLabel(offsetMs: number): string { + // getTimeZoneOffsetMs carries sub-second residue from Date milliseconds; + // round to whole minutes BEFORE the sign test or a -363 ms residue on a + // zero-offset zone renders as 'UTC-0'. + const totalMinutes = Math.round(offsetMs / 60000); + if (totalMinutes === 0) { + return 'UTC'; + } + const sign = totalMinutes < 0 ? '-' : '+'; + const abs = Math.abs(totalMinutes); + const hours = Math.floor(abs / 60); + const minutes = abs % 60; + const minutesLabel = minutes > 0 ? `:${String(minutes).padStart(2, '0')}` : ''; + return `UTC${sign}${hours}${minutes > 0 ? minutesLabel : ''}`; +} + +/** Interpret a naive local datetime ('YYYY-MM-DD HH:mm:ss') in `fromTz` and + * render it in `toTz`, honoring each zone's DST rules. */ +export function convertBetweenTimezones({ dateTime, fromTz, toTz }: { dateTime: string; fromTz: string; toTz: string }): TimezoneConversion { + const normalized = dateTime.trim().replace(' ', 'T'); + const assumedUtc = Date.parse(`${normalized}Z`); + if (Number.isNaN(assumedUtc)) { + throw new TypeError('Invalid date/time (expected YYYY-MM-DD HH:mm:ss)'); + } + + const invalidZone = (name: string) => { + try { + // constructing validates the zone identifier + return !Intl.DateTimeFormat('en-US', { timeZone: name }).resolvedOptions().timeZone; + } + catch { + return true; + } + }; + if (invalidZone(fromTz)) { + throw new Error(`Unknown source timezone: ${fromTz}`); + } + if (invalidZone(toTz)) { + throw new Error(`Unknown target timezone: ${toTz}`); + } + + // Resolve the source-zone offset by fixed-point iteration. A single pass + // measures the offset at the naive time treated as UTC, which is wrong + // whenever the true instant sits across a nearby transition (e.g. EU + // fall-back at 01:00 UTC makes wall 01:05 CEST correspond to the previous + // day). Iterate until the offset is self-consistent; realizable wall + // times converge in ≤ 2 steps, and gap/fold times settle on one of the + // adjacent offsets. + let sourceOffset = getTimeZoneOffsetMs(new Date(assumedUtc), fromTz); + let trueUtc = assumedUtc - sourceOffset; + for (let i = 0; i < 3; i++) { + const nextOffset = getTimeZoneOffsetMs(new Date(trueUtc), fromTz); + if (nextOffset === sourceOffset) { + break; + } + sourceOffset = nextOffset; + trueUtc = assumedUtc - sourceOffset; + } + const targetOffset = getTimeZoneOffsetMs(new Date(trueUtc), toTz); + + const target = new Date(trueUtc + targetOffset); + const formatted = target.toISOString().replace('T', ' ').slice(0, 19); + + return { + formatted, + targetOffset: offsetLabel(targetOffset), + sourceOffset: offsetLabel(sourceOffset), + }; +} + +/** Full IANA list on modern runtimes; a small fallback elsewhere. */ +export function listTimezones(): string[] { + const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] }; + if (typeof intl.supportedValuesOf === 'function') { + const zones = intl.supportedValuesOf('timeZone'); + return zones.includes('UTC') ? zones : ['UTC', ...zones]; + } + return ['UTC', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'America/New_York', 'America/Los_Angeles', 'Asia/Shanghai', 'Asia/Tokyo', 'Australia/Sydney']; +}