Skip to content

Commit 4c2d6f4

Browse files
committed
feat(web): add header time zone and auto-refresh controls
Summary: Let viewers choose browser time or UTC and opt into automatic updates from the shared header. Remember the time-zone preference, default auto-refresh off, remove the manual Refresh button, and align both controls using BaseUI typography and layout. Test Plan: Run the reference hosts with make local-submitqueue-start or make local-stovepipe-start. Verified time-zone switching and persistence, opt-in ten-second polling, and matching typography and alignment at desktop and mobile widths.
1 parent 64442a2 commit 4c2d6f4

27 files changed

Lines changed: 387 additions & 118 deletions

‎doc/rfc/web-ui.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ Summary is the default request view, and `?view=history` makes History shareable
6262

6363
## Behavior
6464

65-
**Windows and pagination.** `List` takes a queue and a half-open receipt window. The module recalculates the trailing 24-hour window on refresh and keeps timestamps out of the URL. Older pages carry the gateway's continuation token verbatim as `?page=`; the token holds the original bounds, and the page shows the window the gateway echoes back. The module neither wraps nor signs the token, so hosts need no secret, and a token the gateway rejects redirects to the live first page. Refreshing an older page returns to the live first page.
65+
**Windows and pagination.** `List` takes a queue and a half-open receipt window. The module recalculates the trailing 24-hour window on refresh and keeps timestamps out of the URL. Older pages carry the gateway's continuation token verbatim as `?page=`; the token holds the original bounds, and the page shows the window the gateway echoes back. The module neither wraps nor signs the token, so hosts need no secret, and a token the gateway rejects redirects to the live first page. Browser reload retains an older page; its latest link or auto-refresh returns to the live first page.
6666

67-
**Refresh and polling.** A refresh does not schedule the next until it has finished rendering. The wait is the poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops once the summary is terminal and loaded history contains the same terminal status, or when a terminal summary pairs with a non-retryable history error that polling cannot fix. A queue list keeps polling for the life of its window. A change page that scans the receipt window refreshes only on request, because each render reads up to ten queue pages; a pinned version, found by one exact lookup, keeps polling.
67+
**Time zones.** Timestamps default to the browser time zone. The header's Browser/UTC segmented control applies to all timestamps and persists in local storage. Server rendering uses UTC until hydration resolves the browser zone and preference.
68+
69+
**Refresh and polling.** The header has an opt-in ten-second auto-refresh checkbox, off by default. Users reload the browser for manual updates. A refresh does not schedule the next until it has finished rendering. The wait is the poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops once the summary is terminal and loaded history contains the same terminal status, or when a terminal summary pairs with a non-retryable history error that polling cannot fix. When enabled, a queue list keeps polling for the life of its window. A change page that scans the receipt window refreshes only on request, because each render reads up to ten queue pages; a pinned version, found by one exact lookup, keeps polling.
6870

6971
**Errors and values.** Gateway failures map from gRPC status: invalid argument and resource exhausted are user errors, not found is a missing resource, unavailable and deadline exceeded are transient, and everything else is internal. A transport with another error shape supplies its own classifier. Raw gateway messages are logged, never rendered, and views keep the last good data on transient failures. Status values stay strings with a display table and an unknown fallback. `int64` values arrive as bigint, number, string, or `Long` and become milliseconds after a safe-range check.
7072

‎web/platform/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ web_library(
3535
":node_modules/@types/react",
3636
":node_modules/baseui",
3737
":node_modules/react",
38+
"//web:node_modules/@types/react",
3839
],
3940
)
4041

‎web/platform/auto-refresh.test.tsx‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe("AutoRefresh", () => {
1515
it("uses the interval plus jitter and continues polling", async () => {
1616
const refresh = vi.fn(async () => undefined);
1717
render(<AutoRefresh intervalMs={2_000} jitterMs={500} random={() => 0.5} refresh={refresh} />);
18+
fireEvent.click(screen.getByRole("checkbox"));
1819

1920
await act(async () => vi.advanceTimersByTime(2_249));
2021
expect(refresh).not.toHaveBeenCalled();
@@ -24,13 +25,13 @@ describe("AutoRefresh", () => {
2425
expect(refresh).toHaveBeenCalledTimes(2);
2526
});
2627

27-
it("keeps manual and timer refreshes single-flight beyond multiple intervals", async () => {
28+
it("keeps polling single-flight beyond multiple intervals", async () => {
2829
let finish: (() => void) | undefined;
2930
const refresh = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
3031
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
32+
fireEvent.click(screen.getByRole("checkbox"));
3133

3234
await act(async () => vi.advanceTimersByTime(100));
33-
fireEvent.click(screen.getByRole("button", { name: "Refreshing…" }));
3435
await act(async () => vi.advanceTimersByTime(500));
3536
expect(refresh).toHaveBeenCalledTimes(1);
3637
await act(async () => finish?.());
@@ -48,6 +49,7 @@ describe("AutoRefresh", () => {
4849
transientFailureCount={4}
4950
/>,
5051
);
52+
fireEvent.click(screen.getByRole("checkbox"));
5153

5254
await act(async () => vi.advanceTimersByTime(29_999));
5355
expect(refresh).not.toHaveBeenCalled();
@@ -67,6 +69,7 @@ describe("AutoRefresh", () => {
6769
transientFailureCount={1}
6870
/>,
6971
);
72+
fireEvent.click(screen.getByRole("checkbox"));
7073

7174
await act(async () => vi.advanceTimersByTime(200));
7275
expect(refresh).toHaveBeenCalledTimes(1);
@@ -83,6 +86,7 @@ describe("AutoRefresh", () => {
8386
it("backs off after a rejected refresh and recovers once a refresh succeeds", async () => {
8487
const refresh = vi.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("network")).mockResolvedValue(undefined);
8588
render(<AutoRefresh intervalMs={100} jitterMs={0} random={() => 0} refresh={refresh} />);
89+
fireEvent.click(screen.getByRole("checkbox"));
8690

8791
await act(async () => vi.advanceTimersByTime(100));
8892
expect(refresh).toHaveBeenCalledTimes(1);
@@ -100,6 +104,7 @@ describe("AutoRefresh", () => {
100104
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
101105
const refresh = vi.fn(async () => undefined);
102106
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
107+
fireEvent.click(screen.getByRole("checkbox"));
103108

104109
expect(screen.getByText("Automatic refresh paused")).toBeTruthy();
105110
await act(async () => vi.advanceTimersByTime(1_000));
@@ -114,6 +119,7 @@ describe("AutoRefresh", () => {
114119
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
115120
const refresh = vi.fn(async () => undefined);
116121
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
122+
fireEvent.click(screen.getByRole("checkbox"));
117123

118124
await act(async () => vi.advanceTimersByTime(1_000));
119125
expect(refresh).not.toHaveBeenCalled();
@@ -125,13 +131,15 @@ describe("AutoRefresh", () => {
125131

126132
it("stops automatic polling for terminal detail views", async () => {
127133
const refresh = vi.fn(async () => undefined);
128-
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} terminal />);
134+
const { rerender } = render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
135+
fireEvent.click(screen.getByRole("checkbox"));
136+
await act(async () => vi.advanceTimersByTime(100));
137+
expect(refresh).toHaveBeenCalledTimes(1);
138+
rerender(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} terminal />);
129139

130140
await act(async () => vi.advanceTimersByTime(1_000));
131-
expect(refresh).not.toHaveBeenCalled();
132-
expect(screen.getByText("Automatic refresh stopped")).toBeTruthy();
133-
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Refresh" })));
134141
expect(refresh).toHaveBeenCalledTimes(1);
142+
expect(screen.getByRole("checkbox")).toBeDisabled();
135143
});
136144

137145
it("does not poll a terminal list failure", async () => {
@@ -145,6 +153,7 @@ describe("AutoRefresh", () => {
145153
transientFailureCount={1}
146154
/>,
147155
);
156+
fireEvent.click(screen.getByRole("checkbox"));
148157

149158
await act(async () => vi.advanceTimersByTime(1_000));
150159
expect(refresh).not.toHaveBeenCalled();

‎web/platform/auto-refresh.tsx‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"use client";
22

33
import { useCallback, useEffect, useRef, useState } from "react";
4+
import type { Theme } from "baseui";
45
import { Block } from "baseui/block";
5-
import { Button, SIZE } from "baseui/button";
6+
import { Checkbox } from "baseui/checkbox";
67
import { LabelXSmall } from "baseui/typography";
78

89
export interface AutoRefreshProps {
910
terminal?: boolean;
10-
/** Whether to poll; when false only the manual button refreshes. */
11-
automatic?: boolean;
11+
/** Whether the host supports refreshing in place. */
12+
available?: boolean;
1213
intervalMs?: number;
1314
jitterMs?: number;
1415
maxBackoffMs?: number;
@@ -23,14 +24,15 @@ function pageCanRefresh(): boolean {
2324

2425
export function AutoRefresh({
2526
terminal = false,
26-
automatic = true,
27-
intervalMs = 2_000,
28-
jitterMs = 500,
27+
available = true,
28+
intervalMs = 10_000,
29+
jitterMs = 0,
2930
maxBackoffMs = 30_000,
3031
transientFailureCount = 0,
3132
refresh,
3233
random = Math.random,
3334
}: AutoRefreshProps) {
35+
const [enabled, setEnabled] = useState(false);
3436
const [refreshing, setRefreshing] = useState(false);
3537
const [paused, setPaused] = useState(false);
3638
const [cycle, setCycle] = useState(0);
@@ -80,7 +82,7 @@ export function AutoRefresh({
8082
}, []);
8183

8284
useEffect(() => {
83-
if (terminal || paused || !automatic) {
85+
if (terminal || paused || !available || !enabled) {
8486
return;
8587
}
8688
const failures = Math.max(transientFailureCount, localFailures.current);
@@ -96,17 +98,23 @@ export function AutoRefresh({
9698
timer.current = null;
9799
}
98100
};
99-
}, [automatic, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]);
101+
}, [available, enabled, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]);
102+
103+
if (!available) return null;
100104

101105
return (
102106
<Block display="flex" alignItems="center" flexWrap gridGap="scale500" data-paused={paused || terminal}>
103-
<Button size={SIZE.compact} disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
104-
{refreshing ? "Refreshing…" : "Refresh"}
105-
</Button>
106-
<LabelXSmall as="span" color="contentSecondary" aria-live="polite">
107-
{terminal ? "Automatic refresh stopped" : !automatic ? "Manual refresh" : paused ? "Automatic refresh paused" :
107+
<Checkbox checked={enabled} disabled={terminal} onChange={event => setEnabled(event.currentTarget.checked)}
108+
overrides={{
109+
Root: { style: { alignItems: "center" } },
110+
Label: { style: ({ $theme }: { $theme: Theme }) => $theme.typography.LabelSmall },
111+
}}>
112+
Auto refresh every {intervalMs / 1_000}s
113+
</Checkbox>
114+
{enabled && <LabelXSmall as="span" color="contentSecondary" aria-live="polite">
115+
{terminal ? "Automatic refresh stopped" : paused ? "Automatic refresh paused" : refreshing ? "Refreshing…" :
108116
transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"}
109-
</LabelXSmall>
117+
</LabelXSmall>}
110118
</Block>
111119
);
112120
}

‎web/platform/timestamp.test.tsx‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { cleanup, fireEvent, render, screen } from "./test/rtl.js";
2+
import { afterEach, expect, it, vi } from "vitest";
3+
import { Timestamp, TimeZoneProvider, TimeZoneToggle } from "./timestamp.js";
4+
5+
afterEach(() => {
6+
cleanup();
7+
vi.restoreAllMocks();
8+
window.localStorage.clear();
9+
});
10+
11+
it("defaults to the browser zone, respects daylight saving, and switches every timestamp without changing its instant", () => {
12+
const options = new Intl.DateTimeFormat().resolvedOptions();
13+
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ ...options, timeZone: "America/New_York" });
14+
const instants = ["2026-11-01T05:30:00.000Z", "2026-11-01T06:30:00.000Z"];
15+
const { container } = render(<TimeZoneProvider>
16+
<TimeZoneToggle />
17+
{instants.map(instant => <Timestamp key={instant} value={Date.parse(instant)} />)}
18+
</TimeZoneProvider>);
19+
const browser = screen.getByRole("option", { name: "Browser" });
20+
expect(browser).toHaveAttribute("aria-selected", "true");
21+
expect(browser).toHaveAttribute("title", "America/New_York");
22+
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EDT")).toBeVisible();
23+
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible();
24+
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
25+
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
26+
expect(screen.getByText("Nov 1, 2026, 5:30:00 AM UTC")).toBeVisible();
27+
expect(screen.getByText("Nov 1, 2026, 6:30:00 AM UTC")).toBeVisible();
28+
expect([...container.querySelectorAll("time")].map(time => time.dateTime)).toEqual(instants);
29+
fireEvent.click(browser);
30+
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible();
31+
});
32+
33+
it("preserves invalid timestamp values without inventing a date", () => {
34+
render(<TimeZoneProvider><Timestamp value="unknown" /></TimeZoneProvider>);
35+
expect(screen.getByText("unknown")).not.toHaveAttribute("datetime");
36+
});
37+
38+
it("remembers UTC when a new page mounts", () => {
39+
const page = <TimeZoneProvider><TimeZoneToggle /><Timestamp value={0} /></TimeZoneProvider>;
40+
const first = render(page);
41+
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
42+
first.unmount();
43+
render(page);
44+
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
45+
expect(screen.getByText("Jan 1, 1970, 12:00:00 AM UTC")).toBeVisible();
46+
});
47+
48+
it("allows switching when browser storage is unavailable", () => {
49+
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
50+
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
51+
render(<TimeZoneProvider><TimeZoneToggle /><Timestamp value={0} /></TimeZoneProvider>);
52+
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
53+
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
54+
});

‎web/platform/timestamp.tsx‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,66 @@
11
"use client";
22

3-
const timestampFormatter = new Intl.DateTimeFormat("en-US", {
3+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
4+
import { Block } from "baseui/block";
5+
import { Segment, SegmentedControl } from "baseui/segmented-control";
6+
import { LabelSmall } from "baseui/typography";
7+
8+
const utcFormatter = new Intl.DateTimeFormat("en-US", {
49
dateStyle: "medium", timeStyle: "long", timeZone: "UTC",
510
});
11+
const preferenceKey = "submitqueue.time-zone";
12+
13+
const TimeZoneContext = createContext<{
14+
selection: "browser" | "utc";
15+
selectTimeZone(selection: "browser" | "utc"): void;
16+
browserTimeZone: string;
17+
formatter: Intl.DateTimeFormat;
18+
} | null>(null);
19+
20+
/** Remembers the browser's selection across page loads; UTC is the server-rendering fallback. */
21+
export function TimeZoneProvider({ children }: { children: ReactNode }) {
22+
const [selection, setSelection] = useState<"browser" | "utc">("browser");
23+
const [browserTimeZone, setBrowserTimeZone] = useState("UTC");
24+
useEffect(() => {
25+
setBrowserTimeZone(new Intl.DateTimeFormat().resolvedOptions().timeZone);
26+
try {
27+
if (window.localStorage.getItem(preferenceKey) === "utc") setSelection("utc");
28+
} catch {
29+
// Storage may be unavailable; the toggle still works for this page.
30+
}
31+
}, []);
32+
const selectTimeZone = useCallback((next: "browser" | "utc") => {
33+
setSelection(next);
34+
try {
35+
window.localStorage.setItem(preferenceKey, next);
36+
} catch {
37+
// Storage may be unavailable; keep the in-memory selection.
38+
}
39+
}, []);
40+
const formatter = useMemo(() => new Intl.DateTimeFormat("en-US", {
41+
dateStyle: "medium", timeStyle: "long", timeZone: selection === "utc" ? "UTC" : browserTimeZone,
42+
}), [selection, browserTimeZone]);
43+
const value = useMemo(() => ({ selection, selectTimeZone, browserTimeZone, formatter }), [selection, selectTimeZone, browserTimeZone, formatter]);
44+
return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>;
45+
}
46+
47+
export function TimeZoneToggle() {
48+
const context = useContext(TimeZoneContext);
49+
if (!context) return null;
50+
return <Block display="flex" alignItems="center" flexWrap gridGap="scale300">
51+
<LabelSmall as="span">Time zone</LabelSmall>
52+
<SegmentedControl uid="time-zone" activeKey={context.selection}
53+
onChange={({ activeKey }) => context.selectTimeZone(activeKey === "utc" ? "utc" : "browser")}
54+
overrides={{ SegmentList: { props: { "aria-label": "Time zone" } } }}>
55+
<Segment key="browser" label="Browser" overrides={{ Segment: { props: { title: context.browserTimeZone } } }} />
56+
<Segment key="utc" label="UTC" />
57+
</SegmentedControl>
58+
</Block>;
59+
}
660

761
export function Timestamp({ value }: { value: string | number }) {
62+
const formatter = useContext(TimeZoneContext)?.formatter ?? utcFormatter;
863
const date = new Date(Number(value));
964
return Number.isNaN(date.getTime()) ? <time>{value}</time> :
10-
<time dateTime={date.toISOString()}>{timestampFormatter.format(date)}</time>;
65+
<time dateTime={date.toISOString()}>{formatter.format(date)}</time>;
1166
}

‎web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const dynamic = "force-dynamic";
77
export const revalidate = 0;
88

99
const page = createNextPage({
10+
shell: { environmentLabel: "Local demo · read-only", footer: "Read-only demo" },
1011
web: resolveReferenceWeb,
1112
authenticate: requireAuthorization,
1213
onLog: resolveReferenceLogCallback(),
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { ReactNode } from "react";
2-
import { SubmitQueueShell } from "@submitqueue/web-submitqueue";
32

43
import { requireAuthorization } from "../../next/authorize";
54

@@ -11,8 +10,5 @@ export default async function ProtectedLayout({
1110
}: Readonly<{ children: ReactNode }>) {
1211
await requireAuthorization();
1312

14-
return (
15-
<SubmitQueueShell environmentLabel="Local demo · read-only"
16-
footer="Read-only demo · updates automatically">{children}</SubmitQueueShell>
17-
);
13+
return children;
1814
}

‎web/service/submitqueue/src/next/page.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
22
import { forbidden, notFound, redirect } from "next/navigation";
33
import { connection } from "next/server";
44
import { cache } from "react";
5-
import { SubmitQueueApp } from "@submitqueue/web-submitqueue";
5+
import { SubmitQueueApp, type SubmitQueueShellProps } from "@submitqueue/web-submitqueue";
66
import type { SubmitQueueWeb, SubmitQueueWebRequest, WebSearchParams } from "@submitqueue/web-submitqueue/server";
77
import { NextNavigationProvider } from "@submitqueue/web-service-shared/navigation";
88

@@ -12,6 +12,7 @@ export interface NextPageProps {
1212
}
1313

1414
export interface NextPageOptions {
15+
shell?: Omit<SubmitQueueShellProps, "children" | "controls">;
1516
/** The web UI, or a resolver called per request so configuration is read at runtime rather than build time. */
1617
web: SubmitQueueWeb | (() => SubmitQueueWeb);
1718
/**
@@ -61,7 +62,7 @@ export function createNextPage(options: NextPageOptions) {
6162
case "redirect":
6263
redirect(result.href);
6364
case "render":
64-
return <NextNavigationProvider><SubmitQueueApp model={result.model} /></NextNavigationProvider>;
65+
return <NextNavigationProvider><SubmitQueueApp model={result.model} shell={options.shell} /></NextNavigationProvider>;
6566
}
6667
}
6768

0 commit comments

Comments
 (0)