From 80b6133fffb1eabf03482343f2f3d0d0b07e9afa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:26:28 +0000 Subject: [PATCH 01/65] Work: one "Get set up" checklist on My Day, no per-area setup rows (D11) The design audit (13 Oct 2026, D11, owner decision 4) found each area's first page spent its top slot on its own "Finish setting up" row. Those rows are removed from On Call, Teaching and CPD, and My Day's setup card becomes "Get set up": every setup step with a tick when done and a link straight to it, plus Start/Resume and Not now as before. Setup progress and its storage are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- src/components/cme/cme-dashboard.tsx | 4 - src/components/on-call/on-call-home.tsx | 5 -- src/components/teaching/teaching-today.tsx | 5 -- .../work-setup/work-setup-prompt-card.tsx | 66 +++++++++----- src/components/work-setup/work-setup-row.tsx | 85 ------------------- src/components/work-setup/work-setup.css | 8 +- tests/work-setup-page.dom.test.tsx | 58 +++++++------ 7 files changed, 78 insertions(+), 153 deletions(-) delete mode 100644 src/components/work-setup/work-setup-row.tsx diff --git a/src/components/cme/cme-dashboard.tsx b/src/components/cme/cme-dashboard.tsx index 08f8056541..e538dc1b40 100644 --- a/src/components/cme/cme-dashboard.tsx +++ b/src/components/cme/cme-dashboard.tsx @@ -32,7 +32,6 @@ import { CmeYearInWeeks } from "@/components/cme/cme-year-in-weeks"; import { WorkColumns } from "@/components/work-frame/work-desktop-layout"; import { Button } from "@/components/ui/button"; import { NewWorkModeOnly } from "@/components/work-mode-launch/work-mode-launch-provider"; -import { WorkSetupRow } from "@/components/work-setup/work-setup-row"; import { addDays, expandEvents } from "@/lib/calendar/calendar-event"; import { cmeCalendarEvents } from "@/lib/cme/calendar-events"; import { buildCmeCatchUpPlan } from "@/lib/cme/catch-up-plan"; @@ -638,9 +637,6 @@ export function CmeDashboard({ the dates and the year's details on the right (11 Oct 2026 layout audit). */}
- - - {summary} {nextStepNode} {toLog} diff --git a/src/components/on-call/on-call-home.tsx b/src/components/on-call/on-call-home.tsx index 11a6df0f09..e48af5bb61 100644 --- a/src/components/on-call/on-call-home.tsx +++ b/src/components/on-call/on-call-home.tsx @@ -36,7 +36,6 @@ import { useRosterShifts } from "@/components/roster/use-roster-shifts"; import { OnCallEmptyState } from "@/components/on-call/kit/empty-state"; import { cn, eyebrowText } from "@/components/ui-primitives"; import { NewWorkModeOnly } from "@/components/work-mode-launch/work-mode-launch-provider"; -import { WorkSetupRow } from "@/components/work-setup/work-setup-row"; import { onCallCallNowScenarios, onCallCallNowSteps } from "@/lib/on-call/call-now"; import { useOnCallEntries } from "@/lib/on-call/entry-store"; import { pinnedEmergencyEntries } from "@/lib/on-call/handbook-items"; @@ -338,10 +337,6 @@ function OnCallHomeLive({ now: pinnedNow }: { now?: Date } = {}) { <> {numbersOnScreen ? null : } - {/* Never between the emergency pin and crisis lines. When hospital numbers are on screen the crisis lines sit at the foot of the page, as on main. */} - - - } nowSurface="own" diff --git a/src/components/teaching/teaching-today.tsx b/src/components/teaching/teaching-today.tsx index c9bdc206c2..afcfde1e18 100644 --- a/src/components/teaching/teaching-today.tsx +++ b/src/components/teaching/teaching-today.tsx @@ -8,8 +8,6 @@ import { focusRing } from "@/components/card-recipes"; import { InformationPageShell } from "@/components/information-page-shell"; import { ModeBandAction } from "@/components/mode-band/mode-band"; import { WorkGlassButton, WorkMiniRing, WorkRing, useWorkUndoToast } from "@/components/mode-kit/work"; -import { NewWorkModeOnly } from "@/components/work-mode-launch/work-mode-launch-provider"; -import { WorkSetupRow } from "@/components/work-setup/work-setup-row"; import { ModeModuleSkeleton } from "@/components/mode-kit/module-skeleton"; import { LogToCpdSheet } from "@/components/teaching/log-to-cpd-sheet"; import { recordChart } from "@/components/teaching/my-record-model"; @@ -131,9 +129,6 @@ function TodayScreen({ demoMode }: { demoMode: boolean }) {

Today

- - - {now && today ? ( () => undefined; /** - * "Set up Work" on My Day's Today: an offer, never a gate. It shows for a - * signed-in doctor who has not finished or put away setup, resumes where they - * left off, and "Not now" puts it away (with Undo). Setup stays one tap away in - * My Day's More and in Help either way. Drawn only after the stored progress is - * read, so it never appears and then vanishes. + * "Get set up" on My Day's Today: the one setup prompt in Work (design audit + * 13 Oct 2026, D11, owner decision 4 — the per-area "Finish setting up" rows + * were removed so the prompt is not repeated on every area's first page). An + * offer, never a gate. It lists each setup step with a tick when done and a + * link straight to that step, shows for a signed-in doctor who has not + * finished or put away setup, resumes where they left off, and "Not now" puts + * it away (with Undo). Drawn only after the stored progress is read, so it + * never appears and then vanishes. */ export function WorkSetupPromptCard() { const hydrated = useSyncExternalStore( @@ -49,28 +53,44 @@ export function WorkSetupPromptCard() { }; return ( - -
+ +
-

Set up Work

+

Get set up

{started ? workSetupCountLabel(done, total) : "Stage, areas, time zone, roster and alerts"}

- -
+
    + {countedWorkSetupSteps(progress.areas).map((step) => { + const stepDone = progress.completed.includes(step); + const skipped = progress.skipped.includes(step); + return ( +
  • + + + {WORK_SETUP_STEP_COPY[step].short} + + + {stepDone ? ( + + + ) : skipped ? ( + Skipped + ) : ( + To do + )} + + +
  • + ); + })} +
+
() => undefined; - -/** - * "Finish setting up": one line and a chevron, in a flat hairline card, at the - * top of each work area's first page while Work setup is incomplete (site - * review 12 Oct 2026, item 2). It opens the existing setup page, resuming where - * the reader left off. - * - * It reads the same device-only setup progress as My Day's "Set up Work" card, - * so finishing or putting setup away anywhere hides both. The cross puts it - * away on this device (with Undo), exactly as the card's "Not now" does. - * Drawn only after the stored progress is read, so it never flashes in and out. - */ -export function WorkSetupRow({ testId = "work-setup-row" }: { readonly testId?: string }) { - const hydrated = useSyncExternalStore( - subscribeNothing, - () => true, - () => false, - ); - const { progress, update } = useWorkSetupProgress(); - const undoToast = useWorkUndoToast(); - if (!hydrated || !workSetupPromptVisible(progress)) return null; - - const started = progress.status === "in-progress"; - const { done, total } = workSetupCount(progress); - const putAway = () => { - const before = progress.status; - update(dismissWorkSetup); - // Undo restores only the status, so a step finished meanwhile is kept. - undoToast?.("Setup put away. Find it in More or Help.", () => - update((current) => (current.status === "dismissed" ? { ...current, status: before } : current)), - ); - }; - - return ( -
- -
- ); -} diff --git a/src/components/work-setup/work-setup.css b/src/components/work-setup/work-setup.css index 4d15e9a486..c6c016fc7f 100644 --- a/src/components/work-setup/work-setup.css +++ b/src/components/work-setup/work-setup.css @@ -315,7 +315,7 @@ } } -/* My Day's "Set up Work" card. */ +/* My Day's "Get set up" checklist: header, one row per step, then the actions. */ .work-setup-prompt { display: flex; align-items: center; @@ -341,8 +341,9 @@ color: var(--work-ink-muted); } -.work-setup-prompt__bar { - margin-top: calc(var(--spacing) * 3); +.work-setup-prompt__list { + border-top: 1px solid var(--work-line); + border-bottom: 1px solid var(--work-line); } .work-setup-prompt__actions { @@ -350,7 +351,6 @@ flex-wrap: wrap; align-items: center; gap: calc(var(--spacing) * 2); - margin-top: calc(var(--spacing) * 3); } /* The dock: the quiet choice ("Skip for now", "Not now") sits first at its own diff --git a/tests/work-setup-page.dom.test.tsx b/tests/work-setup-page.dom.test.tsx index 09da74572b..1119632fa5 100644 --- a/tests/work-setup-page.dom.test.tsx +++ b/tests/work-setup-page.dom.test.tsx @@ -5,7 +5,7 @@ // step bringing its area back, the step position counting only visible steps, // the done step, and the signed-out welcome asking the doctor to sign in. -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const nav = vi.hoisted(() => ({ @@ -52,7 +52,6 @@ import { universalHeaderLeadingSlotId } from "@/components/work-frame/work-frame import { WorkSetupPage } from "@/components/work-setup/work-setup-page"; import { WORK_SETUP_STEP_COPY, workSetupStepHref } from "@/components/work-setup/work-setup-copy"; import { WorkSetupPromptCard } from "@/components/work-setup/work-setup-prompt-card"; -import { WorkSetupRow } from "@/components/work-setup/work-setup-row"; import { WORK_SETUP_PROGRESS_STORAGE_KEY } from "@/lib/account-scoped-browser-state"; import { parseWorkSetupProgress, @@ -277,7 +276,7 @@ describe("done", () => { }); }); -describe("My Day's Set up Work card", () => { +describe("My Day's Get set up card", () => { it("offers Start to a signed-in doctor who has not begun", () => { render(); expect(screen.getByTestId("work-setup-prompt")).toHaveTextContent("Stage, areas, time zone, roster and alerts"); @@ -315,35 +314,40 @@ describe("My Day's Set up Work card", () => { }); }); -describe("Finish setting up row on each area's first page", () => { - it("opens setup, resuming at the stored step with how many are done", () => { - seed({ step: "roster", completed: ["stage", "areas"] }); - render(); - const link = screen.getByRole("link", { name: /Finish setting up/ }); - expect(link).toHaveAttribute("href", workSetupStepHref("roster")); - expect(link).toHaveTextContent("2 of 7 done"); +// Design audit 13 Oct 2026, D11: the per-area "Finish setting up" rows are +// gone; My Day's card is the one setup prompt and lists every step. +describe("My Day's Get set up checklist", () => { + it("lists each step this doctor sees, with a link straight to it", () => { + render(); + const list = screen.getByRole("list", { name: "Setup steps" }); + const steps = ["stage", "areas", "time-zone", "roster", "rotation", "other-areas", "alerts"] as const; + expect(within(list).getAllByRole("link")).toHaveLength(steps.length); + for (const step of steps) { + expect(screen.getByTestId(`work-setup-prompt-step-${step}`)).toHaveAttribute("href", workSetupStepHref(step)); + expect(screen.getByTestId(`work-setup-prompt-step-${step}`)).toHaveTextContent(WORK_SETUP_STEP_COPY[step].short); + } }); - it("opens the setup page from the start before anything is begun, signed in or not", () => { - auth.status = "signed_out"; - render(); - expect(screen.getByRole("link", { name: "Finish setting up" })).toHaveAttribute("href", "/my-day/setup"); + it("ticks a finished step, tags a skipped one and marks the rest to do", () => { + seed({ step: "roster", completed: ["stage", "areas"], skipped: ["time-zone"] }); + render(); + expect(screen.getByTestId("work-setup-prompt-step-stage")).toHaveTextContent("Done"); + expect(screen.getByTestId("work-setup-prompt-step-areas")).toHaveTextContent("Done"); + expect(screen.getByTestId("work-setup-prompt-step-time-zone")).toHaveTextContent("Skipped"); + expect(screen.getByTestId("work-setup-prompt-step-roster")).toHaveTextContent("To do"); }); - it("puts setup away on this device from its cross", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Hide Finish setting up on this device" })); - expect(screen.queryByTestId("work-setup-row")).not.toBeInTheDocument(); - expect(stored().status).toBe("dismissed"); + it("lists only the steps for the areas this doctor uses", () => { + seed({ areas: ["teach"] }); + render(); + expect(screen.queryByTestId("work-setup-prompt-step-roster")).not.toBeInTheDocument(); + expect(screen.queryByTestId("work-setup-prompt-step-other-areas")).not.toBeInTheDocument(); + expect(screen.getByTestId("work-setup-prompt-step-rotation")).toBeInTheDocument(); }); - it("stays away once setup is finished or put away", () => { - seed({ status: "done", step: "done" }); - const { unmount } = render(); - expect(screen.queryByTestId("work-setup-row")).not.toBeInTheDocument(); - unmount(); - seed({ status: "dismissed" }); - render(); - expect(screen.queryByTestId("work-setup-row")).not.toBeInTheDocument(); + it('is headed "Get set up"', () => { + render(); + expect(screen.getByRole("heading", { name: "Get set up" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Get set up" })).toBeInTheDocument(); }); }); From 00b7bedd386913a76866b81a3bbb6ad3a2705a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:27:02 +0000 Subject: [PATCH 02/65] Work pages: use the standard status shapes (D04) My Day Week and Month: overdue and passed dates show a red triangle before a grey word instead of an amber word. Renewals timeline: amber dots only once it is time to start renewing, grey dots further ahead. CPD rows: open targets use a grey ring. Assessments: accent words such as Not started are grey, not green. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- .../admin/renewals/checklist-summary.tsx | 21 +++++++++++++++---- src/components/cme/cme-flat-list.tsx | 8 +++++-- src/components/my-day/my-day-today-cards.tsx | 5 ++++- src/components/my-day/my-day-week-page.tsx | 21 ++++++++++++------- .../assessments/assessments-parts.tsx | 7 ++++++- tests/my-day-week.dom.test.tsx | 4 ++++ 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/components/admin/renewals/checklist-summary.tsx b/src/components/admin/renewals/checklist-summary.tsx index fe1929fcc4..259d72c88a 100644 --- a/src/components/admin/renewals/checklist-summary.tsx +++ b/src/components/admin/renewals/checklist-summary.tsx @@ -51,8 +51,8 @@ const percent = (offset: number) => `${((offset / 12) * 100).toFixed(2)}%`; /** * "Next 12 months" (Josh's locked mockup, work-mode redesign 6 Oct 2026): one * row per item due for action, its name and date at the left and a track at - * the right with a mark at its date (a triangle once passed, a dot while - * still ahead) in its status colour, a line at today in the mode colour, and + * the right with a mark at its date (a red triangle once passed, an amber dot + * once it is time to start renewing, a grey dot further ahead), a line at today in the mode colour, and * month initials under the tracks. * * The drawing is decorative to assistive technology: a plain list beside it @@ -96,6 +96,13 @@ function ChecklistTimeline({ {items.map((row) => { const passed = row.expiresOn < today; const Mark = passed ? Triangle : Circle; + // Amber only once it is time to start renewing; a date further ahead is a grey dot, + // so the red triangle stands apart from the rest (status shapes, 12 Oct 2026). + const status = passed + ? "date-passed" + : requirementRowUrgency(row, now).word === "Start renewing" + ? "start-renewing" + : "plain"; return (
@@ -123,7 +130,7 @@ function ChecklistTimeline({ aria-hidden="true" data-mark={passed ? "triangle" : "dot"} strokeWidth={2} - {...adminStatusColour(passed ? "date-passed" : "start-renewing")} + {...adminStatusColour(status)} fill="currentColor" /> @@ -149,7 +156,13 @@ function ChecklistTimeline({ - Renew by date ahead + Start renewing + + + + Date ahead diff --git a/src/components/cme/cme-flat-list.tsx b/src/components/cme/cme-flat-list.tsx index 8e33e47829..919bfb19b9 100644 --- a/src/components/cme/cme-flat-list.tsx +++ b/src/components/cme/cme-flat-list.tsx @@ -123,7 +123,11 @@ export function CmeFlatList({ ); } -/** The leading mark on a row: a flat circle (still to do), a ticked one (done), or nothing. */ +/** + * The leading mark on a row: a grey open circle (still to do), a ticked one (done), or nothing. + * Both are grey: colour is kept for passed and due-soon dates (status shapes, 12 Oct 2026), so an + * open target no longer reads as a coloured "not recorded" ring. + */ export function CmeRowMark({ state }: { readonly state: "open" | "done" | "none" }) { if (state === "none") return
) : !readOnly ? (
- + Log the replacement {linkingId === session.id ? ( diff --git a/src/components/cme/cme-year-check-page.tsx b/src/components/cme/cme-year-check-page.tsx index 265112e6bd..9857acb6c7 100644 --- a/src/components/cme/cme-year-check-page.tsx +++ b/src/components/cme/cme-year-check-page.tsx @@ -108,7 +108,7 @@ function CheckRow({ row, set }: { row: CmeYearCheckRow; set: CmeRequirementSet } href={href} data-testid={`cme-check-action-${row.id}`} className="work-button min-h-tap shrink-0" - data-variant="tinted" + data-variant="grey" > {action.label} : {row.label} @@ -384,7 +384,7 @@ export function CmeYearCheckPage({ title={row.title} subtitle={`${formatCmeRowDate(row.date, today)} · ${evidenceRowLine(row)}`} end={ - + Attach : {row.title} diff --git a/tests/cme-dashboard.dom.test.tsx b/tests/cme-dashboard.dom.test.tsx index 19db987cd6..b88bfdf71a 100644 --- a/tests/cme-dashboard.dom.test.tsx +++ b/tests/cme-dashboard.dom.test.tsx @@ -203,10 +203,11 @@ describe("the dashboard", () => { expect(log).toHaveAttribute("href", "/cme/new?year=2026"); expect(log).toHaveAttribute("data-cme-log-trigger"); // work-mode redesign, owner request 6 Oct 2026: filled buttons are the kit's primary - // variant. The dock carries Log an activity; the only other is the next - // step's own Log, as the mock-up draws it. No floating + Log. + // variant. The dock carries Log an activity and is the only one: the next + // step's own Log is a grey row pill (design audit D08, 13 Oct 2026). No floating + Log. const filled = [...container.querySelectorAll('a[data-variant="primary"], button[data-variant="primary"]')]; - expect(filled.map((node) => node.textContent)).toEqual(["Log", "Log an activity"]); + expect(filled.map((node) => node.textContent)).toEqual(["Log an activity"]); + expect(screen.getByTestId("cme-next-log")).toHaveAttribute("data-variant", "grey"); expect(log.closest(".work-dock")).not.toBeNull(); }); From e62e904eae4dd6f7fd2af45e4e59a3e65744f10a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:32:42 +0000 Subject: [PATCH 06/65] CPD Log: one docked button, no floating Copy next (D09) Copy next already sits in the not-marked-copied card above the list, so the dock keeps only Log an activity, full width. The dock stays sticky in flow, so the last row is still reachable above it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- src/components/cme/cme-log-page.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/components/cme/cme-log-page.tsx b/src/components/cme/cme-log-page.tsx index f28b87dce9..97d0ac8d48 100644 --- a/src/components/cme/cme-log-page.tsx +++ b/src/components/cme/cme-log-page.tsx @@ -685,18 +685,8 @@ export function CmeLogPage({
From 91cf056be22fac160c5c7ad963b095e23f3ee105 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:33:18 +0000 Subject: [PATCH 07/65] CPD Log: test that the dock holds one button (D09) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- tests/cme-log.dom.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cme-log.dom.test.tsx b/tests/cme-log.dom.test.tsx index d2f6e667a1..7aa1d6aad6 100644 --- a/tests/cme-log.dom.test.tsx +++ b/tests/cme-log.dom.test.tsx @@ -506,6 +506,9 @@ describe("Log rows, grouped by month", () => { expect(filled.map((node) => node.getAttribute("data-testid"))).toEqual(["cme-log-new-entry"]); expect(screen.getByTestId("cme-log-new-entry")).toHaveTextContent("Log an activity"); expect(screen.queryByTestId("cme-quick-log-button")).toBeNull(); + // One docked button (design audit D09): Copy next stays in its card, never floating as well. + const dock = screen.getByTestId("cme-log-new-entry").closest(".work-dock"); + expect(dock?.querySelectorAll("a, button")).toHaveLength(1); }); it("reminds the owner to keep patient details out of reflections, beside the archived link", async () => { From 4bab692d552c9fa3edca7d9e710c44719599a0fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:33:48 +0000 Subject: [PATCH 08/65] MHA clock: plain list, Start clock from form records, renamed MHA time limits The clock drew a timeline bar per form with no end mark, so forms looked like running timers while every card said "Time not calculated". The bars are gone; the list already carries every figure. Timed form records get a Start clock button that opens the clock with the form (and time, once entered) picked. The page is renamed "MHA time limits" (clinical wording, awaiting owner approval in the draft PR). Design audit D01. No deadline calculation added (S1 needs legal sign-off). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- src/app/(search-app)/forms/clock/page.tsx | 2 +- .../psychiatry/mha-clock/page.tsx | 2 +- src/components/forms/mha-timeline-panel.tsx | 14 ++- src/components/psychiatry/mha-clock-page.tsx | 94 ++++--------------- src/components/psychiatry/psychiatry-home.tsx | 2 +- tests/mha-timeline-panel.dom.test.tsx | 2 + tests/psychiatry-mha-clock.dom.test.tsx | 28 +++++- 7 files changed, 61 insertions(+), 83 deletions(-) diff --git a/src/app/(search-app)/forms/clock/page.tsx b/src/app/(search-app)/forms/clock/page.tsx index 33eac86478..401f7e1b72 100644 --- a/src/app/(search-app)/forms/clock/page.tsx +++ b/src/app/(search-app)/forms/clock/page.tsx @@ -6,7 +6,7 @@ import { consolidatedModeSearchPath } from "@/lib/consolidated-mode-home-redirec import { mhaClockForms } from "@/lib/psychiatry-hub/mha-clock-forms"; export const metadata: Metadata = { - title: "MHA clock | PsychSift", + title: "MHA time limits | PsychSift", description: "Every Mental Health Act form you are holding, with how long it has been running and its time limits.", }; diff --git a/src/app/(search-app)/psychiatry/mha-clock/page.tsx b/src/app/(search-app)/psychiatry/mha-clock/page.tsx index ed3cd9e4d4..7a49693f1c 100644 --- a/src/app/(search-app)/psychiatry/mha-clock/page.tsx +++ b/src/app/(search-app)/psychiatry/mha-clock/page.tsx @@ -4,7 +4,7 @@ import { MhaClockPage } from "@/components/psychiatry/mha-clock-page"; import { mhaClockForms } from "@/lib/psychiatry-hub/mha-clock-forms"; export const metadata: Metadata = { - title: "MHA clock | PsychSift", + title: "MHA time limits | PsychSift", description: "Every Mental Health Act form you are holding, with how long it has been running and its time limits.", }; diff --git a/src/components/forms/mha-timeline-panel.tsx b/src/components/forms/mha-timeline-panel.tsx index 5beeee14a3..74616d449f 100644 --- a/src/components/forms/mha-timeline-panel.tsx +++ b/src/components/forms/mha-timeline-panel.tsx @@ -1,8 +1,10 @@ "use client"; -import { ExternalLink } from "lucide-react"; +import { ExternalLink, Timer } from "lucide-react"; +import Link from "next/link"; import { useId, useMemo, useState } from "react"; +import { buttonFaceClass } from "@/components/ui/button-face"; import { inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; import { cn, textMuted } from "@/components/ui-primitives"; import { mhaActMetadata, mhaActSection } from "@/lib/mha-act-sections"; @@ -101,6 +103,16 @@ export function MhaTimelinePanel({ formCode }: { formCode: string }) {

{hint}

+ {/* Design audit D01: a timed form starts its clock from here, with the form (and the + time, once entered) carried across, so the clock list is one tap from the record. */} + +
    diff --git a/src/components/psychiatry/mha-clock-page.tsx b/src/components/psychiatry/mha-clock-page.tsx index fc7f4f014b..0d03e5d4b4 100644 --- a/src/components/psychiatry/mha-clock-page.tsx +++ b/src/components/psychiatry/mha-clock-page.tsx @@ -238,6 +238,20 @@ function AddClock({ forms, now }: { readonly forms: readonly MhaClockForm[]; rea const [timeError, setTimeError] = useState(false); const timeValue = madeAt ?? (now ? perthInputValue(now) : ""); + // A form record's "Start clock" link arrives as ?form=1A (and &made= when the reader + // had already entered one there), so the form is picked and only Start is left to press. + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const wanted = params.get("form"); + if (!wanted || !forms.some((form) => form.code === wanted)) return; + // Read once the page is in the browser, so the server and first paint agree. + // eslint-disable-next-line react-hooks/set-state-in-effect + setCode(wanted); + const made = params.get("made"); + if (made && parsePerthDateTimeInput(made)) setMadeAt(made); + document.getElementById(ADD_SECTION_ID)?.scrollIntoView({ block: "start" }); + }, [forms]); + const fail = (text: string, onTime: boolean) => { setMessage(text); setTimeError(onTime); @@ -341,81 +355,6 @@ function AddClock({ forms, now }: { readonly forms: readonly MhaClockForm[]; rea ); } -/** - * One lane per clock from when it was made to now, and a shared now line. Decorative: every figure - * on it is also written in the list below. - */ -function ShiftBand({ clocks, now }: { readonly clocks: readonly MhaClock[]; readonly now: Date }) { - const nowMs = now.getTime(); - const earliest = Math.max(Math.min(...clocks.map((clock) => clock.madeAt)), nowMs - 33 * HOUR_MS); - const start = Math.floor(earliest / HOUR_MS) * HOUR_MS; - const end = Math.max(Math.ceil((nowMs + HOUR_MS) / HOUR_MS) * HOUR_MS, start + 6 * HOUR_MS); - const span = end - start; - const pct = (ms: number) => Math.min(100, Math.max(0, ((ms - start) / span) * 100)); - const step = span <= 12 * HOUR_MS ? 3 * HOUR_MS : span <= 24 * HOUR_MS ? 6 * HOUR_MS : 12 * HOUR_MS; - const ticks: number[] = []; - for (let tick = start; tick <= end; tick += step) ticks.push(tick); - const LANE = 14; - const GAP = 8; - const lanesHeight = clocks.length * LANE + (clocks.length - 1) * GAP; - const height = lanesHeight + 20; - const nowX = `${pct(nowMs)}%`; - - // SVG with percentage coordinates, so positions need no inline styles. - return ( - - ); -} - function LimitRow({ item, nowMs }: { readonly item: MhaTimerItem; readonly nowMs: number }) { const { entry } = item; const quote = @@ -787,11 +726,11 @@ export function MhaClockPage({ return (
    - +

    {now ? `${hhmm(now)} Perth time` : null}

    - MHA clock + MHA time limits

    @@ -869,7 +808,6 @@ export function MhaClockPage({ ) : null}

    - {now ? : null} )}

    diff --git a/src/components/psychiatry/psychiatry-home.tsx b/src/components/psychiatry/psychiatry-home.tsx index bb88ce8542..0f2203583f 100644 --- a/src/components/psychiatry/psychiatry-home.tsx +++ b/src/components/psychiatry/psychiatry-home.tsx @@ -407,7 +407,7 @@ function ShiftToolsList({ clockLine }: { readonly clockLine: (clocks: readonly M )} icon={Timer} - title="MHA clock" + title="MHA time limits" subtitle={ {unreadable ? MHA_CLOCK_UNREADABLE : clockLine(clocks)} diff --git a/tests/mha-timeline-panel.dom.test.tsx b/tests/mha-timeline-panel.dom.test.tsx index 5e21746a5b..aaf64ae4ed 100644 --- a/tests/mha-timeline-panel.dom.test.tsx +++ b/tests/mha-timeline-panel.dom.test.tsx @@ -70,6 +70,8 @@ describe("MhaTimelinePanel with unsigned copies of the shipped entries", () => { const input = within(panel).getByLabelText("When was this made?"); expect(input).toHaveAttribute("type", "datetime-local"); + // Design audit D01: the record starts its clock, carrying the form across. + expect(within(panel).getByTestId("mha-timeline-start-clock")).toHaveAttribute("href", "/forms/clock?form=3A"); const items = within(panel).getAllByRole("listitem"); expect(items).toHaveLength(3); diff --git a/tests/psychiatry-mha-clock.dom.test.tsx b/tests/psychiatry-mha-clock.dom.test.tsx index 365a7121bc..f0b2b6149c 100644 --- a/tests/psychiatry-mha-clock.dom.test.tsx +++ b/tests/psychiatry-mha-clock.dom.test.tsx @@ -203,11 +203,37 @@ describe("MHA clock store", () => { describe("MhaClockPage", () => { it("starts with an empty state and the owner-approved reference line", () => { render(); - expect(screen.getByRole("heading", { level: 1, name: "MHA clock" })).toBeTruthy(); + expect(screen.getByRole("heading", { level: 1, name: "MHA time limits" })).toBeTruthy(); expect(screen.getByTestId("mha-clock-empty")).toBeTruthy(); expect(screen.getByTestId("mha-clock-reference")).toHaveTextContent(MHA_TIMELINE_REFERENCE_NOTE); }); + it("picks the form and time a form record's Start clock link carries, and has no decorative bars", () => { + window.history.replaceState(null, "", "/forms/clock?form=3A&made=2026-10-05T17%3A15"); + try { + render(); + expect((screen.getByTestId("mha-clock-form") as HTMLSelectElement).value).toBe("3A"); + expect((screen.getByTestId("mha-clock-time") as HTMLInputElement).value).toBe("2026-10-05T17:15"); + act(() => { + fireEvent.click(screen.getByTestId("mha-clock-start")); + }); + expect(screen.getByTestId("mha-clock-card")).toHaveTextContent("Made Mon 17:15"); + expect(screen.queryByTestId("mha-clock-band")).toBeNull(); + } finally { + window.history.replaceState(null, "", "/"); + } + }); + + it("ignores a form the clock does not offer", () => { + window.history.replaceState(null, "", "/forms/clock?form=ZZ"); + try { + render(); + expect((screen.getByTestId("mha-clock-form") as HTMLSelectElement).value).toBe(""); + } finally { + window.history.replaceState(null, "", "/"); + } + }); + it("starts a clock from the form and time entered, and removes it", () => { render(); fireEvent.change(screen.getByTestId("mha-clock-form"), { target: { value: "3A" } }); From 9812afd7166d8dee03cf5a21c47a51191227c448 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:34:02 +0000 Subject: [PATCH 09/65] Work: shared state card on Open shifts, Manage team and Notifications (D13) Design audit 13 Oct 2026, D13. Manage team's not-a-manager, wrong-team and failed-read states, and the Notifications page's signed-out, failed and empty states, now use the shared work state card (the bell's sheet draws them without the card, as before). Manage team's bare "Try again" blocks become the shared error state, and signed out gets the shared sign-in card. With nobody signed in, Open shifts showed a lone "No teams yet" card because the server's invented team was hidden; it now shows the example open shifts, unless example data is switched off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 - src/components/needs-you/needs-you-sheet.tsx | 40 ++++++---- .../needs-you/notifications-page.tsx | 2 +- src/components/open-shifts/use-open-shifts.ts | 13 +++- .../roster/manage/roster-manage-page.tsx | 72 ++++++++--------- ...pen-shifts-no-account-example.dom.test.tsx | 78 +++++++++++++++++++ 7 files changed, 153 insertions(+), 56 deletions(-) create mode 100644 tests/open-shifts-no-account-example.dom.test.tsx diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 024d23e1c8..5f59909d86 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1058,7 +1058,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `AnswerFooter` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 5 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `Button` | controls | yes | yes | inherited-global-root | yes | no | 129 | +| `Button` | controls | yes | yes | inherited-global-root | yes | no | 128 | | `Checkbox` | controls | yes | yes | inherited-global-root | yes | no | 14 | | `Chip` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `ChoiceChip` | controls | yes | yes | inherited-global-root | yes | no | 7 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 4f7fddc1ea..47c2a4511a 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -375,7 +375,6 @@ "src/components/roster/manage/roster-approve-tab.tsx", "src/components/roster/manage/roster-cover-tab.tsx", "src/components/roster/manage/roster-decision-sheet.tsx", - "src/components/roster/manage/roster-manage-page.tsx", "src/components/roster/manage/roster-people-list.tsx", "src/components/roster/manage/roster-team-settings.tsx", "src/components/roster/requests/roster-dates-sheet.tsx", @@ -509,7 +508,6 @@ "src/components/roster/manage/roster-approve-tab.tsx", "src/components/roster/manage/roster-cover-tab.tsx", "src/components/roster/manage/roster-decision-sheet.tsx", - "src/components/roster/manage/roster-manage-page.tsx", "src/components/roster/manage/roster-people-list.tsx", "src/components/roster/manage/roster-team-settings.tsx", "src/components/roster/requests/roster-dates-sheet.tsx", diff --git a/src/components/needs-you/needs-you-sheet.tsx b/src/components/needs-you/needs-you-sheet.tsx index 53c3342dcc..73aa64844a 100644 --- a/src/components/needs-you/needs-you-sheet.tsx +++ b/src/components/needs-you/needs-you-sheet.tsx @@ -21,14 +21,8 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState, type Keyboard import { useBellPhoneQueue } from "@/components/alerts/use-bell-phone-queue"; import { useRemindMe } from "@/components/alerts/use-remind-me"; -import { - WorkButton, - WorkChip, - WorkChips, - WorkEmpty, - WorkIconCircle, - WorkSectionLabel, -} from "@/components/mode-kit/work"; +import { WorkButton, WorkChip, WorkChips, WorkIconCircle, WorkSectionLabel } from "@/components/mode-kit/work"; +import { WorkStateNotice } from "@/components/mode-kit/work-state"; import { useNotificationFeed, type NotificationFeed } from "@/components/needs-you/use-notification-feed"; import { OnCallNotificationsPanel } from "@/components/on-call/on-call-notifications"; import { Sheet } from "@/components/ui/sheet"; @@ -202,6 +196,12 @@ export type NotificationCentreBodyProps = { readonly active: boolean; /** Runs when a row's link is followed (the sheet closes itself; the page does nothing). */ readonly onNavigate: () => void; + /** + * Where the body is drawn. On the page its states take the shared work state + * card; in the bell's sheet they draw without the card (design audit 13 Oct + * 2026, D13). + */ + readonly surface?: "sheet" | "page"; }; /** @@ -209,7 +209,8 @@ export type NotificationCentreBodyProps = { * rows, row options, snoozed list, notes and Undo bar, with every state the * centre has. The bell's sheet and the Notifications page both draw this. */ -export function NotificationCentreBody({ feed, active, onNavigate }: NotificationCentreBodyProps) { +export function NotificationCentreBody({ feed, active, onNavigate, surface = "sheet" }: NotificationCentreBodyProps) { + const bareState = surface === "sheet"; const [segment, setSegment] = useState("all"); const [area, setArea] = useState(null); const [expanded, setExpanded] = useState(null); @@ -327,7 +328,9 @@ export function NotificationCentreBody({ feed, active, onNavigate }: Notificatio ) : null} {status === "signed-out" ? ( - Sign in to see what needs you.} @@ -335,7 +338,10 @@ export function NotificationCentreBody({ feed, active, onNavigate }: Notificatio ) : status === "loading" ? ( ) : status === "error" ? ( - 0 ? ( // Something did not load, so an empty list is not "all caught up". - The areas that loaded have nothing for you.} /> ) : summary.count === 0 ? ( - Nothing needs you right now.} /> ) : shown.length === 0 ? ( -

    {description}

    - +
    ); diff --git a/src/components/open-shifts/use-open-shifts.ts b/src/components/open-shifts/use-open-shifts.ts index 4f6a02d50b..bf8c4f6639 100644 --- a/src/components/open-shifts/use-open-shifts.ts +++ b/src/components/open-shifts/use-open-shifts.ts @@ -7,6 +7,7 @@ import { fetchRosterRead, useRosterTeams } from "@/components/roster/use-roster- import { useRosterShifts } from "@/components/roster/use-roster-shifts"; import { useSignedOutSample } from "@/components/mode-kit/use-signed-out-sample"; import type { OpenShiftListing } from "@/lib/open-shifts/model"; +import { useAuthIfAvailable, useExampleData } from "@/lib/example-data/store"; import { sampleListings, sampleRoster } from "@/lib/open-shifts/sample"; import type { FatigueShift } from "@/lib/roster/fatigue-rules"; import type { RosterTeam } from "@/lib/roster/team/model"; @@ -77,6 +78,10 @@ async function loadTeam(team: RosterTeam): Promise { export function useOpenShifts(): OpenShiftsState { const example = useSignedOutSample("rost"); + // Nobody signed in, and sign-in has settled: signed out, expired, or a build with no accounts. + const authStatus = useAuthIfAvailable()?.status; + const noAccount = authStatus === "signed_out" || authStatus === "expired" || authStatus === "unconfigured"; + const exampleMode = useExampleData().mode; const online = useOnlineStatus(); const teams = useRosterTeams(); const shifts = useRosterShifts(); @@ -184,8 +189,12 @@ export function useOpenShifts(): OpenShiftsState { const rosterStatus = shifts.status === "ready" ? "ready" : shifts.status === "loading" ? "loading" : "error"; - // With example data off, `useRosterTeams` hides the made-up team, so this falls to "No teams yet". - if (releaseHeld && example) { + // The server sent its made-up team to a reader with no account (design audit + // 13 Oct 2026, D13: Open shifts showed a lone "No teams yet" card where Roster + // beside it showed examples). Show the example open shifts, labelled on the + // page. An explicit "off" is honoured, and a signed-in reader with example data + // off keeps "No teams yet", because `useRosterTeams` hides the made-up team. + if (releaseHeld && noAccount && exampleMode !== "off") { return { ...base, status: "ready", diff --git a/src/components/roster/manage/roster-manage-page.tsx b/src/components/roster/manage/roster-manage-page.tsx index 71fd3c247f..04db3e4818 100644 --- a/src/components/roster/manage/roster-manage-page.tsx +++ b/src/components/roster/manage/roster-manage-page.tsx @@ -4,8 +4,8 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useState } from "react"; import { WithoutModeBand } from "@/components/mode-band/mode-band"; import { InformationPageShell } from "@/components/information-page-shell"; -import { Button } from "@/components/ui/button"; -import { WorkStateLoading } from "@/components/mode-kit/work-state"; +import { WorkStateLoading, WorkStateNotice } from "@/components/mode-kit/work-state"; +import { WorkSignInNotice } from "@/components/mode-kit/work-sign-in-notice"; import { useRosterNow } from "@/components/roster/roster-format"; import { useRosterRead, useRosterTeams } from "@/components/roster/use-roster-team"; import type { RosterTeam } from "@/lib/roster/team/model"; @@ -19,7 +19,7 @@ import { RosterSafeNumber } from "./roster-safe-number"; import { useRosterSignedOutSample } from "@/components/roster/roster-sample-context"; import { RosterPublishTab } from "./publish/roster-publish-tab"; import { ClipboardList, Lock, Users } from "lucide-react"; -import { WorkButton, WorkCard, WorkEmpty } from "@/components/mode-kit/work"; +import { WorkButton } from "@/components/mode-kit/work"; import { RosterPageHeader, rosterField } from "@/components/roster/roster-ui"; /** @@ -52,21 +52,25 @@ function useManageSection(): [string, (id: string) => void] { return [section, select]; } -/** Not a roster manager here: say so, and offer the doctor's own way on instead of a blank page. */ +/** + * Not a roster manager here: say so, and offer the doctor's own way on instead + * of a blank page. Every state on this page uses the shared work state card + * (design audit 13 Oct 2026, D13). + */ function NotManager() { return ( - - - Go to Team - - } - /> - + + Go to Team + + } + testId="roster-manage-not-manager" + /> ); } @@ -98,10 +102,7 @@ function ManagerTeam({ ); if (overview.status === "error") return ( -
    -

    {overview.message}

    - -
    + ); if (!overview.data) return ; if (overview.data.me.role !== "manager") return ; @@ -189,24 +190,23 @@ export function RosterManagePage() { /> {teams.status === "loading" ? ( + ) : teams.status === "signed-out" ? ( + ) : teams.status !== "ready" ? ( -
    -

    {teams.message}

    - -
    + ) : !team && available.length > 0 ? ( - - setSelected(available[0]!.serviceId)}> - {`Open ${available[0]!.name}`} - - } - /> - + setSelected(available[0]!.serviceId)}> + {`Open ${available[0]!.name}`} + + } + testId="roster-manage-not-your-team" + /> ) : !team ? ( ) : ( diff --git a/tests/open-shifts-no-account-example.dom.test.tsx b/tests/open-shifts-no-account-example.dom.test.tsx new file mode 100644 index 0000000000..23ecba8a4a --- /dev/null +++ b/tests/open-shifts-no-account-example.dom.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/* + * Design audit 13 Oct 2026, D13: with nobody signed in, Open shifts showed a + * lone "No teams yet" card while Roster beside it showed examples, because the + * server's invented team was hidden. With no account, the page now shows the + * example open shifts; an explicit "off", or a signed-in reader, keeps + * "No teams yet". Every team here is invented. + */ + +const state = vi.hoisted(() => ({ + auth: "unconfigured" as string, + mode: "auto" as "auto" | "on" | "off", +})); + +vi.mock("@/components/roster/roster-format", async (original) => ({ + ...(await original()), + useRosterNow: () => new Date("2026-10-07T01:00:00Z"), +})); +vi.mock("@/components/roster/use-roster-team", () => ({ + // What `useRosterTeams` hands on when example data is not showing: the invented team, hidden. + useRosterTeams: () => ({ + status: "ready", + data: { actorId: "example-me", sample: true, teams: [] }, + message: null, + reload: () => undefined, + }), + fetchRosterRead: vi.fn(async () => ({ ok: false })), +})); +vi.mock("@/components/roster/use-roster-shifts", () => ({ + useRosterShifts: () => ({ status: "ready", shifts: [], reload: async () => undefined }), +})); +vi.mock("@/components/mode-kit/use-signed-out-sample", () => ({ useSignedOutSample: () => false })); +vi.mock("@/lib/example-data/store", () => ({ + useAuthIfAvailable: () => ({ status: state.auth }), + useExampleData: () => ({ mode: state.mode }), +})); +vi.mock("@/lib/teaching/page-visibility", () => ({ setOpenShiftsIsPoster: vi.fn() })); +vi.mock("@/lib/use-online-status", () => ({ useOnlineStatus: () => true })); + +import { useOpenShifts } from "@/components/open-shifts/use-open-shifts"; + +afterEach(() => { + state.auth = "unconfigured"; + state.mode = "auto"; +}); + +describe("Open shifts with nobody signed in", () => { + it("shows the example open shifts when the server sends its invented team", () => { + for (const auth of ["unconfigured", "signed_out", "expired"]) { + state.auth = auth; + const { result, unmount } = renderHook(() => useOpenShifts()); + expect(result.current.status).toBe("ready"); + expect(result.current.sample).not.toBeNull(); + expect(result.current.listings.length).toBeGreaterThan(0); + unmount(); + } + }); + + it("honours example data switched off", () => { + state.mode = "off"; + const { result } = renderHook(() => useOpenShifts()); + expect(result.current.status).toBe("no-team"); + expect(result.current.listings).toHaveLength(0); + }); + + it("keeps No teams yet for a signed-in reader, and while sign-in is still settling", () => { + for (const auth of ["authenticated", "loading"]) { + state.auth = auth; + const { result, unmount } = renderHook(() => useOpenShifts()); + expect(result.current.status).toBe("no-team"); + expect(result.current.sample).toBeNull(); + unmount(); + } + }); +}); From ee1491faaeaa104008468079aabc5ee5c6cb2787 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:34:36 +0000 Subject: [PATCH 10/65] Answer source marker: invisible 48px tap area, same look and text flow (D05) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- src/app/globals.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/app/globals.css b/src/app/globals.css index 3a7888c755..aeee3de448 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2781,6 +2781,22 @@ html[data-motion="reduced"] .shared-compare-tray-layer[data-scroll-hidden="true" vertical-align: baseline; } +/* The tap area. The box above is ~18px, far under the 48px tap rule, and it cannot + grow without growing the line box. This invisible square, centred on the mark, + takes the tap instead: absolutely positioned, so it changes neither the visible + size nor the text flow (audit D05, 2026-10-13). Adjacent marks in a cluster + overlap here; the later mark paints last and takes a tap in the overlap, which + still opens a source of that same sentence. */ +.answer-source-mark::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: max(100%, 3rem); + height: 3rem; + transform: translate(-50%, -50%); +} + /* The partial-support glyph. One glyph reads at this size; a dotted underline and a 1px bottom border were both tried under it and neither draws. */ .answer-source-mark-star { From cc01157a4a9eab7de23733c1fd3172dee06a392f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:36:27 +0000 Subject: [PATCH 11/65] Answer page: key points and answer actions wrap at 320px large text (D17) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LoS9GKm4qEqfAWLm5A8wEw --- src/components/clinical-dashboard/answer-result-surface.tsx | 4 +++- src/components/clinical-dashboard/evidence-panels.tsx | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index a8f4e7a5e1..108c64d5d2 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -351,7 +351,9 @@ function StagedAnswerResultSurfaceImpl({
    Key points diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 60172553ea..6ee9c8214f 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -151,7 +151,10 @@ export function AnswerUtilityActions({ name rather than "Thumbs up". Evidence gaps are NOT here: they are a statement about the answer's evidence and belong with the safety chip in the header, which is also what keeps this row to one line. */} -
    + {/* `chatActionRow` wraps. It used to be forced onto one line, which at 320px with + large text pushed "Report a problem" off the right edge (audit D17, + 2026-10-13); at ordinary sizes the row still fits on one line. */} +
    ); } +function pinsAreProductDefault(pinnedModeIds: readonly AppModeId[]): boolean { + return ( + pinnedModeIds.length === defaultSidebarPinnedModeIds.length && + pinnedModeIds.every((id, index) => id === defaultSidebarPinnedModeIds[index]) + ); +} + +// Loaded on first open: the drawer is on every page and most readers never open this. +const LazyCrisisLinesCard = dynamic(() => import("@/components/crisis-lines-card").then((mod) => mod.CrisisLinesCard)); +const SIDEBAR_CRISIS_CONTACTS = [ + ...WA_CRISIS_CONTACTS.filter((contact) => + ["SYN-CRISIS-CONTACT-001", "SYN-CRISIS-CONTACT-002", "SYN-CRISIS-CONTACT-005"].includes(contact.id), + ), + POISONS_INFORMATION_CONTACT, +]; + +const urgentShortcutLinks = [ + { id: "mha-time-limits", label: "MHA time limits", href: "/psychiatry/mha-clock", icon: Timer }, + { id: "safety-plan", label: "Safety plan", href: "/safety-plan", icon: ShieldCheck }, + { id: "forms", label: "Forms", href: "/forms", icon: FileText }, +] as const; + +/** + * The phone drawer's default shortcuts (design audit D14): the tools a clinician needs fast, + * none of which the page groups below already list as a row. Crisis numbers open in place with + * the same call rows as the answer page and Admin Help, so a number is two taps from any page. + */ +function UrgentShortcuts({ rowItem, onNavigate }: { rowItem: string; onNavigate?: () => void }) { + const [crisisOpen, setCrisisOpen] = useState(false); + return ( + <> +
    setCrisisOpen(event.currentTarget.open)} + > + + +
    + {crisisOpen ? ( + + ) : null} +
    +
    + {urgentShortcutLinks.map((item) => { + const Icon = item.icon; + return ( + onNavigate?.()} + data-testid={`sidebar-urgent-${item.id}`} + className={cn(rowItem, "min-h-12 border border-transparent")} + > +
    +

    + For privacy, this page stores only this offline screen and public application assets. It does not store or + replay clinical queries, answers, documents, uploads, signed URLs, or API responses. The numbers and quotes + above are public, non-patient information, each with its source and the date it was checked. If you use On Call, + this screen can also show the shared On Call numbers saved on this phone in the last seven days, never your + personal entries; they are cleared when you sign out. +

    Try again