Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/rfc/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Summary is the default request view, and `?view=history` makes History shareable

**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.

**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.
**Time zones.** Timestamps default to the browser time zone. Clicking a dotted-underlined timestamp opens a shared popover with local and UTC values, ISO copying, and a Local/UTC display choice that applies to all timestamps and persists in local storage. Server rendering uses UTC until hydration resolves the browser zone and preference.

**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.

Expand Down
1 change: 1 addition & 0 deletions web/platform/styles.css
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.sq-app :is(a, code, dd, h1, p, [role="alert"]) { overflow-wrap: anywhere; }
.sq-app table time { white-space: nowrap; }
209 changes: 169 additions & 40 deletions web/platform/timestamp.test.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,183 @@
import { cleanup, fireEvent, render, screen } from "./test/rtl.js";
import { afterEach, expect, it, vi } from "vitest";
import { Timestamp, TimeZoneProvider, TimeZoneToggle } from "./timestamp.js";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "./test/rtl.js";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { Timestamp, TimeZoneProvider } from "./timestamp.js";

const value = Date.parse("2026-11-01T05:30:00.000Z");
const isoTimestamp = "2026-11-01T05:30:00.000Z";
const preferenceKey = "submitqueue.time-zone";

beforeEach(() => {
const options = new Intl.DateTimeFormat().resolvedOptions();
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ ...options, timeZone: "America/New_York" });
});

afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.localStorage.clear();
});

it("defaults to the browser zone, respects daylight saving, and switches every timestamp without changing its instant", () => {
const options = new Intl.DateTimeFormat().resolvedOptions();
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ ...options, timeZone: "America/New_York" });
const instants = ["2026-11-01T05:30:00.000Z", "2026-11-01T06:30:00.000Z"];
const { container } = render(<TimeZoneProvider>
<TimeZoneToggle />
{instants.map(instant => <Timestamp key={instant} value={Date.parse(instant)} />)}
</TimeZoneProvider>);
const browser = screen.getByRole("option", { name: "Browser" });
expect(browser).toHaveAttribute("aria-selected", "true");
expect(browser).toHaveAttribute("title", "America/New_York");
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EDT")).toBeVisible();
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible();
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Nov 1, 2026, 5:30:00 AM UTC")).toBeVisible();
expect(screen.getByText("Nov 1, 2026, 6:30:00 AM UTC")).toBeVisible();
expect([...container.querySelectorAll("time")].map(time => time.dateTime)).toEqual(instants);
fireEvent.click(browser);
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible();
});

it("preserves invalid timestamp values without inventing a date", () => {
render(<TimeZoneProvider><Timestamp value="unknown" /></TimeZoneProvider>);
expect(screen.getByText("unknown")).not.toHaveAttribute("datetime");
function renderTimestamps() {
return render(<TimeZoneProvider><Timestamp value={value} /><Timestamp value={value + 3_600_000} /></TimeZoneProvider>);
}

async function openTimestamp(index = 0) {
const trigger = screen.getAllByRole("button", { name: /timestamp options$/ })[index]!;
await act(async () => { fireEvent.click(trigger); });
const dialog = screen.getByRole("dialog", { name: "Timestamp" });
await waitFor(() => expect(dialog).toBeVisible());
return { trigger, dialog };
}

it("defaults to local time without saving a preference before the user chooses one", async () => {
renderTimestamps();
const { trigger, dialog } = await openTimestamp();
expect(trigger).toHaveTextContent("Nov 1, 2026, 1:30:00 AM EDT");
expect(within(dialog).getByRole("radio", { name: "Local" })).toBeChecked();
expect(window.localStorage.getItem(preferenceKey)).toBeNull();
expect(dialog).toHaveAccessibleDescription("Applies to all timestamps.");
});

it("remembers UTC when a new page mounts", () => {
const page = <TimeZoneProvider><TimeZoneToggle /><Timestamp value={0} /></TimeZoneProvider>;
const first = render(page);
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
first.unmount();
render(page);
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Jan 1, 1970, 12:00:00 AM UTC")).toBeVisible();
it("shows local, UTC and ISO values without changing the selected display timezone", async () => {
renderTimestamps();
const { trigger, dialog } = await openTimestamp();
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(within(dialog).getByText("Local · America/New_York")).toBeVisible();
expect(within(dialog).getByText("Nov 1, 2026, 5:30:00 AM UTC")).toBeVisible();
expect(within(dialog).getByText(isoTimestamp)).toBeVisible();
expect(within(dialog).getByRole("radio", { name: "Local" })).toBeChecked();
});

it("allows switching when browser storage is unavailable", () => {
it("switches all timestamps through the existing preference and preserves daylight-saving instants", async () => {
const { container } = renderTimestamps();
const { dialog } = await openTimestamp();
expect(screen.getByRole("button", { name: /1:30:00 AM EST — timestamp options$/ })).toBeVisible();
fireEvent.click(within(dialog).getByRole("radio", { name: "UTC" }));
expect(screen.getByRole("button", { name: /5:30:00 AM UTC — timestamp options$/ })).toBeVisible();
expect(screen.getByRole("button", { name: /6:30:00 AM UTC — timestamp options$/ })).toBeVisible();
expect(window.localStorage.getItem(preferenceKey)).toBe("utc");
expect([...container.querySelectorAll("time")].map(time => time.dateTime)).toEqual([
isoTimestamp, "2026-11-01T06:30:00.000Z",
]);
fireEvent.click(within(dialog).getByRole("radio", { name: "Local" }));
expect(screen.getByRole("button", { name: /1:30:00 AM EST — timestamp options$/ })).toBeVisible();
expect(window.localStorage.getItem(preferenceKey)).toBe("browser");
});

it("honors an existing saved UTC preference when a new page mounts", async () => {
window.localStorage.setItem(preferenceKey, "utc");
const view = renderTimestamps();
expect(screen.getByRole("button", { name: /5:30:00 AM UTC — timestamp options$/ })).toBeVisible();
view.unmount();
renderTimestamps();
const { dialog } = await openTimestamp();
expect(within(dialog).getByRole("radio", { name: "UTC" })).toBeChecked();
});

it("allows switching when browser storage is unavailable", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
render(<TimeZoneProvider><TimeZoneToggle /><Timestamp value={0} /></TimeZoneProvider>);
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
renderTimestamps();
const { dialog } = await openTimestamp();
expect(within(dialog).getByRole("radio", { name: "Local" })).toBeChecked();
fireEvent.click(within(dialog).getByRole("radio", { name: "UTC" }));
expect(within(dialog).getByRole("radio", { name: "UTC" })).toBeChecked();
expect(screen.getByRole("button", { name: /5:30:00 AM UTC — timestamp options$/ })).toBeVisible();
});

it("dismisses with Escape and returns focus to the timestamp", async () => {
renderTimestamps();
const { trigger } = await openTimestamp();
fireEvent.keyUp(document, { key: "Escape", keyCode: 27 });
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
expect(trigger).toHaveAttribute("aria-expanded", "false");
});

it("closes from the borderless close control and restores focus", async () => {
renderTimestamps();
const { trigger, dialog } = await openTimestamp();
fireEvent.click(within(dialog).getByRole("button", { name: "Close timestamp options" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
});

it("dismisses when clicking outside or tabbing away without stealing focus", async () => {
render(<TimeZoneProvider><Timestamp value={value} /><button>Other action</button></TimeZoneProvider>);
await openTimestamp();
fireEvent.mouseDown(document.body);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const { dialog } = await openTimestamp();
act(() => within(dialog).getByRole("radio", { name: "Local" }).focus());
const otherAction = screen.getByRole("button", { name: "Other action" });
act(() => otherAction.focus());
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(otherAction).toHaveFocus();
});

it("keeps only the newly selected timestamp popover open", async () => {
renderTimestamps();
await openTimestamp();
const next = screen.getAllByRole("button", { name: /timestamp options$/ })[1]!;
fireEvent.mouseDown(next);
fireEvent.click(next);
await waitFor(() => expect(screen.getAllByRole("dialog")).toHaveLength(1));
expect(next).toHaveAttribute("aria-expanded", "true");
await waitFor(() => expect(within(screen.getByRole("dialog")).getByText("2026-11-01T06:30:00.000Z")).toBeVisible());
});

it("copies the ISO instant and restores the copy icon two seconds after showing success", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
renderTimestamps();
const { dialog } = await openTimestamp();
vi.useFakeTimers();
await act(async () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Copy ISO timestamp" }));
});
expect(writeText).toHaveBeenCalledWith(isoTimestamp);
expect(within(dialog).getByRole("button", { name: "Copied" })).toBeInTheDocument();
expect(within(dialog).getByRole("status")).toHaveTextContent("Copied");
act(() => vi.advanceTimersByTime(1999));
expect(within(dialog).getByRole("button", { name: "Copied" })).toBeInTheDocument();
act(() => vi.advanceTimersByTime(1));
expect(within(dialog).getByRole("button", { name: "Copy ISO timestamp" })).toBeInTheDocument();
expect(within(dialog).getByRole("status")).toBeEmptyDOMElement();
});

it("shows a selectable ISO value and useful feedback when clipboard access fails", async () => {
renderTimestamps();
const { dialog } = await openTimestamp();
fireEvent.click(within(dialog).getByRole("button", { name: "Copy ISO timestamp" }));
await waitFor(() => expect(within(dialog).getByRole("status")).toHaveTextContent("Unable to copy"));
expect(within(dialog).getByText(isoTimestamp)).toBeVisible();
expect(within(dialog).queryByRole("button", { name: "Copied" })).not.toBeInTheDocument();
});

it("does not carry copy feedback into a newly opened popover", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } });
renderTimestamps();
const { dialog } = await openTimestamp();
fireEvent.click(within(dialog).getByRole("button", { name: "Copy ISO timestamp" }));
await waitFor(() => expect(within(dialog).getByRole("button", { name: "Copied" })).toBeInTheDocument());
fireEvent.click(within(dialog).getByRole("button", { name: "Close timestamp options" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const reopened = await openTimestamp();
expect(within(reopened.dialog).getByRole("button", { name: "Copy ISO timestamp" })).toBeInTheDocument();
expect(within(reopened.dialog).getByRole("status")).toBeEmptyDOMElement();
});

it("preserves invalid values as noninteractive timestamps", () => {
render(<TimeZoneProvider><Timestamp value="unknown" /></TimeZoneProvider>);
expect(screen.getByText("unknown")).not.toHaveAttribute("datetime");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("retains the UTC fallback when rendered outside a timezone provider", () => {
render(<Timestamp value={value} />);
expect(screen.getByText("Nov 1, 2026, 5:30:00 AM UTC")).toHaveAttribute("datetime", isoTimestamp);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
Loading
Loading