diff --git a/doc/rfc/web-ui.md b/doc/rfc/web-ui.md index 4bd98baf6..ba6767f34 100644 --- a/doc/rfc/web-ui.md +++ b/doc/rfc/web-ui.md @@ -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. diff --git a/web/platform/styles.css b/web/platform/styles.css index 067c40582..01e20a5db 100644 --- a/web/platform/styles.css +++ b/web/platform/styles.css @@ -1 +1,2 @@ .sq-app :is(a, code, dd, h1, p, [role="alert"]) { overflow-wrap: anywhere; } +.sq-app table time { white-space: nowrap; } diff --git a/web/platform/timestamp.test.tsx b/web/platform/timestamp.test.tsx index 37d241f55..b5b64c485 100644 --- a/web/platform/timestamp.test.tsx +++ b/web/platform/timestamp.test.tsx @@ -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( - - {instants.map(instant => )} - ); - 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(); - expect(screen.getByText("unknown")).not.toHaveAttribute("datetime"); +function renderTimestamps() { + return render(); +} + +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 = ; - 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(); - 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(); + 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(); + 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(); + expect(screen.getByText("Nov 1, 2026, 5:30:00 AM UTC")).toHaveAttribute("datetime", isoTimestamp); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); diff --git a/web/platform/timestamp.tsx b/web/platform/timestamp.tsx index 9706363cb..2957f2939 100644 --- a/web/platform/timestamp.tsx +++ b/web/platform/timestamp.tsx @@ -1,9 +1,8 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; -import { Block } from "baseui/block"; -import { Segment, SegmentedControl } from "baseui/segmented-control"; -import { LabelSmall } from "baseui/typography"; +import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { styled, withStyle, type Theme } from "baseui"; +import { Popover, PLACEMENT } from "baseui/popover"; const utcFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "long", timeZone: "UTC", @@ -14,6 +13,7 @@ const TimeZoneContext = createContext<{ selection: "browser" | "utc"; selectTimeZone(selection: "browser" | "utc"): void; browserTimeZone: string; + browserFormatter: Intl.DateTimeFormat; formatter: Intl.DateTimeFormat; } | null>(null); @@ -26,7 +26,7 @@ export function TimeZoneProvider({ children }: { children: ReactNode }) { try { if (window.localStorage.getItem(preferenceKey) === "utc") setSelection("utc"); } catch { - // Storage may be unavailable; the toggle still works for this page. + // Storage may be unavailable; the controls still work for this page. } }, []); const selectTimeZone = useCallback((next: "browser" | "utc") => { @@ -37,30 +37,190 @@ export function TimeZoneProvider({ children }: { children: ReactNode }) { // Storage may be unavailable; keep the in-memory selection. } }, []); - const formatter = useMemo(() => new Intl.DateTimeFormat("en-US", { - dateStyle: "medium", timeStyle: "long", timeZone: selection === "utc" ? "UTC" : browserTimeZone, - }), [selection, browserTimeZone]); - const value = useMemo(() => ({ selection, selectTimeZone, browserTimeZone, formatter }), [selection, selectTimeZone, browserTimeZone, formatter]); + const browserFormatter = useMemo(() => new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", timeStyle: "long", timeZone: browserTimeZone, + }), [browserTimeZone]); + const formatter = selection === "utc" ? utcFormatter : browserFormatter; + const value = useMemo(() => ({ selection, selectTimeZone, browserTimeZone, browserFormatter, formatter }), + [selection, selectTimeZone, browserTimeZone, browserFormatter, formatter]); return {children}; } -export function TimeZoneToggle() { - const context = useContext(TimeZoneContext); - if (!context) return null; - return - Time zone - context.selectTimeZone(activeKey === "utc" ? "utc" : "browser")} - overrides={{ SegmentList: { props: { "aria-label": "Time zone" } } }}> - - - - ; +const TimestampButton = styled("button", ({ $theme }) => ({ + display: "inline", border: 0, borderRadius: "2px", padding: 0, backgroundColor: "transparent", + color: "inherit", fontFamily: "inherit", fontSize: "inherit", fontWeight: "inherit", lineHeight: "inherit", + textAlign: "inherit", cursor: "pointer", + whiteSpace: "normal", fontVariantNumeric: "tabular-nums", textDecoration: "underline dotted", + textUnderlineOffset: "3px", + ":hover": { color: $theme.colors.linkText }, + ":focus-visible": { outline: `2px solid ${$theme.colors.borderSelected}`, outlineOffset: "3px" }, +})); + +const Panel = styled("div", ({ $theme }) => ({ + ...$theme.typography.ParagraphSmall, boxSizing: "border-box", + width: "min(340px, calc(100vw - 24px))", padding: "12px", color: $theme.colors.contentPrimary, +})); +const PanelHeading = styled("div", { + display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", +}); +const Title = styled("h2", ({ $theme }) => ({ + ...$theme.typography.LabelMedium, margin: 0, +})); +const IconButton = styled("button", ({ $theme }) => ({ + display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, + boxSizing: "border-box", width: "28px", height: "28px", padding: "5px", border: 0, + borderRadius: "4px", backgroundColor: "transparent", color: $theme.colors.contentSecondary, + cursor: "pointer", fontFamily: "inherit", fontSize: "20px", lineHeight: "inherit", + ":hover": { color: $theme.colors.contentPrimary }, + ":focus-visible": { outline: `2px solid ${$theme.colors.borderSelected}`, outlineOffset: "2px" }, + "@media (pointer: coarse)": { width: "44px", height: "44px" }, +})); +const Values = styled("dl", { margin: "10px 0" }); +const ValueGroup = styled("div", ({ $theme }) => ({ + ":not(:first-child)": { marginTop: "8px", paddingTop: "6px", borderTop: `1px solid ${$theme.colors.borderOpaque}` }, +})); +const ValueLabel = styled("dt", ({ $theme }) => ({ + ...$theme.typography.ParagraphXSmall, marginBottom: "2px", + color: $theme.colors.contentSecondary, fontWeight: 500, +})); +const Value = styled("dd", { margin: 0, overflowWrap: "anywhere" }); +const IsoValue = withStyle(Value, { display: "flex", alignItems: "center", gap: "6px" }); +const IsoText = styled("code", ({ $theme }) => ({ + ...$theme.typography.MonoParagraphXSmall, overflowWrap: "anywhere", +})); +const Divider = styled("hr", ({ $theme }) => ({ + margin: "10px 0 8px", border: 0, borderTop: `1px solid ${$theme.colors.borderOpaque}`, +})); +const TimeZoneOptions = styled("fieldset", { margin: 0, padding: 0, border: 0, minWidth: 0 }); +const TimeZoneLabel = styled("legend", ({ $theme }) => ({ + ...$theme.typography.ParagraphXSmall, padding: "0 0 4px", + color: $theme.colors.contentSecondary, fontWeight: 500, +})); +const TimeZoneChoice = styled("label", { + display: "inline-flex", alignItems: "center", gap: "7px", marginRight: "20px", cursor: "pointer", + "@media (pointer: coarse)": { minHeight: "44px" }, +}); +const ScopeNote = styled("p", ({ $theme }) => ({ + margin: "6px 0 0", color: $theme.colors.contentSecondary, fontSize: "11px", lineHeight: "16px", +})); +const CopyStatus = styled<"p", { $error: boolean }>("p", ({ $theme, $error }) => $error ? ({ + ...$theme.typography.ParagraphXSmall, color: $theme.colors.contentNegative, margin: "6px 0", +}) : ({ + position: "absolute", width: "1px", height: "1px", padding: 0, margin: "-1px", + overflow: "hidden", clipPath: "inset(50%)", whiteSpace: "nowrap", +})); + +function TimestampPopover({ date, context }: { + date: Date; + context: NonNullable>; +}) { + const [open, setOpen] = useState(false); + const [copyStatus, setCopyStatus] = useState(""); + const trigger = useRef(null); + const panelId = useId(); + const isoTimestamp = date.toISOString(); + const formatted = context.formatter.format(date); + + useEffect(() => { + if (!open || copyStatus !== "Copied") return; + const timer = window.setTimeout(() => setCopyStatus(""), 2000); + return () => window.clearTimeout(timer); + }, [open, copyStatus]); + + function closePopover(restoreFocus = false) { + setOpen(false); + if (restoreFocus) trigger.current?.focus({ preventScroll: true }); + } + + async function copyTimestamp() { + try { + await navigator.clipboard.writeText(isoTimestamp); + setCopyStatus("Copied"); + } catch { + setCopyStatus("Unable to copy. Select the ISO timestamp above to copy it."); + } + } + + return { + trigger.current = event.currentTarget as HTMLButtonElement; + setCopyStatus(""); + setOpen(!open); + }} + onClickOutside={() => closePopover()} + onEsc={() => closePopover(true)} + popperOptions={{ modifiers: { + preventOverflow: { boundariesElement: "viewport", padding: 12 }, + flip: { boundariesElement: "viewport", padding: 12 }, + // A wide table can scroll the anchor offscreen; keep the popover in the viewport. + keepTogether: { enabled: false }, + } }} + overrides={{ + Body: { style: { zIndex: 1000 } }, + Inner: { style: ({ $theme }: { $theme: Theme }) => ({ + backgroundColor: $theme.colors.backgroundPrimary, + borderTopLeftRadius: "10px", borderTopRightRadius: "10px", + borderBottomLeftRadius: "10px", borderBottomRightRadius: "10px", + border: `1px solid ${$theme.colors.borderOpaque}`, boxShadow: $theme.lighting.shadow600, + maxHeight: "calc(100dvh - 24px)", overflowY: "auto", + }) }, + }} + content={ { + const next = event.relatedTarget; + if (next instanceof Node && !event.currentTarget.contains(next) && !trigger.current?.contains(next)) closePopover(); + }}> + + Timestamp + closePopover(true)}>× + + + Local · {context.browserTimeZone}{context.browserFormatter.format(date)} + UTC{utcFormatter.format(date)} + + ISO 8601 + + {isoTimestamp} + void copyTimestamp()}> + + + + + + {copyStatus} + + + Display timezone + context.selectTimeZone("browser")} />Local + context.selectTimeZone("utc")} />UTC + + Applies to all timestamps. + } + > + + + + ; } export function Timestamp({ value }: { value: string | number }) { - const formatter = useContext(TimeZoneContext)?.formatter ?? utcFormatter; + const context = useContext(TimeZoneContext); const date = new Date(Number(value)); - return Number.isNaN(date.getTime()) ? : - ; + if (Number.isNaN(date.getTime())) return ; + return context ? + : ; } diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 7e15ef8d4..71deeeca6 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -41,7 +41,7 @@ Repository breakage, per-project breakage, and recording completion are separate ## Refresh and presentation -The header offers optional ten-second auto-refresh, off by default. Use browser reload for manual updates. Polling pauses while the page is hidden or offline. Failed refreshes retain successful results on the same route; request-detail results are scoped to their route. Timestamps default to the browser's time zone, with a Browser/UTC segmented control in the header. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim. +The header offers optional ten-second auto-refresh, off by default. Use browser reload for manual updates. Polling pauses while the page is hidden or offline. Failed refreshes retain successful results on the same route; request-detail results are scoped to their route. Timestamps default to the browser's time zone. Click a dotted-underlined timestamp to inspect its local, UTC and ISO values, copy the ISO timestamp, or change the Local/UTC display preference for the entire page. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim. Queue refreshes update the status of matching displayed requests while preserving their positions and loaded older pages. New arrivals appear behind a Show latest requests action that starts a fresh list. A full browser reload also starts a fresh list; older-page URLs retain their explicit cursor. diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx index e53606f72..7da714433 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -1,6 +1,6 @@ "use client"; -import { Timestamp, TimeZoneProvider, TimeZoneToggle } from "@submitqueue/web-platform"; +import { Timestamp, TimeZoneProvider } from "@submitqueue/web-platform"; import { styled } from "baseui"; import { Block } from "baseui/block"; import { Tag } from "baseui/tag"; @@ -61,7 +61,7 @@ function Page({ model }: { model: StovepipePageModel }) { SPStovepipe - + diff --git a/web/submitqueue/README.md b/web/submitqueue/README.md index e300cd12e..e3e1cdb14 100644 --- a/web/submitqueue/README.md +++ b/web/submitqueue/README.md @@ -15,7 +15,7 @@ Entry points: the root is client-safe (`SubmitQueueApp`, page frames, `WebNaviga Process the UI entry with the host's bundler for both SSR and the browser. The `/server` entry runs directly in Node and does not load BaseUI. See the shared [package contract](../README.md#layers). -**Hosting it.** Give `createSubmitQueueWeb` a gateway client (any client matching the structural contract, so the module ships no generated code) and optionally a mount path, authorization check, error classifier, or cached queue catalog. Route every page path to `handle`, render `SubmitQueueApp` with each page model (it includes the shared header, time zone preference and refresh controls), and wrap it in a `WebNavigationProvider` for client-side navigation. Mount `RequestPagesProvider` above route changes with a `loadPage(href, signal)` callback that returns the queue page's `QueueRequestsModel` through the host's authenticated transport; queue lists then load older rows automatically near the bottom, with a Load more button and explicit retry after failure, and keep loaded rows and scroll position across request visits. Without it, continuation links navigate normally. Load `./styles.css` for long-identifier wrapping and supply BaseUI's `BaseProvider` and Styletron's `Provider`. The host owns the theme and styling engine, including SSR stylesheet extraction and hydration; BaseUI, React, React DOM, and Styletron React are peers. `WebShell` follows the provider's theme rather than taking a separate theme prop. +**Hosting it.** Give `createSubmitQueueWeb` a gateway client (any client matching the structural contract, so the module ships no generated code) and optionally a mount path, authorization check, error classifier, or cached queue catalog. Route every page path to `handle`, render `SubmitQueueApp` with each page model (it includes the shared header, timestamp popovers and refresh controls), and wrap it in a `WebNavigationProvider` for client-side navigation. Mount `RequestPagesProvider` above route changes with a `loadPage(href, signal)` callback that returns the queue page's `QueueRequestsModel` through the host's authenticated transport; queue lists then load older rows automatically near the bottom, with a Load more button and explicit retry after failure, and keep loaded rows and scroll position across request visits. Without it, continuation links navigate normally. Load `./styles.css` for long-identifier wrapping and supply BaseUI's `BaseProvider` and Styletron's `Provider`. The host owns the theme and styling engine, including SSR stylesheet extraction and hydration; BaseUI, React, React DOM, and Styletron React are peers. `WebShell` follows the provider's theme rather than taking a separate theme prop. Pass `onLog` on each `handle` request to deliver severity, message, and structured metadata to the host's request-scoped logger. Errors retain their original value under `error`, and logs include the correlation `requestId` and page `path`. The callback must be synchronous; omitting it is silent, and callback exceptions do not change page results. The module has no logging-library dependency. @@ -25,7 +25,7 @@ Instrumentation belongs in the host: wrap `handle` to observe page results and i The header offers optional ten-second auto-refresh, off by default. Browser reload remains available for manual updates. Polling pauses while the page is hidden or offline and stops for terminal views. -Timestamps default to the browser's time zone. The Browser/UTC segmented control in the shared header applies to the entire page. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. +Timestamps default to the browser's time zone. Click a dotted-underlined timestamp to inspect its local, UTC and ISO values, copy the ISO timestamp, or change the Local/UTC display preference for the entire page. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. ```bash ./tool/bazel test //web/submitqueue:all diff --git a/web/submitqueue/view/shell.tsx b/web/submitqueue/view/shell.tsx index 7d5b6ab1d..80350124b 100644 --- a/web/submitqueue/view/shell.tsx +++ b/web/submitqueue/view/shell.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { TimeZoneProvider, TimeZoneToggle, useWebNavigation } from "@submitqueue/web-platform"; +import { TimeZoneProvider, useWebNavigation } from "@submitqueue/web-platform"; import { styled, withStyle } from "baseui"; import { Block } from "baseui/block"; import { LabelXSmall } from "baseui/typography"; @@ -77,7 +77,6 @@ export function WebShell({ {environmentLabel ? {environmentLabel} : null} - {controls} diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index 1ec42b11c..fed24db72 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -323,30 +323,38 @@ test.describe("time zone selection", () => { return times.length > 0 && times.every(time => time.textContent === formatter.format(new Date(time.getAttribute("datetime")!))); }, zone)).toBe(true); }; - const timeZones = page.getByRole("banner").getByRole("listbox", { name: "Time zone", exact: true }); + const timeZones = page.getByRole("dialog", { name: "Timestamp", exact: true }); + const openTimeZones = () => page.getByRole("button", { name: /timestamp options$/ }).first().click(); + const closeTimeZones = () => timeZones.getByRole("button", { name: "Close timestamp options" }).click(); await page.goto("/web%2Fpagination"); - await expect(timeZones.getByRole("option", { name: "Browser", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("banner").getByRole("listbox", { name: "Time zone", exact: true })).toHaveCount(0); + await openTimeZones(); + await expect(timeZones.getByRole("radio", { name: "Local", exact: true })).toBeChecked(); await expectTimeZone("America/Los_Angeles"); - await timeZones.getByRole("option", { name: "UTC", exact: true }).click(); + await timeZones.getByRole("radio", { name: "UTC", exact: true }).check(); await expectTimeZone("UTC"); + await closeTimeZones(); await page.getByRole("link", { name: "51", exact: true }).click(); await expect(page.getByRole("heading", { name: "Request 51", exact: true })).toBeVisible(); - await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await openTimeZones(); + await expect(timeZones.getByRole("radio", { name: "UTC", exact: true })).toBeChecked(); await expectTimeZone("UTC"); - await timeZones.getByRole("option", { name: "Browser", exact: true }).click(); + await timeZones.getByRole("radio", { name: "Local", exact: true }).check(); await expectTimeZone("America/Los_Angeles"); - const browser = timeZones.getByRole("option", { name: "Browser", exact: true }); + const browser = timeZones.getByRole("radio", { name: "Local", exact: true }); await browser.focus(); await page.keyboard.press("ArrowRight"); - await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect(timeZones.getByRole("radio", { name: "UTC", exact: true })).toBeChecked(); await expectTimeZone("UTC"); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); await page.reload(); - await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await openTimeZones(); + await expect(timeZones.getByRole("radio", { name: "UTC", exact: true })).toBeChecked(); await expectTimeZone("UTC"); - await browser.click(); + await browser.check(); await page.reload(); - await expect(browser).toHaveAttribute("aria-selected", "true"); + await openTimeZones(); + await expect(browser).toBeChecked(); await expectTimeZone("America/Los_Angeles"); expect(errors).toEqual([]); }); diff --git a/web/test/e2e/submitqueue/submitqueue.spec.ts b/web/test/e2e/submitqueue/submitqueue.spec.ts index 2d268bef8..878079318 100644 --- a/web/test/e2e/submitqueue/submitqueue.spec.ts +++ b/web/test/e2e/submitqueue/submitqueue.spec.ts @@ -352,35 +352,47 @@ test("refreshes from the header only while auto-refresh is enabled", async ({ pa test.describe("time zone selection", () => { test.use({ timezoneId: "America/New_York" }); - test("switches queue, request history and change timestamps from the shared header", async ({ page }) => { + test("switches queue, request history and change timestamps from the shared popover", async ({ page }) => { const expectTimeZone = async (zone: string) => { await expect.poll(() => page.locator("time[datetime]").evaluateAll((times, timeZone) => { const formatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "long", timeZone }); return times.length > 0 && times.every(time => time.textContent === formatter.format(new Date(time.getAttribute("datetime")!))); }, zone)).toBe(true); }; - const timeZones = page.getByRole("banner").getByRole("listbox", { name: "Time zone", exact: true }); + const timeZones = page.getByRole("dialog", { name: "Timestamp", exact: true }); + const openTimeZones = () => page.getByRole("button", { name: /timestamp options$/ }).first().click(); + const closeTimeZones = () => timeZones.getByRole("button", { name: "Close timestamp options" }).click(); await page.goto(`/queue/${queue}`); - await expect(timeZones.getByRole("option", { name: "Browser", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("banner").getByRole("listbox", { name: "Time zone", exact: true })).toHaveCount(0); + await openTimeZones(); + await expect(timeZones.getByRole("radio", { name: "Local", exact: true })).toBeChecked(); await expectTimeZone("America/New_York"); - await timeZones.getByRole("option", { name: "UTC", exact: true }).click(); + await timeZones.getByRole("radio", { name: "UTC", exact: true }).check(); await expectTimeZone("UTC"); + await closeTimeZones(); await page.locator(".sq-request-list__item td:first-child a").first().click(); - await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await openTimeZones(); + await expect(timeZones.getByRole("radio", { name: "UTC", exact: true })).toBeChecked(); await expectTimeZone("UTC"); + await closeTimeZones(); await expect(page.getByText("Received", { exact: true })).toBeVisible(); await page.getByRole("link", { name: /^History/ }).click(); await expect(page.getByRole("list", { name: "Request history", exact: true })).toBeVisible(); - await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await openTimeZones(); + await expect(timeZones.getByRole("radio", { name: "UTC", exact: true })).toBeChecked(); await expectTimeZone("UTC"); + await closeTimeZones(); const changePath = `/queue/${queue}/change/git/git.example.com/demo/refs%2Fheads%2Fpagination`; for (const path of [changePath, `${changePath}/7777777777777777777777777777777777777777`]) { await page.goto(path); await expect(page.getByRole("table", { name: "Change submissions" })).toBeVisible(); - await expect(timeZones.getByRole("option", { name: "UTC", exact: true })).toHaveAttribute("aria-selected", "true"); + await openTimeZones(); + await expect(timeZones.getByRole("radio", { name: "UTC", exact: true })).toBeChecked(); await expectTimeZone("UTC"); + await closeTimeZones(); } - await timeZones.getByRole("option", { name: "Browser", exact: true }).click(); + await openTimeZones(); + await timeZones.getByRole("radio", { name: "Local", exact: true }).check(); await expectTimeZone("America/New_York"); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); }); diff --git a/web/test/package/react18/react18-runtime.test.mjs b/web/test/package/react18/react18-runtime.test.mjs index bdd85309d..200017d62 100644 --- a/web/test/package/react18/react18-runtime.test.mjs +++ b/web/test/package/react18/react18-runtime.test.mjs @@ -87,6 +87,11 @@ test("renders and hydrates packaged pages with React 18 and host themes", async // waiting to hydrate is itself reported as a hydration failure. const { act } = React; globalThis.IS_REACT_ACT_ENVIRONMENT = true; + function textColor(element) { + const color = dom.window.getComputedStyle(element).color; + // JSDOM leaves explicit inheritance unresolved on styled buttons. + return color === "inherit" && element.parentElement ? textColor(element.parentElement) : color; + } for (const { route, page, theme, html, stylesheets } of pages) { const styles = stylesheets.map(({ css, attrs }) => { const style = document.createElement("style"); @@ -108,11 +113,13 @@ test("renders and hydrates packaged pages with React 18 and host themes", async document.body.append(expected); assert.equal(dom.window.getComputedStyle(container.querySelector(".sq-app")).backgroundColor, dom.window.getComputedStyle(expected).backgroundColor, "the page must inherit its host's background"); - expected.style.color = theme.colors.contentPrimary; - const selectedTimeZone = container.querySelector('[role="option"][aria-selected="true"]'); - assert.ok(selectedTimeZone); - assert.equal(dom.window.getComputedStyle(selectedTimeZone).color, - dom.window.getComputedStyle(expected).color, "the selected time zone must inherit its host's text color"); + const timestamps = container.querySelectorAll('button[aria-haspopup="dialog"]'); + assert.equal(timestamps.length, container.querySelectorAll("time[datetime]").length); + for (const timestamp of timestamps) { + assert.equal(textColor(timestamp), textColor(timestamp.parentElement), + "the timestamp must inherit its surrounding text color"); + } + assert.equal(container.querySelector('[role="listbox"][aria-label="Time zone"]'), null); expected.remove(); if (route === "/repo%2Fmain/request/1") { assert.match(container.textContent, /example-project/u);