mirror of
https://github.com/CorentinTh/it-tools.git
synced 2026-10-22 13:51:10 +00:00
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:
parent
92f4d60055
commit
9e4e2c3d24
2 changed files with 223 additions and 93 deletions
|
|
@ -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$/);
|
||||
});
|
||||
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue