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
+60
View File
@@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return {
@@ -406,3 +407,62 @@ describe("TodayTab Undo and live updates", () => {
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
});
});
describe("TodayTab in the stored application timezone", () => {
function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone, effective_timezone })
: jsonResponse(SNAPSHOT),
),
);
render(
<AppTimezoneProvider>
<TodayTab />
</AppTimezoneProvider>,
);
}
beforeAll(() => {
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("shows the completion clock in the stored zone, not the browser's", async () => {
renderWithZone("Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
// 11:04 UTC is 11:04 PM in Auckland.
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
// The header's day is the API's household-local today_iso, as-is.
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
});
it("an empty stored zone shows the clock in the API host's published zone", async () => {
renderWithZone("", "Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
});
it("only an API that publishes no zone leaves the browser's local clock", async () => {
renderWithZone("", "");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
});
});
+3 -1
View File
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen";
import { useAppTimezone } from "../time/AppTimezone";
import {
formatClockTime,
formatDueTime,
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
const undoing = undo.pendingId === instance.id;
const timeZone = useAppTimezone();
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here.
return (
@@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
Completed {formatClockTime(instance.completed_at as string, timeZone)}
</div>
)}
</div>
+10 -4
View File
@@ -4,8 +4,10 @@
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
* in UTC, so the printed day never shifts with the viewer's offset — unlike
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones.
* day in negative-offset zones. Instants are shown in the application
* timezone (time/zone.ts).
*/
import { zoneOption } from "../time/zone";
const LOCALE = "en-US";
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
return format(new Date(isoTimestamp), {
hour: "numeric",
minute: "2-digit",
timeZone: zoneOption(timeZone),
});
}