Skip to content

Commit ca35866

Browse files
committed
Merge fix/355-zoned-ignition: build the ignition instant in the model's timezone
Closes #355.
2 parents ee7df7a + 9d15aca commit ca35866

5 files changed

Lines changed: 222 additions & 18 deletions

File tree

frontend/src/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
NotificationPermissionBanner,
2525
} from './features/Notifications';
2626
import { resolveIgnitionLatitude } from './features/ModelSetup/utils/ignitionLatitude';
27+
import { resolveZonedInstant } from './features/ModelSetup/utils/zonedInstant';
2728
import { runModel } from './services/api';
2829
import { registerServiceWorker } from './services/serviceWorker';
2930
import type { ModelResultsResponse } from './features/ModelReview/types';
@@ -187,7 +188,12 @@ function AppContent() {
187188
}
188189

189190
// Build time range
190-
const startDateTime = new Date(`${data.temporal.startDate}T${data.temporal.startTime}`);
191+
// Resolved in the model's own timezone, never the browser's (#355).
192+
const startDateTime = resolveZonedInstant(
193+
data.temporal.startDate,
194+
data.temporal.startTime,
195+
data.temporal.timezone,
196+
);
191197
const endDateTime = new Date(startDateTime.getTime() + data.temporal.durationHours * 60 * 60 * 1000);
192198

193199
// Helper to read file content

frontend/src/features/ModelSetup/utils/__tests__/weatherPreflight.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type StartingCodeCandidate,
1515
} from '../weatherPreflight.js';
1616
import type { ModelSetupData } from '../../types/index.js';
17+
import { resolveZonedInstant } from '../zonedInstant.js';
1718

1819
const CANDIDATE: StartingCodeCandidate = {
1920
ffmc: 84.65,
@@ -73,14 +74,19 @@ describe('buildIgnitionInstant', () => {
7374
expect(iso).toMatch(/Z$|[+-]\d{2}:\d{2}$/);
7475
});
7576

76-
it('matches what App.tsx sends as timeRange.start, byte for byte', () => {
77-
// Deliberate: App.tsx:189 parses this naive string in the BROWSER zone,
78-
// which is wrong (#355). The gate reproduces that exactly so the reading
79-
// it offers cannot disagree with the run that follows. When #355 is fixed,
80-
// both move together and this test is what proves it.
81-
const { startDate, startTime } = baseData().temporal;
82-
const appTsx = new Date(`${startDate}T${startTime}`).toISOString();
83-
expect(buildIgnitionInstant(baseData().temporal)).toBe(appTsx);
77+
it('resolves the wall clock in the MODEL timezone, not the browser zone', () => {
78+
// 2026-08-04 12:00 in America/Edmonton (MDT, UTC-6) is 18:00Z. Before #355
79+
// this depended on where the operator was sitting.
80+
expect(buildIgnitionInstant(baseData().temporal)).toBe('2026-08-04T18:00:00.000Z');
81+
});
82+
83+
it('agrees with what App.tsx sends as timeRange.start', () => {
84+
// Both now go through resolveZonedInstant, so the codes offered cannot
85+
// disagree with the run that follows.
86+
const { startDate, startTime, timezone } = baseData().temporal;
87+
expect(buildIgnitionInstant(baseData().temporal)).toBe(
88+
resolveZonedInstant(startDate, startTime, timezone).toISOString(),
89+
);
8490
});
8591
});
8692

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Zone-aware instant construction — issue #355.
3+
*
4+
* The wizard collects a date, a time, and an IANA timezone. Those three must
5+
* combine into one instant. Every existing site does `new Date("YYYY-MM-DDTHH:mm")`,
6+
* which JavaScript reads in the BROWSER's zone, so the declared timezone is
7+
* ignored and the same inputs produce different runs by operator location.
8+
*
9+
* No date library exists in this package, deliberately — these use Intl, the
10+
* same mechanism dateHelpers.ts already relies on.
11+
*/
12+
13+
import { describe, it, expect } from 'vitest';
14+
import { resolveZonedInstant } from '../zonedInstant.js';
15+
16+
describe('resolveZonedInstant', () => {
17+
it('resolves a summer mountain-daylight time to the right instant', () => {
18+
// 2026-08-04 12:00 MDT (UTC-6) === 18:00Z. Vita's ignition.
19+
const instant = resolveZonedInstant('2026-08-04', '12:00', 'America/Edmonton');
20+
expect(instant.toISOString()).toBe('2026-08-04T18:00:00.000Z');
21+
});
22+
23+
it('resolves a winter mountain-standard time, offset and all', () => {
24+
// 2026-01-04 12:00 MST (UTC-7) === 19:00Z. The offset differs from summer,
25+
// which a fixed offset would get wrong half the year.
26+
const instant = resolveZonedInstant('2026-01-04', '12:00', 'America/Edmonton');
27+
expect(instant.toISOString()).toBe('2026-01-04T19:00:00.000Z');
28+
});
29+
30+
it('handles a half-hour offset zone', () => {
31+
// Newfoundland daylight time is UTC-2:30.
32+
const instant = resolveZonedInstant('2026-08-04', '12:00', 'America/St_Johns');
33+
expect(instant.toISOString()).toBe('2026-08-04T14:30:00.000Z');
34+
});
35+
36+
it('is the identity for UTC', () => {
37+
const instant = resolveZonedInstant('2026-08-04', '12:00', 'UTC');
38+
expect(instant.toISOString()).toBe('2026-08-04T12:00:00.000Z');
39+
});
40+
41+
it('resolves an eastern-hemisphere zone', () => {
42+
// Tokyo is UTC+9 year round.
43+
const instant = resolveZonedInstant('2026-08-04', '12:00', 'Asia/Tokyo');
44+
expect(instant.toISOString()).toBe('2026-08-04T03:00:00.000Z');
45+
});
46+
47+
it('produces different instants for the same wall clock in different zones', () => {
48+
// The whole point of #355: an Ontario duty officer and an NWT one entering
49+
// identical wizard values must get the fire's zone, not their own.
50+
const edmonton = resolveZonedInstant('2026-08-04', '12:00', 'America/Edmonton');
51+
const toronto = resolveZonedInstant('2026-08-04', '12:00', 'America/Toronto');
52+
53+
expect(edmonton.getTime()).not.toBe(toronto.getTime());
54+
expect(edmonton.getTime() - toronto.getTime()).toBe(2 * 60 * 60 * 1000);
55+
});
56+
57+
it('crosses a DST boundary correctly on the day the clocks go forward', () => {
58+
// 2026-03-08 is the US/Canada spring-forward date. 03:00 local is MDT.
59+
const instant = resolveZonedInstant('2026-03-08', '03:00', 'America/Edmonton');
60+
expect(instant.toISOString()).toBe('2026-03-08T09:00:00.000Z');
61+
});
62+
63+
it('resolves a time on the day the clocks go back', () => {
64+
// 2026-11-01, fall-back. 12:00 local is MST (UTC-7) by midday.
65+
const instant = resolveZonedInstant('2026-11-01', '12:00', 'America/Edmonton');
66+
expect(instant.toISOString()).toBe('2026-11-01T19:00:00.000Z');
67+
});
68+
69+
it('accepts a time with seconds', () => {
70+
const instant = resolveZonedInstant('2026-08-04', '12:30:45', 'UTC');
71+
expect(instant.toISOString()).toBe('2026-08-04T12:30:45.000Z');
72+
});
73+
74+
it('defaults a missing time to midnight local', () => {
75+
const instant = resolveZonedInstant('2026-08-04', '', 'America/Edmonton');
76+
expect(instant.toISOString()).toBe('2026-08-04T06:00:00.000Z');
77+
});
78+
79+
describe('fail-fast', () => {
80+
it('throws on an unknown timezone rather than silently using the browser zone', () => {
81+
expect(() => resolveZonedInstant('2026-08-04', '12:00', 'Mars/Olympus_Mons')).toThrow(/timezone/i);
82+
});
83+
84+
it('throws on a malformed date', () => {
85+
expect(() => resolveZonedInstant('not-a-date', '12:00', 'UTC')).toThrow(/date/i);
86+
});
87+
88+
it('throws on a malformed time', () => {
89+
expect(() => resolveZonedInstant('2026-08-04', '25:99', 'UTC')).toThrow(/time/i);
90+
});
91+
});
92+
});

frontend/src/features/ModelSetup/utils/weatherPreflight.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919

2020
import type { ModelSetupData, TemporalData, WeatherData } from '../types/index.js';
21+
import { resolveZonedInstant } from './zonedInstant.js';
2122

2223
/** The reading recovered by the backend, as it arrives over JSON. */
2324
export interface StartingCodeCandidate {
@@ -45,18 +46,15 @@ export function needsPreflight(weather: WeatherData): boolean {
4546
}
4647

4748
/**
48-
* The ignition instant, built exactly as App.tsx:189 builds timeRange.start.
49+
* The ignition instant, resolved in the MODEL's timezone.
4950
*
50-
* That line parses a naive date-time string, which JavaScript reads in the
51-
* BROWSER's zone rather than the model's declared timezone — filed as #355.
52-
* This reproduces the bug deliberately: the pre-flight answer depends on which
53-
* daily reading precedes ignition, so if the gate computed a more correct
54-
* instant than the run that follows it, the codes offered could disagree with
55-
* the codes the model actually needed. One consistent error beats two
56-
* inconsistent ones. When #355 is fixed, both move together.
51+
* Previously this deliberately reproduced App.tsx:189's browser-zone bug so the
52+
* pre-flight answer could not disagree with the run that followed it. #355 is
53+
* now fixed, and both sites moved together — App.tsx and this helper share
54+
* resolveZonedInstant, so they cannot drift apart again.
5755
*/
5856
export function buildIgnitionInstant(temporal: TemporalData): string {
59-
return new Date(`${temporal.startDate}T${temporal.startTime}`).toISOString();
57+
return resolveZonedInstant(temporal.startDate, temporal.startTime, temporal.timezone).toISOString();
6058
}
6159

6260
/**
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Zone-aware instant construction — issue #355.
3+
*
4+
* The wizard collects a date, a time, and an IANA timezone. Combining them with
5+
* `new Date("YYYY-MM-DDTHH:mm")` reads the string in the BROWSER's zone, so the
6+
* timezone the user explicitly chose is ignored and the same inputs produce
7+
* different instants depending on where the operator happens to be sitting.
8+
*
9+
* There is no date library in this package and we are not adding one for this.
10+
* Intl carries the whole timezone database, and dateHelpers.ts already leans on
11+
* it, so the offset is looked up rather than assumed — which also means DST and
12+
* half-hour zones fall out correctly instead of needing special cases.
13+
*/
14+
15+
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
16+
const TIME_PATTERN = /^(\d{2}):(\d{2})(?::(\d{2}))?$/;
17+
18+
/**
19+
* How far the given zone sits from UTC at a particular instant, in ms.
20+
*
21+
* Works by asking Intl what wall-clock time the zone shows at that instant,
22+
* then reading that wall clock back as if it were UTC. The difference is the
23+
* offset. hourCycle h23 keeps midnight as 00 rather than 24.
24+
*/
25+
function zoneOffsetMs(instant: number, timeZone: string): number {
26+
const formatter = new Intl.DateTimeFormat('en-CA', {
27+
timeZone,
28+
hourCycle: 'h23',
29+
year: 'numeric',
30+
month: '2-digit',
31+
day: '2-digit',
32+
hour: '2-digit',
33+
minute: '2-digit',
34+
second: '2-digit',
35+
});
36+
37+
const parts: Record<string, number> = {};
38+
for (const { type, value } of formatter.formatToParts(new Date(instant))) {
39+
if (type !== 'literal') parts[type] = Number(value);
40+
}
41+
42+
const wallClockAsUtc = Date.UTC(
43+
parts.year,
44+
parts.month - 1,
45+
parts.day,
46+
parts.hour,
47+
parts.minute,
48+
parts.second,
49+
);
50+
51+
return wallClockAsUtc - instant;
52+
}
53+
54+
/** Rejects an unknown zone rather than letting Intl fall back to the browser's. */
55+
function assertZone(timeZone: string): void {
56+
try {
57+
new Intl.DateTimeFormat('en-CA', { timeZone });
58+
} catch {
59+
throw new Error(`Unknown timezone: ${timeZone}`);
60+
}
61+
}
62+
63+
/**
64+
* @param date YYYY-MM-DD
65+
* @param time HH:mm or HH:mm:ss. Empty means midnight local.
66+
* @param timeZone IANA identifier — the model's zone, never the browser's.
67+
*/
68+
export function resolveZonedInstant(date: string, time: string, timeZone: string): Date {
69+
assertZone(timeZone);
70+
71+
const dateMatch = DATE_PATTERN.exec(date);
72+
if (!dateMatch) {
73+
throw new Error(`Invalid date "${date}" — expected YYYY-MM-DD`);
74+
}
75+
76+
const normalisedTime = time.trim() === '' ? '00:00' : time.trim();
77+
const timeMatch = TIME_PATTERN.exec(normalisedTime);
78+
if (!timeMatch) {
79+
throw new Error(`Invalid time "${time}" — expected HH:mm`);
80+
}
81+
82+
const [, year, month, day] = dateMatch.map(Number);
83+
const [, hour, minute, second] = timeMatch.map((v) => Number(v ?? 0));
84+
85+
if (month < 1 || month > 12 || day < 1 || day > 31) {
86+
throw new Error(`Invalid date "${date}"`);
87+
}
88+
if (hour > 23 || minute > 59 || (second || 0) > 59) {
89+
throw new Error(`Invalid time "${time}"`);
90+
}
91+
92+
// Read the wall clock as if it were UTC, then walk back by the zone's offset.
93+
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute, second || 0);
94+
95+
// Two passes. The first offset is looked up at the wrong instant by exactly
96+
// the offset itself; re-reading at the corrected instant settles it, which is
97+
// what makes DST changeover days land correctly.
98+
let instant = wallClockAsUtc - zoneOffsetMs(wallClockAsUtc, timeZone);
99+
instant = wallClockAsUtc - zoneOffsetMs(instant, timeZone);
100+
101+
return new Date(instant);
102+
}

0 commit comments

Comments
 (0)