Manual timezone is the single source of local-time truth (task 7e173881) (#285)

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #285.
This commit is contained in:
2026-09-30 10:02:05 -04:00
committed by approver_agent
parent 69db92b7b8
commit 0e726ac64f
46 changed files with 1556 additions and 429 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* The effective application timezone, loaded once per signed-in shell: the
* API's `effective_timezone` — the stored zone, or the API host's own zone
* when none is stored — so every screen's local time matches the API's.
*
* <AppTimezoneProvider> fetches the household settings and renders its
* screens only once the zone is known (a failed load says so and offers a
* retry), so no screen ever computes local time in a zone it will not keep.
* The Settings screen publishes every zone it loads or saves, so a change
* applies app-wide at once.
*
* Without a provider (a screen rendered on its own, e.g. in a test)
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { ApiForbiddenError } from "../api/client";
import { fetchSettings } from "../api/settings";
interface ZoneHub {
timeZone: string;
publish(timeZone: string): void;
}
const AppTimezoneContext = createContext<ZoneHub | null>(null);
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; timeZone: string };
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
})
.catch((error: unknown) => {
if (cancelled) return;
setState({
kind: "error",
message:
error instanceof ApiForbiddenError
? error.message
: "The application timezone could not be loaded. Check your connection and try again.",
});
});
return () => {
cancelled = true;
};
}, [attempt]);
if (state.kind === "loading") {
return (
<div className="admin-loading" role="status" data-testid="timezone-loading">
Loading…
</div>
);
}
if (state.kind === "error") {
return (
<div className="admin-error" role="alert" data-testid="timezone-error">
<p>{state.message}</p>
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
}
const hub: ZoneHub = {
timeZone: state.timeZone,
publish: (timeZone) =>
setState((prev) =>
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
),
};
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
}
/** The effective application timezone: an IANA name, or "" (browser-local). */
export function useAppTimezone(): string {
return useContext(AppTimezoneContext)?.timeZone ?? "";
}
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
export function usePublishAppTimezone(): (timeZone: string) => void {
const hub = useContext(AppTimezoneContext);
return (timeZone) => hub?.publish(timeZone);
}
+60
View File
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
import { formatClockTime } from "../today/format";
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
describe("application timezone helpers (process in UTC)", () => {
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
expect(todayIn("", NOW)).toBe("2026-09-23");
});
it("wall-clock time converts in the stored zone, across its DST change", () => {
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
Date.parse("2026-09-23T12:10:00Z"),
);
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
Date.parse("2026-09-27T18:30:00Z"),
);
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
});
it("the completion clock reads in the stored zone", () => {
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
});
it("an invalid stored zone fails fast", () => {
expect(zoneOption("")).toBeUndefined();
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
});
});
describe("empty zone keeps browser-local behavior", () => {
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("matches the browser's own calendar and wall clock", () => {
// 13:00 UTC is 06:00 PDT on Sep 23.
expect(todayIn("", NOW)).toBe("2026-09-23");
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Local time in the application timezone.
*
* The admin-saved zone (settings `timezone`, an IANA name) is the single
* source of local-time truth for the whole application; the API publishes it
* as `effective_timezone`, naming its host's own zone when none is stored.
* "" means the API published no zone, and only then does the browser's own
* zone apply. Every helper takes
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
*/
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
export function zoneOption(timeZone: string): string | undefined {
return timeZone === "" ? undefined : timeZone;
}
interface WallClock {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
function wallClock(instant: number, timeZone: string): WallClock {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: zoneOption(timeZone),
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(instant);
const part = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((p) => p.type === type)?.value);
return {
year: part("year"),
month: part("month"),
day: part("day"),
hour: part("hour"),
minute: part("minute"),
second: part("second"),
};
}
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
export function isoDateIn(instant: Date, timeZone: string): string {
const { year, month, day } = wallClock(instant.getTime(), timeZone);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** Today's calendar date in the zone. */
export function todayIn(timeZone: string, now: Date = new Date()): string {
return isoDateIn(now, timeZone);
}
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
function offsetAt(instant: number, timeZone: string): number {
const c = wallClock(instant, timeZone);
const wholeSeconds = Math.floor(instant / 1000) * 1000;
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
}
/**
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
*/
export function wallClockInstant(
iso: string,
hour: number,
minute: number,
timeZone: string,
): number {
const [year, month, day] = iso.split("-").map(Number);
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
const guess = asUtc - offsetAt(asUtc, timeZone);
return asUtc - offsetAt(guess, timeZone);
}