diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 439df26874..226fc5fae1 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1094,7 +1094,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | inherited-global-root | yes | no | 5 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 33 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 16 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 136 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 137 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 9 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 59c7277ee3..4f7fddc1ea 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -2313,6 +2313,7 @@ "src/components/first-nations/where-is-home.tsx", "src/components/forms/form-priority-facts-section.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", + "src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx", "src/components/mode-kit/dial-sheet.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/my-day/my-day-calendar-page.tsx", @@ -2455,6 +2456,7 @@ "src/components/first-nations/where-is-home.tsx", "src/components/forms/form-priority-facts-section.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", + "src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx", "src/components/mode-kit/dial-sheet.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/my-day/my-day-calendar-page.tsx", diff --git a/src/app/(search-app)/layout.tsx b/src/app/(search-app)/layout.tsx index 10fb114d7e..57f4c77d9c 100644 --- a/src/app/(search-app)/layout.tsx +++ b/src/app/(search-app)/layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { SharedSearchAppShell } from "@/components/clinical-dashboard/shared-search-app-shell"; +import { AppHelpersDeferred } from "@/components/app-helpers-deferred"; import { PsychiatryVisitRecorder } from "@/components/psychiatry/psychiatry-visit-recorder"; import { RecentPagesRecorderDeferred } from "@/components/recent-pages/recent-pages-recorder-deferred"; import { WorkModeLaunchProvider } from "@/components/work-mode-launch/work-mode-launch-provider"; @@ -30,6 +31,8 @@ export default async function SearchAppLayout({ children }: { children: ReactNod + {/* Outside the shell: on `/` the shell draws the dashboard in place of its children. */} + ); } diff --git a/src/app/globals.css b/src/app/globals.css index 25f6d01cf8..3a7888c755 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -15,6 +15,9 @@ @import "./work-mode-tokens.css"; /* Clinical header glass and title band, in line with Work (12 Oct 2026). */ @import "./clinical-mode.css"; +/* Quiet tab and row motion (12 Oct 2026). Here rather than in the search-app layout so + it adds no separate stylesheet to that layout's first download. */ +@import "./motion.css"; @custom-variant dark (&:where(.dark, .dark *)); @custom-variant pointer-fine (@media (pointer: fine)); diff --git a/src/app/motion.css b/src/app/motion.css new file mode 100644 index 0000000000..57a5d77d75 --- /dev/null +++ b/src/app/motion.css @@ -0,0 +1,60 @@ +/* + * Quiet, consistent motion (site recommendations, 12 Oct 2026, item 9). + * + * One small set, on the design system's motion tokens: + * - Tab content cross-fades in (--duration-quick, 150 ms) when a tab changes: + * a role="tab" switch, or a move between two Work pages (Work's tabs are + * pages). `src/components/motion/motion-enter.tsx` marks the new content with + * `data-motion-enter`; only opacity moves, so nothing shifts position and no + * layout or composer timing changes. + * - A tapped list row lights at once and lets go slowly (--duration-deliberate), + * so the highlight is still visible as the next page opens. + * - Bottom sheets already rise with a soft settle: the shared Sheet's + * `sheet-up` (250 ms on --ease-standard's curve). Nothing is added here. + * + * Everything is off under prefers-reduced-motion, and under the in-app Motion + * setting's "reduced"; the in-app "full" opts back in, matching the + * motion-safe variant in globals.css. The search composer is never targeted. + */ + +@keyframes motion-enter-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: no-preference) { + html:not([data-motion="reduced"]) [data-motion-enter] { + animation: motion-enter-fade var(--duration-quick) var(--ease-standard) both; + } + + html:not([data-motion="reduced"]) a.work-row, + html:not([data-motion="reduced"]) button.work-row { + transition: background-color var(--duration-deliberate) var(--ease-standard); + } + + html:not([data-motion="reduced"]) a.work-row:active, + html:not([data-motion="reduced"]) button.work-row:active { + transition: none; + } +} + +@media (prefers-reduced-motion: reduce) { + html[data-motion="full"] [data-motion-enter] { + animation: motion-enter-fade var(--duration-quick) var(--ease-standard) both; + } + + html[data-motion="full"] a.work-row, + html[data-motion="full"] button.work-row { + transition: background-color var(--duration-deliberate) var(--ease-standard); + } + + html[data-motion="full"] a.work-row:active, + html[data-motion="full"] button.work-row:active { + transition: none; + } +} diff --git a/src/components/app-helpers-deferred.tsx b/src/components/app-helpers-deferred.tsx new file mode 100644 index 0000000000..a758e8321e --- /dev/null +++ b/src/components/app-helpers-deferred.tsx @@ -0,0 +1,29 @@ +"use client"; + +import dynamic from "next/dynamic"; + +/* + * Open where you were, keyboard shortcuts and page motion (site recommendations, + * 12 Oct 2026). None of them draws anything a page needs to show, so they load + * just after the page instead of adding to every page's first download. + */ +const LastPlace = dynamic(() => import("@/components/last-place/last-place").then((m) => m.LastPlace), { + ssr: false, +}); +const KeyboardShortcuts = dynamic( + () => import("@/components/keyboard-shortcuts/keyboard-shortcuts").then((m) => m.KeyboardShortcuts), + { ssr: false }, +); +const MotionEnter = dynamic(() => import("@/components/motion/motion-enter").then((m) => m.MotionEnter), { + ssr: false, +}); + +export function AppHelpersDeferred() { + return ( + <> + + + + + ); +} diff --git a/src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx b/src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx new file mode 100644 index 0000000000..d0a12a0fe5 --- /dev/null +++ b/src/components/keyboard-shortcuts/keyboard-shortcuts-sheet.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Sheet } from "@/components/ui/sheet"; +import { shortcutRows } from "@/lib/keyboard-shortcuts"; + +function isApple(): boolean { + if (typeof navigator === "undefined") return false; + return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent); +} + +const keyCap = + "inline-flex min-w-7 items-center justify-center rounded-md border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] px-1.5 font-sans text-xs font-semibold leading-6 text-[color:var(--text-heading)]"; + +/** + * The short "Keyboard shortcuts" list for the whole app, opened with "?" on a + * Clinical page or from the Help page. Work pages keep their AI Search list, + * which adds the keys that work inside AI Search. Loaded only when first asked for. + */ +export function KeyboardShortcutsSheet({ open, onClose }: { readonly open: boolean; readonly onClose: () => void }) { + return ( + +
+ {shortcutRows(isApple()).map((row) => ( +
+
{row.label}
+
+ {row.keys.map((combo, index) => ( + + {index > 0 ? or : null} + {combo.map((key) => ( + + {key} + + ))} + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx b/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx new file mode 100644 index 0000000000..6468b7ddc2 --- /dev/null +++ b/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx @@ -0,0 +1,81 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, useState } from "react"; + +import { OPEN_KEYBOARD_SHORTCUTS_EVENT, isTypingTarget, shortcutFor } from "@/lib/keyboard-shortcuts"; + +const KeyboardShortcutsSheet = dynamic( + () => import("@/components/keyboard-shortcuts/keyboard-shortcuts-sheet").then((mod) => mod.KeyboardShortcutsSheet), + { ssr: false }, +); + +/** Another dialog is open over the page: its keys belong to it. */ +function dialogOpen(): boolean { + return document.querySelector('[role="dialog"][aria-modal="true"]') !== null; +} + +function shown(element: Element): boolean { + return element.getClientRects().length > 0; +} + +/** + * The page's own search box when the shared composer is not on screen (the + * Psychiatry and Medicines homes draw their own). Null when there is none. + */ +function pageSearchBox(): HTMLInputElement | null { + const composer = document.getElementById("global-search-input"); + if (composer && shown(composer)) return null; + const boxes = document.querySelectorAll('#main-content input[type="search"]:not([disabled])'); + for (const box of boxes) if (shown(box)) return box; + return null; +} + +/** + * Keyboard shortcuts on a computer (site recommendations, 12 Oct 2026, item 5). + * + * Fills the gaps the page owners leave, and only on a screen with a mouse or + * trackpad, never while typing in a field and never over an open dialog: + * - "/" and Ctrl K (Command K on a Mac) go to a page's own search box where + * the shared composer is not on screen. Where it is, its own shortcut + * already does this; on Work pages AI Search owns both keys. + * - "?" shows the "Keyboard shortcuts" list on Clinical pages (Work pages show + * their AI Search list). The Help page opens the same list. + * Esc already closes every sheet and dialog (the shared Sheet owns it). + * + * Page owners that handle a key mark it handled (`preventDefault`), so a key + * is never acted on twice. The composer's position and behaviour are untouched. + */ +export function KeyboardShortcuts() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const finePointer = typeof window.matchMedia === "function" ? window.matchMedia("(pointer: fine)") : null; + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || !finePointer?.matches) return; + const action = shortcutFor(event); + if (!action || isTypingTarget(event.target) || dialogOpen()) return; + if (action === "help") { + event.preventDefault(); + setOpen(true); + return; + } + const box = pageSearchBox(); + if (!box) return; + event.preventDefault(); + box.focus(); + }; + const onOpen = () => setOpen(true); + window.addEventListener("keydown", onKeyDown); + window.addEventListener(OPEN_KEYBOARD_SHORTCUTS_EVENT, onOpen); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener(OPEN_KEYBOARD_SHORTCUTS_EVENT, onOpen); + }; + }, []); + + // Loaded on first ask, then kept, so closing can play the sheet's own exit. + const [asked, setAsked] = useState(false); + if (open && !asked) setAsked(true); + return asked ? setOpen(false)} /> : null; +} diff --git a/src/components/last-place/last-place.tsx b/src/components/last-place/last-place.tsx new file mode 100644 index 0000000000..2284a576ba --- /dev/null +++ b/src/components/last-place/last-place.tsx @@ -0,0 +1,184 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { usePathname, useRouter } from "next/navigation"; +import { useCallback, useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react"; + +import { landingModeForPreference, readAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; +import { useWorkModeRouteVisible } from "@/components/work-mode-launch/work-mode-launch-provider"; +import { + LAST_PLACE_STORAGE_KEY, + coldLandingTarget, + isColdHomeOpen, + lastPlaceToRemember, + readLastPlace, +} from "@/lib/last-place"; + +/** + * The roster read is only needed on a cold open of the home, so it loads then and never + * weighs on every other page of the app. + */ +const ShiftNowProbe = dynamic(() => import("@/components/last-place/shift-now-probe").then((m) => m.ShiftNowProbe), { + ssr: false, +}); + +/** How long a cold open waits for the roster before going to the last page instead. */ +const SHIFT_CHECK_WAIT_MS = 2_500; + +function readStoredLastPlace(): string | null { + try { + return readLastPlace(window.localStorage.getItem(LAST_PLACE_STORAGE_KEY)); + } catch { + return null; + } +} + +function storeLastPlace(path: string) { + try { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, path); + } catch { + // Storage blocked (private window): the app simply opens on Clinical as before. + } +} + +/** + * Read once per document, at the first mount. Module scope rather than a ref, so + * a remount (React's development double-run, or a layout change) can neither + * re-read storage after this page was remembered over it nor decide twice. + */ +let boot: { readonly lastPlace: string | null; done: boolean } | null = null; +const bootListeners = new Set<() => void>(); + +/** + * True once the doctor has tapped or typed on this document. This component loads after + * the page, so its own listeners can miss the first touches; the browser keeps the record. + */ +function doctorHasInteracted(): boolean { + return ( + (navigator as Navigator & { userActivation?: { hasBeenActive?: boolean } }).userActivation?.hasBeenActive === true + ); +} + +function bootDecision() { + boot ??= { lastPlace: readStoredLastPlace(), done: doctorHasInteracted() || !coldHomeOpen() }; + return boot; +} + +/** The decision is over (made, or given up): nothing moves the page after this. */ +function endDecision() { + const state = bootDecision(); + if (state.done) return; + state.done = true; + for (const listener of bootListeners) listener(); +} + +function subscribeBoot(listener: () => void) { + bootListeners.add(listener); + return () => bootListeners.delete(listener); +} + +function bootChecking(): boolean { + return !bootDecision().done; +} + +/** Test seam: forget the per-document decision. */ +export function resetLastPlaceForTests() { + boot = null; +} + +/** Browser facts about how this document was opened. */ +function coldHomeOpen(): boolean { + let entryUrl: URL | null = null; + let navigationType: string | null = null; + try { + const entry = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; + if (entry) { + entryUrl = new URL(entry.name); + navigationType = entry.type; + } + } catch { + // An older browser without navigation timing: fall back to the current address. + } + const url = entryUrl ?? new URL(window.location.href); + let cameFromApp = false; + try { + cameFromApp = document.referrer !== "" && new URL(document.referrer).origin === window.location.origin; + } catch { + cameFromApp = false; + } + return isColdHomeOpen({ + entry: { pathname: url.pathname, search: url.search, hash: url.hash }, + navigationType, + cameFromApp, + landingPreferenceSet: landingModeForPreference(readAppPreferences().landing) !== null, + standalone: + (typeof window.matchMedia === "function" && window.matchMedia("(display-mode: standalone)").matches) || + (navigator as Navigator & { standalone?: boolean }).standalone === true, + historyLength: window.history.length, + }); +} + +/** + * Open where the doctor is (site recommendations, 12 Oct 2026, item 1). + * + * Remembers the last page used, on this device only and without its query + * string, and on a cold open of a bare `/` (the installed app's start address, + * or a typed visit) goes there instead; while a rostered shift is running it + * opens My Day. Anything else, including every `/?mode=` or `/?q=` link, a + * reload, Back and in-app navigation, keeps today's behaviour. The move uses + * `replace`, so Back never returns to a page the doctor did not choose. + * + * It gives up the moment the doctor starts using the page (a tap or a key), so + * a slow roster read can never pull the screen from under them. + */ +export function LastPlace() { + const pathname = usePathname(); + const router = useRouter(); + const routeVisible = useWorkModeRouteVisible(); + const firstPathname = useRef(pathname); + // False on the server and through hydration; then true only while a cold open is being decided. + const checking = useSyncExternalStore(subscribeBoot, bootChecking, () => false); + const [shiftNow, setShiftNow] = useState(null); + + const finish = useEffectEvent((onShiftNow: boolean) => { + const state = bootDecision(); + if (state.done) return; + endDecision(); + // Only a bare, untouched home moves: a search or mode change on `/` keeps the page. + if (window.location.pathname !== "/" || window.location.search || window.location.hash) return; + if (doctorHasInteracted()) return; + const target = coldLandingTarget({ onShiftNow, lastPlace: state.lastPlace, routeVisible }); + if (target) router.replace(target, { scroll: false }); + }); + + // While deciding: stop waiting for the roster after a short while, and give up + // the moment the doctor starts using the page. + useEffect(() => { + if (!checking) return undefined; + const timer = window.setTimeout(() => finish(false), SHIFT_CHECK_WAIT_MS); + window.addEventListener("pointerdown", endDecision, { capture: true, once: true }); + window.addEventListener("keydown", endDecision, { capture: true, once: true }); + return () => { + window.clearTimeout(timer); + window.removeEventListener("pointerdown", endDecision, { capture: true }); + window.removeEventListener("keydown", endDecision, { capture: true }); + }; + }, [checking]); + + // Remember each page. The remembered one is read first (bootDecision is read + // once per document), so the page on screen never replaces it before the + // decision. A page change before the decision (a tapped link) also ends it. + useEffect(() => { + bootDecision(); + if (pathname !== firstPathname.current) endDecision(); + const remembered = lastPlaceToRemember(pathname); + if (remembered) storeLastPlace(remembered); + }, [pathname]); + + useEffect(() => { + if (shiftNow !== null) finish(shiftNow); + }, [shiftNow]); + + const reportShift = useCallback((onShiftNow: boolean) => setShiftNow(onShiftNow), []); + return checking ? : null; +} diff --git a/src/components/last-place/shift-now-probe.tsx b/src/components/last-place/shift-now-probe.tsx new file mode 100644 index 0000000000..3d3ebe8df3 --- /dev/null +++ b/src/components/last-place/shift-now-probe.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { kindOf } from "@/components/roster/roster-format"; +import { useRosterShifts } from "@/components/roster/use-roster-shifts"; +import { summariseToday } from "@/lib/roster/today"; + +/** + * Reads the doctor's own roster once, and reports whether a real shift (not + * the example roster) is running right now. The same roster read and the same + * "on now" rule My Day uses for its hero. + */ +export function ShiftNowProbe({ onResult }: { readonly onResult: (onShiftNow: boolean) => void }) { + const roster = useRosterShifts(); + const reported = useRef(false); + useEffect(() => { + if (reported.current) return; + if (roster.status === "loading" || (roster.status === "ready" && roster.teamLoading)) return; + reported.current = true; + if (roster.status !== "ready" || roster.sample || roster.demoMode) { + onResult(false); + return; + } + const today = summariseToday( + roster.shifts.map((shift) => ({ + id: shift.id, + startsAt: shift.startsAt, + endsAt: shift.endsAt, + kind: kindOf(shift), + })), + new Date(), + ); + onResult(today.lead.state === "on_now"); + }, [roster.status, roster.teamLoading, roster.sample, roster.demoMode, roster.shifts, onResult]); + return null; +} diff --git a/src/components/motion/motion-enter.tsx b/src/components/motion/motion-enter.tsx new file mode 100644 index 0000000000..06227186b4 --- /dev/null +++ b/src/components/motion/motion-enter.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useLayoutEffect, useRef } from "react"; + +import { workAreaIdForPath } from "@/lib/work-frame/areas"; + +const ATTRIBUTE = "data-motion-enter"; + +/** + * Marks elements so `motion.css` fades them in once. The attribute is removed + * and re-added (with a style read between) so a second change replays it, and + * cleared when the fade ends so it never lingers on the element. + */ +export function markMotionEnter(elements: Iterable) { + for (const element of elements) { + if (!(element instanceof HTMLElement)) continue; + element.removeAttribute(ATTRIBUTE); + // Reading layout restarts the animation when the attribute comes straight back. + void element.offsetWidth; + element.setAttribute(ATTRIBUTE, ""); + // Reduced motion (or no animation at all): nothing to wait for, so nothing is left behind. + if (getComputedStyle(element).animationName === "none") { + element.removeAttribute(ATTRIBUTE); + continue; + } + element.addEventListener("animationend", () => element.removeAttribute(ATTRIBUTE), { once: true }); + } +} + +/** Elements that can carry an animation: `display: contents` wrappers pass it to their children. */ +function animatable(elements: Iterable): Element[] { + const out: Element[] = []; + for (const element of elements) { + if (getComputedStyle(element).display === "contents") out.push(...animatable(element.children)); + else out.push(element); + } + return out; +} + +/** A Work page's content: everything after its band (title and tabs), which stays put. */ +function workPageContent(): Element[] { + const band = document.querySelector("#main-content .work-band"); + if (!band?.parentElement) return []; + const after: Element[] = []; + let next = band.nextElementSibling; + while (next) { + after.push(next); + next = next.nextElementSibling; + } + return animatable(after); +} + +/** The panel a tab shows: by `aria-controls`, else the tab list's neighbouring panel. */ +function panelFor(tab: Element): Element | null { + const controls = tab.getAttribute("aria-controls"); + const byId = controls ? document.getElementById(controls) : null; + if (byId) return byId; + const list = tab.closest('[role="tablist"]'); + let scope = list?.parentElement ?? null; + while (scope && scope !== document.body) { + const panel = scope.querySelector('[role="tabpanel"]'); + if (panel) return panel; + scope = scope.parentElement; + } + return null; +} + +/** + * Tab content cross-fades in (site recommendations, 12 Oct 2026, item 9). + * + * Two kinds of tab: `role="tab"` controls (the content swaps in place), and + * Work's tab row, whose tabs are pages. The search composer is never touched, + * and only opacity moves; `motion.css` switches it all off under reduced motion. + */ +export function MotionEnter() { + const pathname = usePathname(); + const previous = useRef(pathname); + + // A move between two Work pages: fade the new page's content in before it paints. + useLayoutEffect(() => { + const from = previous.current; + previous.current = pathname; + if (from === pathname) return; + if (workAreaIdForPath(from) === null || workAreaIdForPath(pathname) === null) return; + markMotionEnter(workPageContent()); + }, [pathname]); + + // A role="tab" switch: fade the shown panel in on the frame after React has swapped it. + useEffect(() => { + let frame = 0; + const onTab = (event: Event) => { + if (event instanceof KeyboardEvent && !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + const target = event.target instanceof Element ? event.target.closest('[role="tab"]') : null; + if (!target) return; + const list = target.closest('[role="tablist"]'); + const before = list?.querySelector('[role="tab"][aria-selected="true"]') ?? null; + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + const selected = list?.querySelector('[role="tab"][aria-selected="true"]') ?? target; + if (selected === before) return; + const panel = panelFor(selected); + // Never fade anything holding the search composer. + if (panel && !panel.querySelector("#global-search-input")) markMotionEnter([panel]); + }); + }; + // Capture: read the selected tab before React swaps it. + document.addEventListener("click", onTab, true); + document.addEventListener("keydown", onTab, true); + return () => { + window.cancelAnimationFrame(frame); + document.removeEventListener("click", onTab, true); + document.removeEventListener("keydown", onTab, true); + }; + }, []); + + return null; +} diff --git a/src/components/on-call/on-call-offline-banner.tsx b/src/components/on-call/on-call-offline-banner.tsx index 7198840ee1..2e32c161ce 100644 --- a/src/components/on-call/on-call-offline-banner.tsx +++ b/src/components/on-call/on-call-offline-banner.tsx @@ -1,9 +1,9 @@ "use client"; -import { CloudOff } from "lucide-react"; +import { CloudOff, WifiOff } from "lucide-react"; import { cn } from "@/components/ui-primitives"; -import { formatClinicalDate } from "@/lib/source-metadata"; +import { savedLine } from "@/lib/offline-line"; export interface OnCallOfflineBannerProps { /** ISO timestamp of when the shown copy was cached, or `null` if unknown. */ @@ -16,7 +16,9 @@ export interface OnCallOfflineBannerProps { /** * Shown when contacts render from the offline cache rather than a live fetch - * (spec §6, §8.5). Names the date the saved copy was taken. + * (spec §6, §8.5). Names when the saved copy was taken, in the one wording the + * whole app uses for a saved copy ("Offline, showing what was saved at 19:02"; + * an earlier day is named too). * * The visible banner is deliberately NOT itself a live region — no `role`, * no `aria-live` on this element — because the page can mount it already @@ -31,11 +33,8 @@ export function OnCallOfflineBanner({ reason = "offline", testId = "on-call-offline-banner", }: OnCallOfflineBannerProps) { - const savedLabel = savedAt ? formatClinicalDate(savedAt) : "an earlier date"; - const message = - reason === "failed" - ? `Couldn't reach the server. Showing a saved copy from ${savedLabel}.` - : `You are offline. Showing a saved copy from ${savedLabel}.`; + const message = savedLine({ reason, savedAt }); + const Icon = reason === "failed" ? CloudOff : WifiOff; return ( <> @@ -45,7 +44,7 @@ export function OnCallOfflineBanner({ "flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] px-3 py-2 text-sm font-semibold text-[color:var(--warning)]", )} > - + {message} diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx index 68dbc6b35e..8e7ea540c9 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -13,10 +13,17 @@ import { Zap, type LucideIcon, } from "lucide-react"; -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import "@/components/pwa-offline-line.css"; + import { BrandMark } from "@/components/clinical-dashboard/brand"; import { BRAND_INSTALL_TAGLINE, BRAND_NAME } from "@/lib/brand"; import { createBrowserStore } from "@/lib/client-store-factory"; +import type { savedLine as SavedLine } from "@/lib/offline-line"; + +/** savedLine's own wording when the save time is unknown. */ +const OFFLINE_LINE_FALLBACK = "Offline, showing what was saved earlier"; const INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-install-dismissed-at"; const IOS_INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-ios-install-dismissed-at"; @@ -311,11 +318,69 @@ function useSettledNoticeSignature(signature: string | null): string | null { * `?pwa-dev=0` (local hosts only) unregisters that worker and deletes the * owned caches again. */ +/** + * Reports the query string, so a page whose views switch by query alone + * (Roster's ?view=hours) counts as a different page for the offline time. Its + * own Suspense boundary keeps the query read from making the app tree client-only. + */ +function QueryWatcher({ onQuery }: { readonly onQuery: (query: string) => void }) { + const query = useSearchParams().toString(); + useEffect(() => onQuery(query), [query, onQuery]); + return null; +} + export function PwaLifecycle() { const isOnline = useConnectivityStore(); const [connectionRestored, setConnectionRestored] = useState(false); const [installPrompt, setInstallPrompt] = useState(null); const [offlineNoticeDismissed, setOfflineNoticeDismissed] = useState(false); + // When the page on screen was last opened while online. What an offline page + // still shows was loaded then or later, so naming this time can understate how + // fresh it is but never overstate it. + const pathname = usePathname(); + // Null until the query has been read, which never matches a saved page. + const [query, setQuery] = useState(null); + const page = query === null ? null : query ? `${pathname}?${query}` : pathname; + const shownOnlineRef = useRef<{ at: number; page: string | null } | null>(null); + const [offlineSaved, setOfflineSaved] = useState<{ at: number; page: string | null } | null>(null); + // A page reached after the signal dropped came from a saved copy of unknown + // age, so the time only holds for the page (path and query) that was on screen + // when it dropped. + // The Clinical home "/" switches what it shows by query (?mode=…) without a new + // path, so there a matching path proves nothing and the line says "saved earlier". + const offlineSavedAt = + offlineSaved && page !== null && pathname !== "/" && offlineSaved.page === page ? offlineSaved.at : null; + // The offline line's wording helper pulls in the work clock and account + // preferences, which every page would otherwise download up front for a + // notice most visits never show. It loads just after mount, while online, so + // it is ready before the signal drops; until then the line says "saved earlier". + const [savedLineFn, setSavedLineFn] = useState(null); + // A load that fails (opened offline, flaky signal) is tried again when the + // signal returns, so a later offline notice can still name its time. + useEffect(() => { + let live = true; + let loading = false; + let loaded = false; + const load = () => { + if (loading || loaded) return; + loading = true; + import("@/lib/offline-line") + .then((module) => { + loaded = true; + if (live) setSavedLineFn(() => module.savedLine); + }) + .catch(() => {}) + .finally(() => { + loading = false; + }); + }; + load(); + window.addEventListener("online", load); + return () => { + live = false; + window.removeEventListener("online", load); + }; + }, []); const [showIosHint, setShowIosHint] = useState(false); const [waitingWorker, setWaitingWorker] = useState(null); const [activatedUpdateReady, setActivatedUpdateReady] = useState(false); @@ -335,6 +400,7 @@ export function PwaLifecycle() { const handleOffline = () => { if (restoredTimer) window.clearTimeout(restoredTimer); setConnectionRestored(false); + setOfflineSaved(shownOnlineRef.current); }; const handleOnline = () => { if (restoredTimer) window.clearTimeout(restoredTimer); @@ -354,6 +420,10 @@ export function PwaLifecycle() { }; }, []); + useEffect(() => { + shownOnlineRef.current = navigator.onLine && page !== null ? { at: Date.now(), page } : null; + }, [page]); + useEffect(() => { const displayMode = window.matchMedia("(display-mode: standalone)"); const syncDisplayMode = () => { @@ -544,8 +614,14 @@ export function PwaLifecycle() { // while that element is absent — including the hydration gap where it briefly // stops existing — means being restyled a moment later. See useAppShellReady. const appShellReady = useAppShellReady(); - if (!appShellReady) return null; - if (settledSignature === null || settledSignature !== desiredSignature) return null; + // Always mounted, notices or not, so the page is known before the signal drops. + const queryWatcher = ( + + + + ); + if (!appShellReady) return queryWatcher; + if (settledSignature === null || settledSignature !== desiredSignature) return queryWatcher; const showOffline = wantsOffline; const showUpdate = wantsUpdate; const showInstall = wantsInstall; @@ -553,6 +629,7 @@ export function PwaLifecycle() { return (
+ {queryWatcher} {showOffline ? (

- You’re offline + {savedLineFn ? savedLineFn({ reason: "offline", savedAt: offlineSavedAt }) : OFFLINE_LINE_FALLBACK}

Clinical search and private features need a connection. diff --git a/src/components/pwa-offline-line.css b/src/components/pwa-offline-line.css new file mode 100644 index 0000000000..88ff53e5fd --- /dev/null +++ b/src/components/pwa-offline-line.css @@ -0,0 +1,37 @@ +/* + * The offline line looks the same in Work and Clinical (site recommendations, + * 12 Oct 2026, item 10). Work used to squeeze this card onto one row, which + * broke awkwardly once the line named the saved time. `revert-layer` hands + * each property back to the card's own utility classes, so Work draws exactly + * the card Clinical draws; Work's glass card ground (globals.css) is kept. The + * card's padding is not a utility, so it is restated: the lifecycle card's. + */ +html[data-work-frame] .pwa-notice-stack .pwa-offline-card { + padding: var(--pwa-inset-xl); +} + +html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-row { + align-items: revert-layer; +} + +html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-body { + display: revert-layer; + flex: revert-layer; + flex-wrap: revert-layer; + align-items: revert-layer; + justify-content: revert-layer; + column-gap: revert-layer; +} + +html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-copy { + display: revert-layer; +} + +html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-retry { + margin-top: revert-layer; +} + +html[data-work-frame] .pwa-notice-stack .pwa-offline-card .pwa-notice-dismiss { + top: revert-layer; + translate: revert-layer; +} diff --git a/src/components/work-frame/work-rail.tsx b/src/components/work-frame/work-rail.tsx index ac45490c4b..f883ec7b99 100644 --- a/src/components/work-frame/work-rail.tsx +++ b/src/components/work-frame/work-rail.tsx @@ -8,7 +8,8 @@ import type { SidebarIdentity } from "@/components/clinical-dashboard/ClinicalSi import { rememberedWorkAreaPage, useWorkSideCounts } from "@/components/work-frame/work-frame-store"; import { useNewWorkMode, useWorkModeRouteVisible } from "@/components/work-mode-launch/work-mode-launch-provider"; import { appModeIcons } from "@/lib/app-mode-icons"; -import type { AppModeId } from "@/lib/app-modes"; +import { DEFAULT_APP_MODE, useLastAppMode } from "@/components/clinical-dashboard/use-last-app-mode"; +import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; import { modeMenuSideForMode } from "@/lib/phone-mode-groups"; import { WORK_AREAS, workAreaFor, type WorkAreaId } from "@/lib/work-frame/areas"; import { WORK_SIDE_AREAS, workSideAreaId, workSideBadgeText, workSideCountLabel } from "@/lib/work-frame/side-nav"; @@ -47,6 +48,34 @@ export function useWorkRailShown(): boolean { ); } +/** + * The way back to Clinical (site recommendations, 12 Oct 2026, item 1): the + * Clinical rail has My Day near its top, so the work rail has Clinical in the + * same place. It opens the Clinical mode used last, with that mode's icon. + */ +function WorkRailClinical() { + const [storedMode] = useLastAppMode(); + // The mode menu also remembers Work modes (My Day, Roster), which must never + // turn this button into a link to another Work page. + const lastMode = modeMenuSideForMode(storedMode) === "clinical" ? storedMode : DEFAULT_APP_MODE; + const Icon = appModeIcons[lastMode]; + return ( + + + + + ); +} + /** * The tablet rail (owner pick "Burger and rail", 7 Oct 2026): from 768 px a * narrow column down the left edge with your initial, the seven work areas @@ -76,6 +105,7 @@ export function WorkRail({ > +

    {WORK_SIDE_AREAS.filter((entry) => routeVisible(entry.href)).map((entry) => { const current = entry.id === currentArea; diff --git a/src/components/work-help/work-help-page.tsx b/src/components/work-help/work-help-page.tsx index aa7b0f5605..6681980fb9 100644 --- a/src/components/work-help/work-help-page.tsx +++ b/src/components/work-help/work-help-page.tsx @@ -8,6 +8,7 @@ import { ChevronRight, Clock3, Compass, + Keyboard, Search, SearchX, ShieldCheck, @@ -42,6 +43,7 @@ import { } from "@/lib/work-help"; import { workSetupCount, workSetupCountLabel } from "@/lib/work-setup/progress"; import { looksLikePatientDetail } from "@/lib/work-text/patient-detail-check"; +import { OPEN_KEYBOARD_SHORTCUTS_EVENT } from "@/lib/keyboard-shortcuts"; const subscribeNothing = () => () => undefined; function useHydrated(): boolean { @@ -266,6 +268,26 @@ function GetStarted() { ); } +/** Keyboard shortcuts, only where there is a mouse or trackpad (a phone has no use for them). */ +function OnAComputer() { + return ( +
    + On a computer + +
  • + window.dispatchEvent(new Event(OPEN_KEYBOARD_SHORTCUTS_EVENT))} + testId="work-help-keyboard-shortcuts" + /> +
  • +
    +
    + ); +} + function HelpHome() { const router = useRouter(); const searchParams = useSearchParams(); @@ -345,6 +367,7 @@ function HelpHome() { ) : ( <> + 0 ? "Your areas" : "Areas"} topics={yours} /> diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts new file mode 100644 index 0000000000..b1f83ad058 --- /dev/null +++ b/src/lib/keyboard-shortcuts.ts @@ -0,0 +1,41 @@ +/** + * Keyboard shortcuts on a computer (site recommendations, 12 Oct 2026, item 5). + * + * Pure rules for which key does what, so they can be pinned by unit tests. + * Work pages already own "/", Ctrl K and "?" (AI Search, see + * `work-search-button.tsx`), and the Clinical composer already owns "/" and + * Ctrl K where it is on the page (`universal-search-command-surface.tsx`). The + * global listener only fills the gaps those leave. + */ + +/** Ask the global shortcuts list to open (the Help page's row dispatches it). */ +export const OPEN_KEYBOARD_SHORTCUTS_EVENT = "psychsift:open-keyboard-shortcuts"; + +export type ShortcutKey = Pick; + +export type ShortcutAction = "search" | "help" | null; + +/** What a key press asks for, before any page has had its say. */ +export function shortcutFor(event: ShortcutKey): ShortcutAction { + if (event.altKey || event.isComposing) return null; + if (event.key === "/" && !event.metaKey && !event.ctrlKey) return "search"; + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey) && !event.shiftKey) return "search"; + if (event.key === "?" && !event.metaKey && !event.ctrlKey) return "help"; + return null; +} + +/** Typing in a field (or a rich-text box): the keys are the reader's letters there, not ours. */ +export function isTypingTarget(target: EventTarget | null): boolean { + if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false; + return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName); +} + +/** The rows of the list, worded for the platform's command key. */ +export function shortcutRows(apple: boolean): readonly { readonly keys: string[][]; readonly label: string }[] { + const command = apple ? "⌘" : "Ctrl"; + return [ + { keys: [["/"], [command, "K"]], label: "Search: the search box in Clinical, AI Search in Work" }, + { keys: [["Esc"]], label: "Close a sheet or dialog" }, + { keys: [["?"]], label: "Show this list" }, + ]; +} diff --git a/src/lib/last-place.ts b/src/lib/last-place.ts new file mode 100644 index 0000000000..1675950f31 --- /dev/null +++ b/src/lib/last-place.ts @@ -0,0 +1,110 @@ +/** + * "Open where the doctor is" (site recommendations, 12 Oct 2026, item 1). + * + * The app remembers the last Work or Clinical page the doctor used, on this + * device only, as a bare path: never the query string (which can carry search + * text) and never the hash. A cold open of `/` then lands there instead of on + * Clinical Answer, or on My Day while a rostered shift is running. + * + * Everything here is pure so the rules can be pinned by unit tests; the client + * component in `src/components/last-place/` only feeds it browser facts. + */ + +/** localStorage key holding the last page path. Device only; never synced. */ +export const LAST_PLACE_STORAGE_KEY = "clinical-kb-last-place"; + +/** Where a running shift sends a cold open. */ +export const ON_SHIFT_LANDING_PATH = "/my-day"; + +const MAX_PATH_LENGTH = 160; + +/** A fresh tab: its own start page (or about:blank) plus this page. */ +const FRESH_TAB_HISTORY = 2; + +/** Plain path segments only: letters, digits and the usual URL-safe marks. */ +const SAFE_PATH = /^(?:\/[A-Za-z0-9._~-]+)+$/; + +/** + * Pages that make a poor first screen: a form part-way through, a results page + * that needs its query (which is never stored), set-up flows, and the patient + * safety plan, which belongs to one conversation rather than to "where I was". + */ +const SKIPPED_LAST_SEGMENTS = new Set(["new", "edit", "search", "setup"]); +// "/teaching/c" and "/teaching/display" carry one-time or private tokens in the path, +// which must never be kept on the device or replayed on a later open. +const SKIPPED_PREFIXES = ["/safety-plan", "/mockups", "/api", "/auth", "/teaching/c", "/teaching/display"]; + +/** + * The path to remember for a page, or null when the page should not replace + * the remembered one. `/` (the Clinical home) is remembered as `/`: the mode + * itself is already kept by `use-last-app-mode`. + */ +export function lastPlaceToRemember(pathname: string | null | undefined): string | null { + if (!pathname) return null; + if (pathname === "/") return "/"; + if (pathname.length > MAX_PATH_LENGTH || !SAFE_PATH.test(pathname)) return null; + if (SKIPPED_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) return null; + const last = pathname.slice(pathname.lastIndexOf("/") + 1); + if (SKIPPED_LAST_SEGMENTS.has(last)) return null; + return pathname; +} + +/** A stored value, checked again on the way out: storage outlives code changes. */ +export function readLastPlace(stored: string | null | undefined): string | null { + return lastPlaceToRemember(stored ?? null); +} + +export type ColdLandingFacts = { + /** The address the document was first loaded at (not the current one). */ + readonly entry: { readonly pathname: string; readonly search: string; readonly hash: string }; + /** PerformanceNavigationTiming.type for this document ("navigate", "reload", "back_forward", ...). */ + readonly navigationType: string | null; + /** The page was opened from another page of this app (a link, or in-app history). */ + readonly cameFromApp: boolean; + /** Settings "Default landing view" is set: that explicit choice wins. */ + readonly landingPreferenceSet: boolean; + /** The installed app's own window (display-mode standalone). */ + readonly standalone: boolean; + /** + * window.history.length: 1 or 2 for a fresh tab or window (a new tab's own start + * page counts as one), more once this tab has been round the app. + */ + readonly historyLength: number; +}; + +/** + * Whether this load of `/` is a cold open that may be sent elsewhere: the + * installed app opening, or a fresh tab or window. Any query (`?mode=`, `?q=`, + * `?focus=`), any hash, a reload, Back/Forward, arriving from inside the app, or + * a tab that has already been somewhere keeps today's behaviour exactly. + */ +export function isColdHomeOpen(facts: ColdLandingFacts): boolean { + if (facts.entry.pathname !== "/") return false; + if (facts.entry.search !== "" && facts.entry.search !== "?") return false; + if (facts.entry.hash !== "" && facts.entry.hash !== "#") return false; + if (facts.navigationType !== null && facts.navigationType !== "navigate") return false; + if (facts.cameFromApp) return false; + if (facts.landingPreferenceSet) return false; + return facts.standalone || facts.historyLength <= FRESH_TAB_HISTORY; +} + +/** + * Where a cold open of `/` goes, or null to stay on `/` (Clinical, with the + * remembered Clinical mode seeded as it is today). + * + * A running shift wins, then the last page. `routeVisible` filters out a Work + * page this reader cannot open (the classic work mode hides new-only screens). + */ +export function coldLandingTarget({ + onShiftNow, + lastPlace, + routeVisible, +}: { + readonly onShiftNow: boolean; + readonly lastPlace: string | null; + readonly routeVisible: (href: string) => boolean; +}): string | null { + if (onShiftNow && routeVisible(ON_SHIFT_LANDING_PATH)) return ON_SHIFT_LANDING_PATH; + if (!lastPlace || lastPlace === "/") return null; + return routeVisible(lastPlace) ? lastPlace : null; +} diff --git a/src/lib/offline-line.ts b/src/lib/offline-line.ts new file mode 100644 index 0000000000..ed8eb01c0e --- /dev/null +++ b/src/lib/offline-line.ts @@ -0,0 +1,37 @@ +import { currentWorkTimeZone } from "@/lib/work-time/current-zone"; +import { formatZonedDay, zonedDateOf, zonedTimeOf, zonedToday } from "@/lib/work-time/format"; + +/** + * The one line for a page showing what it saved earlier (site recommendations, + * 12 Oct 2026, item 10): "Offline, showing what was saved at 19:02". Used by + * the app-wide offline notice and by On Call's saved-copy banner, so the + * wording is the same in Work and Clinical. + * + * Times are on the work clock (Perth unless the doctor chose another zone). A + * copy from an earlier day names the day, because a bare "19:02" would read as + * today. A server that could not be reached is not the doctor's lost signal, so + * that case says "Couldn't refresh" rather than "Offline". + */ +export type SavedLineReason = "offline" | "failed"; + +export function savedLine({ + reason = "offline", + savedAt, + now = Date.now(), + zone = currentWorkTimeZone(), +}: { + readonly reason?: SavedLineReason | null; + /** When the shown copy was saved (ISO string or epoch ms), or null when unknown. */ + readonly savedAt: string | number | null; + readonly now?: number; + readonly zone?: string; +}): string { + const lead = reason === "failed" ? "Couldn't refresh" : "Offline"; + const at = typeof savedAt === "string" ? Date.parse(savedAt) : savedAt; + if (at === null || !Number.isFinite(at)) return `${lead}, showing what was saved earlier`; + const time = zonedTimeOf(at, zone); + const today = zonedToday(zone, now); + const day = zonedDateOf(at, zone); + if (day === today) return `${lead}, showing what was saved at ${time}`; + return `${lead}, showing what was saved on ${formatZonedDay(day, today)} at ${time}`; +} diff --git a/tests/challenger-m1-empirical-stress.dom.test.tsx b/tests/challenger-m1-empirical-stress.dom.test.tsx index 90d0d77efa..c4b9f038c8 100644 --- a/tests/challenger-m1-empirical-stress.dom.test.tsx +++ b/tests/challenger-m1-empirical-stress.dom.test.tsx @@ -18,7 +18,11 @@ import { import { cmeModuleOrderChangedEvent, cmeModuleOrderStorageKey } from "@/lib/cme/module-order-keys"; const navigation = vi.hoisted(() => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() })); -vi.mock("next/navigation", () => ({ useRouter: () => navigation, usePathname: () => "/cme/customise" })); +vi.mock("next/navigation", () => ({ + useRouter: () => navigation, + usePathname: () => "/cme/customise", + useSearchParams: () => new URLSearchParams(), +})); afterEach(() => { cleanup(); diff --git a/tests/keyboard-shortcuts.dom.test.tsx b/tests/keyboard-shortcuts.dom.test.tsx new file mode 100644 index 0000000000..ff2b2a3c6b --- /dev/null +++ b/tests/keyboard-shortcuts.dom.test.tsx @@ -0,0 +1,115 @@ +/** @vitest-environment jsdom */ + +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { KeyboardShortcuts } from "@/components/keyboard-shortcuts/keyboard-shortcuts"; +import { OPEN_KEYBOARD_SHORTCUTS_EVENT, isTypingTarget, shortcutFor, shortcutRows } from "@/lib/keyboard-shortcuts"; + +function key(init: Partial & { key: string }) { + return { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, isComposing: false, ...init }; +} + +describe("shortcutFor", () => { + it("reads / and Ctrl or Command K as search, ? as the list", () => { + expect(shortcutFor(key({ key: "/" }))).toBe("search"); + expect(shortcutFor(key({ key: "k", ctrlKey: true }))).toBe("search"); + expect(shortcutFor(key({ key: "K", metaKey: true }))).toBe("search"); + expect(shortcutFor(key({ key: "?", shiftKey: true }))).toBe("help"); + }); + + it("ignores other keys and chords", () => { + expect(shortcutFor(key({ key: "k" }))).toBeNull(); + expect(shortcutFor(key({ key: "/", ctrlKey: true }))).toBeNull(); + expect(shortcutFor(key({ key: "/", altKey: true }))).toBeNull(); + expect(shortcutFor(key({ key: "k", ctrlKey: true, shiftKey: true }))).toBeNull(); + expect(shortcutFor(key({ key: "/", isComposing: true }))).toBeNull(); + }); + + it("treats fields and rich-text boxes as typing", () => { + expect(isTypingTarget(document.createElement("input"))).toBe(true); + expect(isTypingTarget(document.createElement("textarea"))).toBe(true); + expect(isTypingTarget(document.createElement("button"))).toBe(false); + expect(isTypingTarget(null)).toBe(false); + }); + + it("names the platform's command key", () => { + expect(shortcutRows(true)[0].keys[1]).toEqual(["⌘", "K"]); + expect(shortcutRows(false)[0].keys[1]).toEqual(["Ctrl", "K"]); + }); +}); + +describe("KeyboardShortcuts", () => { + let fine = true; + beforeEach(() => { + fine = true; + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: fine)" ? fine : false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia; + document.body.innerHTML = ""; + }); + afterEach(() => vi.restoreAllMocks()); + + function mountSearchPage() { + const main = document.createElement("div"); + main.id = "main-content"; + const box = document.createElement("input"); + box.type = "search"; + // jsdom has no layout: report the box as on screen. + box.getClientRects = () => [{}] as unknown as DOMRectList; + main.append(box); + document.body.append(main); + return box; + } + + it("puts the cursor in a page's own search box on /", () => { + const box = mountSearchPage(); + render(); + const event = new KeyboardEvent("keydown", { key: "/", cancelable: true }); + act(() => { + window.dispatchEvent(event); + }); + expect(document.activeElement).toBe(box); + expect(event.defaultPrevented).toBe(true); + }); + + it("leaves a key a page owner already handled", () => { + const box = mountSearchPage(); + render(); + const event = new KeyboardEvent("keydown", { key: "/", cancelable: true }); + event.preventDefault(); + act(() => { + window.dispatchEvent(event); + }); + expect(document.activeElement).not.toBe(box); + }); + + it("does nothing on a touch-only screen", () => { + fine = false; + const box = mountSearchPage(); + render(); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "/", cancelable: true })); + }); + expect(document.activeElement).not.toBe(box); + }); + + it("opens the list on ?", async () => { + render(); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "?", shiftKey: true, cancelable: true })); + }); + expect(await screen.findByRole("dialog", { name: "Keyboard shortcuts" })).toBeInTheDocument(); + }); + + it("opens the list when the Help page asks", async () => { + render(); + act(() => { + window.dispatchEvent(new Event(OPEN_KEYBOARD_SHORTCUTS_EVENT)); + }); + expect(await screen.findByRole("dialog", { name: "Keyboard shortcuts" })).toBeInTheDocument(); + }); +}); diff --git a/tests/last-place.dom.test.tsx b/tests/last-place.dom.test.tsx new file mode 100644 index 0000000000..86e6d9e5d6 --- /dev/null +++ b/tests/last-place.dom.test.tsx @@ -0,0 +1,159 @@ +/** @vitest-environment jsdom */ + +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const replace = vi.fn(); +let pathname = "/"; +let roster: { + status: "loading" | "ready" | "signed-out" | "error"; + shifts: { id: string; startsAt: string; endsAt: string; title: string; kind?: string }[]; + teamLoading: boolean; + sample: boolean; + demoMode: boolean; +}; + +vi.mock("next/navigation", () => ({ + usePathname: () => pathname, + useRouter: () => ({ replace, push: vi.fn(), prefetch: vi.fn() }), +})); +vi.mock("@/components/roster/use-roster-shifts", () => ({ + useRosterShifts: () => roster, +})); + +import { LastPlace, resetLastPlaceForTests } from "@/components/last-place/last-place"; +import { LAST_PLACE_STORAGE_KEY } from "@/lib/last-place"; + +function setEntry(url: string, type = "navigate") { + vi.spyOn(performance, "getEntriesByType").mockReturnValue([ + { name: `http://localhost${url}`, type } as unknown as PerformanceEntry, + ]); +} + +describe("LastPlace", () => { + beforeEach(() => { + resetLastPlaceForTests(); + replace.mockReset(); + pathname = "/"; + window.localStorage.clear(); + roster = { status: "signed-out", shifts: [], teamLoading: false, sample: false, demoMode: false }; + Object.defineProperty(document, "referrer", { value: "", configurable: true }); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("opens the last page on a cold open of a bare /", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + setEntry("/"); + render(); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/teaching", { scroll: false })); + // The page now shown is remembered only after the old one was read. + expect(window.localStorage.getItem(LAST_PLACE_STORAGE_KEY)).toBe("/"); + }); + + it("opens My Day while a real shift is running", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + const now = Date.now(); + roster = { + status: "ready", + shifts: [ + { + id: "s1", + title: "On call", + kind: "on_call", + startsAt: new Date(now - 3_600_000).toISOString(), + endsAt: new Date(now + 3_600_000).toISOString(), + }, + ], + teamLoading: false, + sample: false, + demoMode: false, + }; + setEntry("/"); + render(); + await waitFor(() => expect(replace).toHaveBeenCalledWith("/my-day", { scroll: false })); + }); + + it("ignores the example roster", async () => { + const now = Date.now(); + roster = { + status: "ready", + shifts: [ + { + id: "s1", + title: "Day", + startsAt: new Date(now - 3_600_000).toISOString(), + endsAt: new Date(now + 3_600_000).toISOString(), + }, + ], + teamLoading: false, + sample: true, + demoMode: false, + }; + setEntry("/"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + }); + + it("never moves a / with a query, a reload, or an arrival from inside the app", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + setEntry("/?mode=dsm"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + + resetLastPlaceForTests(); + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + setEntry("/", "reload"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + + resetLastPlaceForTests(); + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + Object.defineProperty(document, "referrer", { value: `${window.location.origin}/on-call`, configurable: true }); + setEntry("/"); + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + }); + + it("leaves the doctor where they are once they start using the page", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + roster = { ...roster, status: "loading" }; + setEntry("/"); + const view = render(); + await act(async () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" })); + }); + roster = { ...roster, status: "signed-out" }; + view.rerender(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + }); + + it("leaves the page alone when the doctor touched it before this helper loaded", async () => { + window.localStorage.setItem(LAST_PLACE_STORAGE_KEY, "/teaching"); + setEntry("/"); + Object.defineProperty(navigator, "userActivation", { value: { hasBeenActive: true }, configurable: true }); + try { + render(); + await act(async () => {}); + expect(replace).not.toHaveBeenCalled(); + } finally { + Reflect.deleteProperty(navigator, "userActivation"); + } + }); + + it("remembers each page without its query string", async () => { + pathname = "/on-call/whos-on"; + setEntry("/on-call/whos-on?x=1"); + render(); + await act(async () => {}); + expect(window.localStorage.getItem(LAST_PLACE_STORAGE_KEY)).toBe("/on-call/whos-on"); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/last-place.test.ts b/tests/last-place.test.ts new file mode 100644 index 0000000000..a0055d4411 --- /dev/null +++ b/tests/last-place.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + ON_SHIFT_LANDING_PATH, + coldLandingTarget, + isColdHomeOpen, + lastPlaceToRemember, + readLastPlace, + type ColdLandingFacts, +} from "@/lib/last-place"; + +const cold: ColdLandingFacts = { + entry: { pathname: "/", search: "", hash: "" }, + navigationType: "navigate", + cameFromApp: false, + landingPreferenceSet: false, + standalone: false, + historyLength: 1, +}; + +describe("lastPlaceToRemember", () => { + it("keeps plain Work and Clinical page paths", () => { + expect(lastPlaceToRemember("/on-call")).toBe("/on-call"); + expect(lastPlaceToRemember("/my-day/week")).toBe("/my-day/week"); + expect(lastPlaceToRemember("/dsm/major-depressive-disorder")).toBe("/dsm/major-depressive-disorder"); + expect(lastPlaceToRemember("/")).toBe("/"); + }); + + it("never keeps forms part-way through, results pages, set-up or the safety plan", () => { + expect(lastPlaceToRemember("/cme/new")).toBeNull(); + expect(lastPlaceToRemember("/documents/search")).toBeNull(); + expect(lastPlaceToRemember("/my-day/setup")).toBeNull(); + expect(lastPlaceToRemember("/safety-plan")).toBeNull(); + expect(lastPlaceToRemember("/safety-plan/print")).toBeNull(); + expect(lastPlaceToRemember("/mockups/anything")).toBeNull(); + }); + + it("never keeps a page whose path carries a check-in or display token", () => { + expect(lastPlaceToRemember("/teaching/c/abc123token")).toBeNull(); + expect(lastPlaceToRemember("/teaching/display/abc123token")).toBeNull(); + expect(lastPlaceToRemember("/teaching")).toBe("/teaching"); + }); + + it("refuses anything that is not a bare same-site path", () => { + expect(lastPlaceToRemember("/on-call?q=lithium")).toBeNull(); + expect(lastPlaceToRemember("/on-call#top")).toBeNull(); + expect(lastPlaceToRemember("//evil.example/x")).toBeNull(); + expect(lastPlaceToRemember("https://evil.example")).toBeNull(); + expect(lastPlaceToRemember("/a b")).toBeNull(); + expect(lastPlaceToRemember(`/${"a".repeat(200)}`)).toBeNull(); + expect(lastPlaceToRemember("")).toBeNull(); + expect(lastPlaceToRemember(null)).toBeNull(); + }); + + it("checks a stored value again on the way out", () => { + expect(readLastPlace("/teaching")).toBe("/teaching"); + expect(readLastPlace("javascript:alert(1)")).toBeNull(); + expect(readLastPlace(null)).toBeNull(); + }); +}); + +describe("isColdHomeOpen", () => { + it("is true only for a fresh open of a bare /", () => { + expect(isColdHomeOpen(cold)).toBe(true); + expect(isColdHomeOpen({ ...cold, navigationType: null })).toBe(true); + }); + + it("never moves a / that carries a query or a hash", () => { + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, search: "?mode=dsm" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, search: "?q=lithium" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, search: "?focus=1" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, hash: "#main" } })).toBe(false); + }); + + it("keeps today's behaviour for other pages, reloads, Back, in-app arrivals and a set landing view", () => { + expect(isColdHomeOpen({ ...cold, entry: { ...cold.entry, pathname: "/on-call" } })).toBe(false); + expect(isColdHomeOpen({ ...cold, navigationType: "reload" })).toBe(false); + expect(isColdHomeOpen({ ...cold, navigationType: "back_forward" })).toBe(false); + expect(isColdHomeOpen({ ...cold, cameFromApp: true })).toBe(false); + expect(isColdHomeOpen({ ...cold, landingPreferenceSet: true })).toBe(false); + }); + + it("moves only a fresh tab or the installed app, not a tab that has been somewhere", () => { + expect(isColdHomeOpen({ ...cold, historyLength: 2 })).toBe(true); + expect(isColdHomeOpen({ ...cold, historyLength: 3 })).toBe(false); + expect(isColdHomeOpen({ ...cold, historyLength: 3, standalone: true })).toBe(true); + }); +}); + +describe("coldLandingTarget", () => { + const all = () => true; + + it("opens My Day while a shift is running", () => { + expect(coldLandingTarget({ onShiftNow: true, lastPlace: "/teaching", routeVisible: all })).toBe( + ON_SHIFT_LANDING_PATH, + ); + }); + + it("otherwise opens the last page used", () => { + expect(coldLandingTarget({ onShiftNow: false, lastPlace: "/teaching", routeVisible: all })).toBe("/teaching"); + }); + + it("stays on Clinical when the last place was Clinical home or nothing is remembered", () => { + expect(coldLandingTarget({ onShiftNow: false, lastPlace: "/", routeVisible: all })).toBeNull(); + expect(coldLandingTarget({ onShiftNow: false, lastPlace: null, routeVisible: all })).toBeNull(); + }); + + it("skips a page this reader cannot open", () => { + const hidden = (href: string) => href !== "/teaching" && href !== ON_SHIFT_LANDING_PATH; + expect(coldLandingTarget({ onShiftNow: false, lastPlace: "/teaching", routeVisible: hidden })).toBeNull(); + expect(coldLandingTarget({ onShiftNow: true, lastPlace: "/on-call", routeVisible: hidden })).toBe("/on-call"); + }); +}); diff --git a/tests/motion-enter.dom.test.tsx b/tests/motion-enter.dom.test.tsx new file mode 100644 index 0000000000..47f466560c --- /dev/null +++ b/tests/motion-enter.dom.test.tsx @@ -0,0 +1,93 @@ +/** @vitest-environment jsdom */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { act, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let pathname = "/my-day"; +vi.mock("next/navigation", () => ({ usePathname: () => pathname })); + +import { MotionEnter, markMotionEnter } from "@/components/motion/motion-enter"; + +// Rules only: the file's notes may name what it leaves alone. +const css = readFileSync(join(process.cwd(), "src/app/motion.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + +function withAnimation(name: string) { + const real = window.getComputedStyle; + return vi.spyOn(window, "getComputedStyle").mockImplementation((element: Element) => { + const style = real(element); + return new Proxy(style, { + get: (target, key) => (key === "animationName" ? name : Reflect.get(target, key)), + }); + }); +} + +describe("motion.css", () => { + it("only animates when motion is allowed", () => { + // Every rule sits inside a motion media query: nothing runs under reduced motion by default. + const outside = css + .replace(/@media[^{]+\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "") + .replace(/@keyframes[^{]+\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, ""); + expect(outside).not.toMatch(/animation:|transition:/); + expect(css).toContain("@media (prefers-reduced-motion: no-preference)"); + expect(css).toContain('html:not([data-motion="reduced"])'); + }); + + it("uses the design system's motion tokens and never names the composer", () => { + expect(css).toMatch(/var\(--duration-quick\)/); + expect(css).not.toMatch(/\d+ms/); + expect(css).not.toMatch(/composer|global-search/); + }); +}); + +describe("markMotionEnter", () => { + afterEach(() => vi.restoreAllMocks()); + + it("marks an element and clears the mark when the fade ends", () => { + withAnimation("motion-enter-fade"); + const element = document.createElement("div"); + markMotionEnter([element]); + expect(element.hasAttribute("data-motion-enter")).toBe(true); + element.dispatchEvent(new Event("animationend")); + expect(element.hasAttribute("data-motion-enter")).toBe(false); + }); + + it("leaves nothing behind under reduced motion", () => { + withAnimation("none"); + const element = document.createElement("div"); + markMotionEnter([element]); + expect(element.hasAttribute("data-motion-enter")).toBe(false); + }); +}); + +describe("MotionEnter", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("fades a Work page's content, not its band, on a move between Work pages", () => { + withAnimation("motion-enter-fade"); + document.body.innerHTML = + '
    '; + pathname = "/my-day"; + const view = render(); + pathname = "/my-day/week"; + act(() => view.rerender()); + expect(document.getElementById("page")?.hasAttribute("data-motion-enter")).toBe(true); + expect(document.querySelector(".work-band")?.hasAttribute("data-motion-enter")).toBe(false); + }); + + it("leaves Clinical pages alone", () => { + withAnimation("motion-enter-fade"); + document.body.innerHTML = + '
    '; + pathname = "/"; + const view = render(); + pathname = "/dsm"; + act(() => view.rerender()); + expect(document.getElementById("page")?.hasAttribute("data-motion-enter")).toBe(false); + }); +}); diff --git a/tests/offline-line.test.ts b/tests/offline-line.test.ts new file mode 100644 index 0000000000..86b1818419 --- /dev/null +++ b/tests/offline-line.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { savedLine } from "@/lib/offline-line"; + +const PERTH = "Australia/Perth"; +// 12 Oct 2026, 19:02 in Perth (UTC+8). +const now = Date.parse("2026-10-12T11:30:00Z"); + +describe("savedLine", () => { + it("names the Perth time of a copy saved today", () => { + expect(savedLine({ savedAt: "2026-10-12T11:02:00Z", now, zone: PERTH })).toBe( + "Offline, showing what was saved at 19:02", + ); + }); + + it("names the day of a copy from an earlier day, so a bare time never reads as today", () => { + expect(savedLine({ savedAt: "2026-10-11T11:02:00Z", now, zone: PERTH })).toBe( + "Offline, showing what was saved on Sun 11 Oct at 19:02", + ); + }); + + it("says a failed refresh is not lost signal", () => { + expect(savedLine({ reason: "failed", savedAt: Date.parse("2026-10-12T11:02:00Z"), now, zone: PERTH })).toBe( + "Couldn't refresh, showing what was saved at 19:02", + ); + }); + + it("never invents a time it does not know", () => { + expect(savedLine({ savedAt: null, now, zone: PERTH })).toBe("Offline, showing what was saved earlier"); + expect(savedLine({ savedAt: "not a date", now, zone: PERTH })).toBe("Offline, showing what was saved earlier"); + }); +}); diff --git a/tests/on-call-compliance.test.ts b/tests/on-call-compliance.test.ts index b2a0f98fa5..8da4fa1c5a 100644 --- a/tests/on-call-compliance.test.ts +++ b/tests/on-call-compliance.test.ts @@ -514,7 +514,7 @@ describe("recordedExpiryHasPassed", () => { * machinery rather than on the reader's standing, so the compliance vocabulary * is the wrong ruler for them: * - * - `on-call-offline-banner.tsx` ("Showing a saved copy from …") is about the + * - `on-call-offline-banner.tsx` ("Offline, showing what was saved at …") is about the * cache. A future "this copy is not up to date" would be true of the cache * and no claim at all about a registration. * - `on-call-page-sections.ts` owns the band headings, which ARE compliance diff --git a/tests/on-call-contacts.dom.test.tsx b/tests/on-call-contacts.dom.test.tsx index da1b1aa82c..6d6662db0c 100644 --- a/tests/on-call-contacts.dom.test.tsx +++ b/tests/on-call-contacts.dom.test.tsx @@ -150,9 +150,11 @@ describe("OnCallFreshnessBadge", () => { }); describe("OnCallOfflineBanner", () => { - it("names the date the saved copy was taken", () => { + it("names when the saved copy was taken, in the app-wide wording", () => { render(); - expect(screen.getByTestId("on-call-offline-banner")).toHaveTextContent(/20\/08\/2026/); + expect(screen.getByTestId("on-call-offline-banner")).toHaveTextContent( + /^Offline, showing what was saved on Thu 20 Aug( 2026)? at 08:00$/, + ); }); it("is not itself a live region — the visible banner carries no aria-live or status/alert role", () => { @@ -167,7 +169,7 @@ describe("OnCallOfflineBanner", () => { const announcement = screen.getByTestId("on-call-offline-banner-announcement"); expect(announcement).toHaveAttribute("aria-live", "polite"); expect(announcement.className).toMatch(/\bsr-only\b/); - expect(announcement).toHaveTextContent(/20\/08\/2026/); + expect(announcement).toHaveTextContent(/Thu 20 Aug/); }); }); diff --git a/tests/pwa-lifecycle.dom.test.tsx b/tests/pwa-lifecycle.dom.test.tsx index 01cca71b54..16d0766de6 100644 --- a/tests/pwa-lifecycle.dom.test.tsx +++ b/tests/pwa-lifecycle.dom.test.tsx @@ -5,6 +5,13 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PwaLifecycle } from "@/components/pwa-lifecycle"; +const navigationState = vi.hoisted(() => ({ pathname: "/", search: "" })); +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + usePathname: () => navigationState.pathname, + useSearchParams: () => new URLSearchParams(navigationState.search), +})); + type MockWorker = ServiceWorker & { postMessage: ReturnType }; function createWorker(version?: string): MockWorker { @@ -207,7 +214,7 @@ describe("PwaLifecycle", () => { Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); fireEvent.offline(window); - expect(await screen.findByRole("region", { name: "You’re offline" })).toBeInTheDocument(); + expect(await screen.findByRole("region", { name: /^Offline, showing what was saved/ })).toBeInTheDocument(); expect(screen.getByText("Clinical search and private features need a connection.")).toBeInTheDocument(); Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); @@ -215,22 +222,77 @@ describe("PwaLifecycle", () => { expect(await screen.findByText("Connection restored")).toBeInTheDocument(); }); + it("names the work-clock time the page on screen was opened online", async () => { + navigationState.pathname = "/on-call"; + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + render(); + + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + fireEvent.offline(window); + expect( + await screen.findByRole("region", { name: /^Offline, showing what was saved at \d{2}:\d{2}$/ }), + ).toBeInTheDocument(); + navigationState.pathname = "/"; + }); + + it("claims no time on the Clinical home, whose content changes by query without a new path", async () => { + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + render(); + + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + fireEvent.offline(window); + expect(await screen.findByRole("region", { name: "Offline, showing what was saved earlier" })).toBeInTheDocument(); + }); + + it("forgets the saved time when a new page is opened while offline", async () => { + navigationState.pathname = "/on-call"; + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + const { rerender } = render(); + + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + fireEvent.offline(window); + await screen.findByRole("region", { name: /^Offline, showing what was saved at \d{2}:\d{2}$/ }); + + // The next page comes from a saved copy of unknown age, so no time is claimed for it. + navigationState.pathname = "/my-day"; + rerender(); + expect(await screen.findByRole("region", { name: "Offline, showing what was saved earlier" })).toBeInTheDocument(); + navigationState.pathname = "/"; + }); + + it("forgets the saved time when the same page switches view by query while offline", async () => { + navigationState.pathname = "/roster"; + navigationState.search = "view=hours"; + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + const { rerender } = render(); + + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + fireEvent.offline(window); + await screen.findByRole("region", { name: /^Offline, showing what was saved at \d{2}:\d{2}$/ }); + + // Roster's month view reads its shifts again, so the Hours view's time does not hold for it. + navigationState.search = ""; + rerender(); + expect(await screen.findByRole("region", { name: "Offline, showing what was saved earlier" })).toBeInTheDocument(); + navigationState.pathname = "/"; + }); + it("dismisses the offline notice per episode and re-surfaces it on the next connectivity drop", async () => { const user = userEvent.setup(); render(); Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); fireEvent.offline(window); - expect(await screen.findByRole("region", { name: "You’re offline" })).toBeInTheDocument(); + expect(await screen.findByRole("region", { name: /^Offline, showing what was saved/ })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Dismiss offline notice" })); - expect(screen.queryByRole("region", { name: "You’re offline" })).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: /^Offline, showing what was saved/ })).not.toBeInTheDocument(); Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); fireEvent.online(window); Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); fireEvent.offline(window); - expect(await screen.findByRole("region", { name: "You’re offline" })).toBeInTheDocument(); + expect(await screen.findByRole("region", { name: /^Offline, showing what was saved/ })).toBeInTheDocument(); }); it("shows install UI only after browser eligibility and invokes the deferred prompt from a user action", async () => { @@ -727,7 +789,7 @@ describe("notice-stack swap settling", () => { Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); try { render(); - await screen.findByRole("region", { name: "You’re offline" }); + await screen.findByRole("region", { name: /^Offline, showing what was saved/ }); await act(async () => { Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); @@ -737,7 +799,7 @@ describe("notice-stack swap settling", () => { // Immediately after the commit that clears the offline card, the stack // must be fully empty — not already showing connection-restored — or // the swap resizes an already-visible fixed element. - expect(screen.queryByRole("region", { name: "You’re offline" })).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: /^Offline, showing what was saved/ })).not.toBeInTheDocument(); expect(screen.queryByText("Connection restored")).not.toBeInTheDocument(); await screen.findByText("Connection restored"); @@ -777,7 +839,7 @@ describe("notice stack waits out the app-shell gap", () => { const shell = mountAppShell(); try { render(); - await screen.findByRole("region", { name: "You\u2019re offline" }); + await screen.findByRole("region", { name: /^Offline, showing what was saved/ }); // The hydration gap. `document.readyState` is already "complete" here, // which is exactly why a bare readyState fallback does not hold: it @@ -786,12 +848,12 @@ describe("notice stack waits out the app-shell gap", () => { await act(async () => { shell.remove(); }); - expect(screen.queryByRole("region", { name: "You\u2019re offline" })).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: /^Offline, showing what was saved/ })).not.toBeInTheDocument(); await act(async () => { mountAppShell(); }); - await screen.findByRole("region", { name: "You\u2019re offline" }); + await screen.findByRole("region", { name: /^Offline, showing what was saved/ }); } finally { document.getElementById("main-content")?.remove(); Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); diff --git a/tests/work-rail-clinical.dom.test.tsx b/tests/work-rail-clinical.dom.test.tsx new file mode 100644 index 0000000000..c4c540f74a --- /dev/null +++ b/tests/work-rail-clinical.dom.test.tsx @@ -0,0 +1,42 @@ +/** @vitest-environment jsdom */ + +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/my-day", + useRouter: () => ({ replace: vi.fn(), push: vi.fn(), prefetch: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +import { WorkRail } from "@/components/work-frame/work-rail"; +import { LAST_APP_MODE_STORAGE_KEY } from "@/components/clinical-dashboard/use-last-app-mode"; + +const identity = { signedIn: false, displayName: "", initials: "G" } as Parameters[0]["identity"]; + +describe("WorkRail Clinical button", () => { + afterEach(() => window.localStorage.clear()); + + it("goes back to Clinical Answer by default", () => { + render( {}} />); + const link = screen.getByTestId("work-rail-clinical"); + expect(link).toHaveAccessibleName("Clinical, Answer"); + expect(link.getAttribute("href")).toContain("mode=answer"); + }); + + it("goes back to the Clinical mode used last", () => { + window.localStorage.setItem(LAST_APP_MODE_STORAGE_KEY, "dsm"); + render( {}} />); + const link = screen.getByTestId("work-rail-clinical"); + expect(link.getAttribute("href")).toContain("mode=dsm"); + expect(link).toHaveTextContent("Clinical"); + }); + + it.each(["roster", "my-day"])("never links to a Work page when %s was the mode used last", (mode) => { + window.localStorage.setItem(LAST_APP_MODE_STORAGE_KEY, mode); + render( {}} />); + const link = screen.getByTestId("work-rail-clinical"); + expect(link).toHaveAccessibleName("Clinical, Answer"); + expect(link.getAttribute("href")).toContain("mode=answer"); + }); +});