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).
This commit is contained in:
a0666660 2026-10-03 21:16:18 +08:00
parent 92f4d60055
commit 9e4e2c3d24
2 changed files with 223 additions and 93 deletions

View file

@ -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$/);
});

View file

@ -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'];
}