+
First Nations
@@ -213,7 +208,7 @@ function PendingSectionBody({ formCode, pdfHref }: { formCode?: string; pdfHref?
className="text-[color:var(--clinical-accent)] underline underline-offset-2"
href={mhaActMetadata.sourceUrl}
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
{`Mental Health Act 2014 (WA), version ${mhaActMetadata.actVersion} as at ${mhaActMetadata.actAsAt}`}
@@ -224,12 +219,27 @@ function PendingSectionBody({ formCode, pdfHref }: { formCode?: string; pdfHref?
className="text-[color:var(--clinical-accent)] underline underline-offset-2"
href={pdfHref}
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
{formCode ? `Official Form ${formCode} PDF` : "Official form PDF"}
- ) : null}
+ ) : (
+
+
+ {formCode ? `Official Form ${formCode} PDF unavailable` : "Official form PDF unavailable"}
+
+
+ Official PDF link is not available for this form; consult the Chief Psychiatrist register.
+
+
+ )}
);
@@ -443,6 +453,14 @@ export function PriorityFactsSection({ form, cards }: { form: FormRecord; cards:
>
{`Section ${entry.section}`}
{` — ${entry.title}`}
+ {entry.reviewStatus === "drafted" ? (
+
+ Awaiting clinical review
+
+ ) : null}
))}
diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx
index b10c7c047e..388b5b58fa 100644
--- a/src/components/formulation/formulation-builder-page.tsx
+++ b/src/components/formulation/formulation-builder-page.tsx
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { useMemo, useState, useDeferredValue, useEffect } from "react";
import { useDirtyStateGuard } from "@/components/ui/use-dirty-state-guard";
+import { subscribeAccountTransition } from "@/lib/account-scoped-browser-state";
import {
FormulationPageShell,
@@ -42,6 +43,8 @@ import {
type FormulationMechanismSummary,
} from "@/lib/formulation-mechanism-index";
+const STORAGE_KEY = "psychsift_formulation_draft";
+
const builderSteps = [
{ id: "select", label: "Select", description: "Mechanisms" },
{ id: "structure", label: "Structure", description: "Framework" },
@@ -208,7 +211,7 @@ export function FormulationBuilderPage({
const initialDraft = useMemo(() => {
if (typeof window === "undefined") return null;
try {
- const raw = sessionStorage.getItem("psychsift_formulation_draft");
+ const raw = sessionStorage.getItem(STORAGE_KEY);
return raw
? (JSON.parse(raw) as {
selectedIds?: string[];
@@ -272,14 +275,36 @@ export function FormulationBuilderPage({
useDirtyStateGuard(isDirty);
useEffect(() => {
- if (isDirty || selectedIds.length > 0) {
- sessionStorage.setItem(
- "psychsift_formulation_draft",
- JSON.stringify({ sectionNotes, qualityNotes, selectedIds, templateId, editedDraft }),
- );
+ try {
+ if (isDirty || selectedIds.length > 0) {
+ sessionStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ sectionNotes, qualityNotes, selectedIds, templateId, editedDraft }),
+ );
+ } else {
+ sessionStorage.removeItem(STORAGE_KEY);
+ }
+ } catch {
+ // Ignore storage errors
}
}, [isDirty, sectionNotes, qualityNotes, selectedIds, templateId, editedDraft]);
+ useEffect(() => {
+ return subscribeAccountTransition(() => {
+ setSelectedIds([]);
+ setTemplateId(validInitialTemplate);
+ setSectionNotes({});
+ setQualityNotes({});
+ setEditedDraft(null);
+ setActiveStep("select");
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Ignore storage errors
+ }
+ });
+ }, [validInitialTemplate]);
+
function toggleMechanism(id: string) {
setSelectedIds((current) =>
current.includes(id) ? current.filter((item) => item !== id) : normalizeMechanismSelection([...current, id]),
@@ -289,7 +314,14 @@ export function FormulationBuilderPage({
function clearMechanisms() {
setSelectedIds([]);
+ setSectionNotes({});
+ setQualityNotes({});
setEditedDraft(null);
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Ignore storage errors
+ }
}
function updateSection(id: string, value: string) {
@@ -570,6 +602,10 @@ export function FormulationBuilderPage({
rows={3}
aria-label={section.label}
placeholder="Add de-identified case evidence…"
+ autoComplete="off"
+ spellCheck={false}
+ data-gramm="false"
+ data-enable-grammarly="false"
className={cn(fieldControlPlain, "mt-3 h-auto min-h-24 resize-y p-3 font-medium leading-6")}
/>
{suggestions.length ? (
@@ -630,6 +666,10 @@ export function FormulationBuilderPage({
rows={4}
aria-label={prompt.label}
placeholder="Record a concise review note…"
+ autoComplete="off"
+ spellCheck={false}
+ data-gramm="false"
+ data-enable-grammarly="false"
className={cn(fieldControlPlain, "mt-3 h-auto min-h-28 resize-y p-3 font-medium leading-6")}
/>
{starter.length ? (
@@ -693,6 +733,10 @@ export function FormulationBuilderPage({
onChange={(event) => setEditedDraft(event.target.value)}
rows={22}
aria-label="Formulation draft"
+ autoComplete="off"
+ spellCheck={false}
+ data-gramm="false"
+ data-enable-grammarly="false"
className={cn(fieldControlPlain, "h-auto min-h-[32rem] resize-y rounded-xl p-4 font-mono leading-6")}
/>
diff --git a/src/components/last-place/shift-now-probe.tsx b/src/components/last-place/shift-now-probe.tsx
index 3d3ebe8df3..41ace78087 100644
--- a/src/components/last-place/shift-now-probe.tsx
+++ b/src/components/last-place/shift-now-probe.tsx
@@ -12,7 +12,7 @@ import { summariseToday } from "@/lib/roster/today";
* "on now" rule My Day uses for its hero.
*/
export function ShiftNowProbe({ onResult }: { readonly onResult: (onShiftNow: boolean) => void }) {
- const roster = useRosterShifts();
+ const roster = useRosterShifts(undefined, { skipWhenUnauthenticated: true });
const reported = useRef(false);
useEffect(() => {
if (reported.current) return;
diff --git a/src/components/my-day/my-day-strip-day.tsx b/src/components/my-day/my-day-strip-day.tsx
new file mode 100644
index 0000000000..1ee8782d4e
--- /dev/null
+++ b/src/components/my-day/my-day-strip-day.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import Link from "next/link";
+
+import { focusRing } from "@/components/card-recipes";
+import { cn } from "@/components/ui-primitives";
+import { dayCode, perthWeekday, SHIFT_CODE } from "@/lib/my-day/quiet-figures";
+import { SHIFT_KIND_LABEL, type ShiftKind } from "@/lib/roster/shift-kind";
+import { formatPerthDay } from "@/lib/roster/shifts/perth-time";
+
+/** A roster kind's bar colour: the work-mode shift-code tokens (a Late is the same violet everywhere). */
+export const SHIFT_BAR: Readonly
> = {
+ day: "bg-[color:var(--work-shift-d)]",
+ evening: "bg-[color:var(--work-shift-l)]",
+ night: "bg-[color:var(--work-shift-n)]",
+ on_call: "bg-[color:var(--work-shift-oc)]",
+ leave: "bg-[color:var(--work-shift-al)]",
+ other: "bg-[color:var(--work-shift-d)]",
+};
+
+export function hoursText(value: number): string {
+ return `${Number(value.toFixed(1))}`;
+}
+
+/** One day in the strip: its weekday, its date, a bar in its shift's colour and the code in words. */
+export function StripDay({
+ date,
+ today,
+ kinds,
+ due,
+ href,
+}: {
+ readonly date: string;
+ readonly today: string;
+ /** Null when no roster is available: then no code is drawn, rather than a false "off". */
+ readonly kinds: readonly ShiftKind[] | null;
+ readonly due: number;
+ /** Where a tap goes (the Week page), or nothing. */
+ readonly href?: string;
+}) {
+ const code = kinds ? dayCode(kinds) : null;
+ const off = kinds !== null && code === null;
+ const isToday = date === today;
+ const words = [
+ kinds ? (kinds.length ? kinds.map((kind) => SHIFT_KIND_LABEL[kind]).join(" and ") : "Off") : "",
+ due ? `${due} due` : "",
+ ]
+ .filter(Boolean)
+ .join(", ");
+ const tile = cn(
+ "relative grid min-h-13 content-center justify-items-center gap-0.5 rounded-xl border py-1.5 no-underline forced-colors:border",
+ isToday
+ ? "border-transparent bg-[color:var(--mode-identity)]"
+ : "border-[color:var(--work-line)] bg-[color:var(--work-surface)]",
+ );
+ const inner = (
+ <>
+ {words ? `${formatPerthDay(date)}: ${words}` : formatPerthDay(date)}
+
+ {perthWeekday(date)}
+
+
+ {Number(date.slice(8, 10))}
+
+
+ {due ? (
+
+ ) : null}
+ >
+ );
+ return (
+
+ {href ? (
+
+ {inner}
+
+ ) : (
+ {inner}
+ )}
+
+ {code ? SHIFT_CODE[code] : off ? "off" : ""}
+
+
+ );
+}
diff --git a/src/components/my-day/my-day-today-cards.tsx b/src/components/my-day/my-day-today-cards.tsx
index abd9ca10ea..3c565caae2 100644
--- a/src/components/my-day/my-day-today-cards.tsx
+++ b/src/components/my-day/my-day-today-cards.tsx
@@ -60,7 +60,6 @@ import {
MY_DAY_AREA_NAME,
perthWeekday,
relativeDays,
- SHIFT_CODE,
shiftTitle,
shortMonth,
weekdayTime,
@@ -840,15 +839,9 @@ export interface DayDetail {
readonly actionLabel?: string;
}
-/** A roster kind's bar colour: the work-mode shift-code tokens (a Late is the same violet everywhere). */
-export const SHIFT_BAR: Readonly> = {
- day: "bg-[color:var(--work-shift-d)]",
- evening: "bg-[color:var(--work-shift-l)]",
- night: "bg-[color:var(--work-shift-n)]",
- on_call: "bg-[color:var(--work-shift-oc)]",
- leave: "bg-[color:var(--work-shift-al)]",
- other: "bg-[color:var(--work-shift-d)]",
-};
+import { hoursText, SHIFT_BAR, StripDay } from "@/components/my-day/my-day-strip-day";
+
+export { hoursText, SHIFT_BAR, StripDay };
/** The legend's words for each kind, in the order the mockup lists them. */
const SHIFT_LEGEND: readonly (readonly [ShiftKind, string])[] = [
@@ -1174,103 +1167,6 @@ function MonthDay({
);
}
-/** One day in the strip: its weekday, its date, a bar in its shift's colour and the code in words. */
-export function StripDay({
- date,
- today,
- kinds,
- due,
- href,
-}: {
- readonly date: string;
- readonly today: string;
- /** Null when no roster is available: then no code is drawn, rather than a false "off". */
- readonly kinds: readonly ShiftKind[] | null;
- readonly due: number;
- /** Where a tap goes (the Week page), or nothing. */
- readonly href?: string;
-}) {
- const code = kinds ? dayCode(kinds) : null;
- const off = kinds !== null && code === null;
- const isToday = date === today;
- const words = [
- kinds ? (kinds.length ? kinds.map((kind) => SHIFT_KIND_LABEL[kind]).join(" and ") : "Off") : "",
- due ? `${due} due` : "",
- ]
- .filter(Boolean)
- .join(", ");
- const tile = cn(
- "relative grid min-h-13 content-center justify-items-center gap-0.5 rounded-xl border py-1.5 no-underline forced-colors:border",
- isToday
- ? "border-transparent bg-[color:var(--mode-identity)]"
- : "border-[color:var(--work-line)] bg-[color:var(--work-surface)]",
- );
- const inner = (
- <>
- {words ? `${formatPerthDay(date)}: ${words}` : formatPerthDay(date)}
-
- {perthWeekday(date)}
-
-
- {Number(date.slice(8, 10))}
-
-
- {due ? (
-
- ) : null}
- >
- );
- return (
-
- {href ? (
-
- {inner}
-
- ) : (
- {inner}
- )}
-
- {code ? SHIFT_CODE[code] : off ? "off" : ""}
-
-
- );
-}
-
/** The strip of seven days. Opts out of the tab swipe, so a sideways drag here never changes the page. */
export function WeekStrip({ children, testId }: { readonly children: ReactNode; readonly testId: string }) {
return (
@@ -1664,10 +1560,6 @@ export function NeedsYouCard({
// ---------------------------------------------------------------- CPD
-export function hoursText(value: number): string {
- return `${Number(value.toFixed(1))}`;
-}
-
/**
* CPD this year as one row: the hours logged out of the year's target and the share done, opening
* CPD, where the breakdown by type and the pace live (Work mode simplify plan, 12 Oct 2026). Only
diff --git a/src/components/my-day/my-day-week-page.tsx b/src/components/my-day/my-day-week-page.tsx
index c79b214e56..da4bf8e17f 100644
--- a/src/components/my-day/my-day-week-page.tsx
+++ b/src/components/my-day/my-day-week-page.tsx
@@ -13,7 +13,6 @@ import { MyDayFrame } from "@/components/my-day/my-day-frame";
import { listNames } from "@/components/my-day/my-day-page-parts";
import { clashWithShifts } from "@/components/my-day/my-day-clash";
import { AreaIcon, DateBlock, QuietFoot, quietCard } from "@/components/my-day/my-day-quiet";
-import { hoursText, MonthView, MyDaySegmented, StripDay, type DayDetail } from "@/components/my-day/my-day-today-cards";
import { cmeRoutineItemsThrough } from "@/components/my-day/sources/cme";
import { useMyDayItems } from "@/components/my-day/use-my-day-items";
import { kindOf } from "@/components/roster/roster-format";
@@ -30,7 +29,7 @@ import type { CmeRoutine } from "@/lib/cme/routines";
import type { MyDayItem, MyDaySourceMode } from "@/lib/my-day/model";
import { dueCountsByDate } from "@/lib/my-day/dashboard";
import { hoursBars, kindsByDate as kindsByDateOf, mondayIndex } from "@/lib/my-day/figures";
-import { duePerthDate, mergeMyDayItems } from "@/lib/my-day/merge";
+import { mergeMyDayItems } from "@/lib/my-day/merge";
import { perthWeekday, shiftTitle, weekdayTime } from "@/lib/my-day/quiet-figures";
import { myDayItemWeekDate, myDayWeekDates, myDayWeekDayLabel, myDayWeekRangeLabel } from "@/lib/my-day/week";
import { DEFAULT_REMINDER_SETTINGS, type ReminderSettings } from "@/lib/reminders/settings";
@@ -38,6 +37,8 @@ import { addDaysToDate, perthDateOf, perthTimeOf } from "@/lib/roster/shifts/per
import { zonedDateOf } from "@/lib/work-time/format";
import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone";
+import { hoursText, StripDay } from "@/components/my-day/my-day-strip-day";
+
/** The signed-out sample, downloaded only when a signed-out visitor opens this page. */
const MyDayWeekSample = dynamic(
() => import("@/components/my-day/my-day-sample-subpages").then((module) => module.MyDaySubpageSampleView),
@@ -84,23 +85,6 @@ export function MyDayWeekPage({ now }: { now?: Date } = {}) {
);
}
-type WeekView = "week" | "month";
-
-interface WeekViewProps {
- readonly view: WeekView;
- readonly onView: (view: WeekView) => void;
- /** The month the calendar shows ("2026-11"), or null for this month. */
- readonly shownMonth: string | null;
- readonly onMonth: (month: string) => void;
-}
-
-/** The last day of a month ("2026-10" to "2026-10-31"). */
-function monthEnd(month: string): string {
- const [year, monthNumber] = month.split("-").map(Number);
- const days = new Date(Date.UTC(year!, monthNumber!, 0)).getUTCDate();
- return `${month}-${String(days).padStart(2, "0")}`;
-}
-
function MyDayWeekBody({ now }: { now: Date }) {
const { zone } = useWorkTimeZone();
const today = zonedDateOf(now, zone);
@@ -430,25 +414,11 @@ function entriesOn(
]);
}
-/** A row of the month's chosen day, in the same words as the agenda. */
-function dayDetail(entry: AgendaEntry): DayDetail {
- return {
- key: entry.key,
- mode: entry.identity,
- title: entry.time && entry.time !== "All day" ? `${entry.title} ${entry.time}` : entry.title,
- subtitle: [entry.state, entry.area, entry.detail, entry.clash].filter(Boolean).join(" · ") || undefined,
- passed: entry.shape === "passed",
- href: entry.href,
- actionLabel: "Open",
- };
-}
-
/**
- * Week or Month. Week: the seven days as a strip (the same codes as Today),
- * then one card with a date block per day and its timed rows, then a pointer
- * to Roster. Month: the calendar of shifts and due dates, and the chosen
- * day's rows. Drawn from whatever shifts, sessions and items it is given: the
- * reader's own, or the signed-out sample.
+ * Week: the seven days as a strip (the same codes as Today), then one card with
+ * a date block per day and its timed rows, then a pointer to Roster. Drawn from
+ * whatever shifts, sessions and items it is given: the reader's own, or the
+ * signed-out sample.
*/
export function MyDayWeekDays({
now,
@@ -459,10 +429,6 @@ export function MyDayWeekDays({
reminders,
rosterKnown,
hoursPartial = false,
- view = "week",
- onView,
- shownMonth = null,
- onMonth,
notices,
}: {
readonly now: Date;
@@ -475,19 +441,16 @@ export function MyDayWeekDays({
readonly rosterKnown: boolean;
/** Team shifts failed or are still refreshing: a partial roster cannot support an honest hours total. */
readonly hoursPartial?: boolean;
- /** Notices drawn under the Week and Month switch. */
+ /** Notices drawn above the week strip. */
readonly notices?: ReactNode;
-} & Partial) {
+}) {
const { zone } = useWorkTimeZone();
const today = zonedDateOf(now, zone);
const dates = useMemo(() => myDayWeekDates(today), [today]);
const lastDate = dates[dates.length - 1]!;
- const month = shownMonth ?? today.slice(0, 7);
- // CPD routines are listed as far ahead as the page shows.
- const through = view === "month" && monthEnd(month) > lastDate ? monthEnd(month) : lastDate;
const merged = useMemo(
- () => mergeMyDayItems([items, cmeRoutineItemsThrough(cmeRoutines, through, now, reminders)]),
- [items, cmeRoutines, through, now, reminders],
+ () => mergeMyDayItems([items, cmeRoutineItemsThrough(cmeRoutines, lastDate, now, reminders)]),
+ [items, cmeRoutines, lastDate, now, reminders],
);
const entriesByDay = useMemo(
() =>
@@ -525,159 +488,123 @@ export function MyDayWeekDays({
zonedDateOf(Date.parse(shift.endsAt) - 1, zone) === date,
);
const at = now.getTime();
- const detailFor = (date: string): DayDetail[] =>
- entriesOn(date, shifts, sessions, merged, (item) => {
- // What is already late waits on today, as in the week.
- const due = duePerthDate(item.due);
- return due !== null && due < today ? today : due;
- }).map(dayDetail);
return (
- {onView ? (
-
- ) : null}
{notices}
- {view === "month" ? (
-
- ) : (
- <>
- {/* From 1024px the agenda below is seven day columns with their own dates, so the strip would repeat them. */}
-
- {dates.map((date) => (
-
- ))}
-
+ {/* From 1024px the agenda below is seven day columns with their own dates, so the strip would repeat them. */}
+
+ {dates.map((date) => (
+
+ ))}
+
-
+
+
+ {rosterKnown && !hoursPartial ? (
+ // Hours left the main tabs (usefulness audit, 11 Oct 2026): one line here, over the same Monday-to-Sunday week as Hours.
+
li+li]:border-t [&>li+li]:border-[color:var(--work-line)]",
- // Desktop (usefulness audit, 11 Oct 2026): the week as seven day columns, not one long list.
- "lg:grid-cols-7 lg:px-0 lg:[&>li+li]:border-t-0 lg:[&>li+li]:border-l",
+ focusRing,
+ "flex min-h-12 min-w-0 items-center gap-2.5 px-3 py-2.5 text-inherit no-underline",
)}
>
- {dates.map((date) => {
- const entries = entriesByDay.get(date) ?? [];
- const isToday = date === today;
- const nowIndex = isToday
- ? entries.findIndex((entry) => entry.startsAt !== null && entry.startsAt > at)
- : -1;
- const ending = entries.length === 0 ? endsOn(date) : undefined;
- return (
-
- {myDayWeekDayLabel(date, today)}
-
-
-
- {entries.length > 0 ? (
-
- {entries.map((entry, index) => (
-
- {index === nowIndex ? : null}
-
-
- ))}
- {isToday && nowIndex === -1 && entries.some((entry) => entry.startsAt !== null) ? (
-
- ) : null}
-
- ) : (
-
- {!rosterKnown
- ? "Roster not loaded, so shifts are not shown"
- : ending
- ? `${shiftTitle(kindOf(ending))} ends ${perthTimeOf(ending.endsAt)} · nothing else on`
- : "Nothing on"}
-
- )}
-
- );
- })}
-
-
-
- {rosterKnown && !hoursPartial ? (
- // Hours left the main tabs (usefulness audit, 11 Oct 2026): one line here, over the same Monday-to-Sunday week as Hours.
-
-
-
-
- {`${hoursText(rosteredHours)} h rostered this week`}
-
- Hours by week and fortnight, not pay
-
-
-
- ) : null}
-
-
-
-
- All your shifts are in Roster
-
- Swaps, leave and your team
+
+
+
+ {`${hoursText(rosteredHours)} h rostered this week`}
-
-
-
-
-
- From Roster, Teaching, CPD and Admin. Each item opens the page that owns it.
-
-
- >
- )}
+
Hours by week and fortnight, not pay
+
+
+
+ ) : null}
+
+
+
+ All your shifts are in Roster
+ Swaps, leave and your team
+
+
+
+
+
+ From Roster, Teaching, CPD and Admin. Each item opens the page that owns it.
+
);
}
diff --git a/src/components/needs-you/notification-centre.css b/src/components/needs-you/notification-centre.css
index 3a65954976..4d9a201a59 100644
--- a/src/components/needs-you/notification-centre.css
+++ b/src/components/needs-you/notification-centre.css
@@ -23,7 +23,7 @@
outline: 2px solid var(--surface-raised);
background: var(--mode-identity, var(--clinical-accent));
color: var(--mode-identity-contrast, var(--text-inverse, var(--surface-raised)));
- font-size: 0.625rem;
+ font-size: 0.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: var(--leading-tight);
diff --git a/src/components/on-call/handover/handover-page.tsx b/src/components/on-call/handover/handover-page.tsx
index 92581bb78a..bc5449cbe5 100644
--- a/src/components/on-call/handover/handover-page.tsx
+++ b/src/components/on-call/handover/handover-page.tsx
@@ -386,7 +386,9 @@ function NoteArea({
rows={rows}
placeholder={placeholder}
autoComplete="off"
- spellCheck
+ spellCheck={false}
+ data-gramm="false"
+ data-enable-grammarly="false"
onChange={(event) => onChange(event.target.value)}
data-testid={testId}
className={cn(fieldControlPlain, "min-h-20 py-2 text-base-minus")}
@@ -424,6 +426,9 @@ function TextLine({
maxLength={maxLength}
placeholder={placeholder}
autoComplete="off"
+ spellCheck={false}
+ data-gramm="false"
+ data-enable-grammarly="false"
onChange={(event) => onChange(event.target.value)}
data-testid={testId}
className={inputClass}
@@ -842,6 +847,9 @@ export function OnCallHandoverPage() {
maxLength={ON_CALL_HANDOVER_FIELD_LIMITS.bed}
onChange={(event) => update("bed")(event.target.value)}
autoComplete="off"
+ spellCheck={false}
+ data-gramm="false"
+ data-enable-grammarly="false"
data-testid="on-call-handover-bed"
className={inputClass}
/>
@@ -852,6 +860,9 @@ export function OnCallHandoverPage() {
maxLength={ON_CALL_HANDOVER_FIELD_LIMITS.ward}
onChange={(event) => update("ward")(event.target.value)}
autoComplete="off"
+ spellCheck={false}
+ data-gramm="false"
+ data-enable-grammarly="false"
data-testid="on-call-handover-ward"
className={inputClass}
/>
diff --git a/src/components/on-call/on-call-entry-editor.tsx b/src/components/on-call/on-call-entry-editor.tsx
index b0aede4945..35b1e77420 100644
--- a/src/components/on-call/on-call-entry-editor.tsx
+++ b/src/components/on-call/on-call-entry-editor.tsx
@@ -1,7 +1,7 @@
"use client";
import { Trash2 } from "lucide-react";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { ON_CALL_VIEW_TITLES, type OnCallPageView } from "@/components/on-call/on-call-section-identity";
import { OnCallFreshnessBadge } from "@/components/on-call/on-call-freshness-badge";
@@ -13,6 +13,7 @@ import { FormField } from "@/components/ui/form-field";
import { Select, type SelectOption } from "@/components/ui/select";
import { Sheet } from "@/components/ui/sheet";
import { TextField } from "@/components/ui/text-field";
+import { useDirtyStateGuard } from "@/components/ui/use-dirty-state-guard";
import { cn, fieldControlPlain, InlineNotice, textMuted } from "@/components/ui-primitives";
import { parseApiErrorResponse } from "@/lib/api-client-error";
import { isComplianceEntry, ON_CALL_ADMIN_CATEGORIES, ON_CALL_COMPLIANCE_CATEGORIES } from "@/lib/on-call/compliance";
@@ -783,6 +784,13 @@ export function OnCallEntryEditor({
}
}
+ const initialDraft = useMemo(
+ () => buildInitialDraft(section, entry, createAsRoleExplainer, createAsCompliance),
+ [section, entry, createAsRoleExplainer, createAsCompliance],
+ );
+ const isDirty = open && JSON.stringify(draft) !== JSON.stringify(initialDraft);
+ useDirtyStateGuard(isDirty && busy === null);
+
const fieldSpecs = detailFieldsFor(section, draft.isCompliance);
const freshness = entry ? onCallEntryFreshness(entry) : null;
diff --git a/src/components/open-shifts/open-shifts-ui.tsx b/src/components/open-shifts/open-shifts-ui.tsx
index aa8f1346cc..eb001fc12d 100644
--- a/src/components/open-shifts/open-shifts-ui.tsx
+++ b/src/components/open-shifts/open-shifts-ui.tsx
@@ -231,7 +231,7 @@ export function SectionHeading({ children, count, id }: { children: ReactNode; c
>
{children}
{count !== undefined ? (
- {count}
+ {` ${count}`}
) : null}
);
diff --git a/src/components/psychiatry/mha-clock-page.tsx b/src/components/psychiatry/mha-clock-page.tsx
index 0d03e5d4b4..61e67f0dc1 100644
--- a/src/components/psychiatry/mha-clock-page.tsx
+++ b/src/components/psychiatry/mha-clock-page.tsx
@@ -413,7 +413,7 @@ function LimitRow({ item, nowMs }: { readonly item: MhaTimerItem; readonly nowMs
diff --git a/src/components/roster/manage/roster-cover-tab.tsx b/src/components/roster/manage/roster-cover-tab.tsx
index 0c999e94f0..52b836a921 100644
--- a/src/components/roster/manage/roster-cover-tab.tsx
+++ b/src/components/roster/manage/roster-cover-tab.tsx
@@ -364,6 +364,7 @@ export function RosterCoverTab({ serviceId, overview }: { serviceId: string; ove
{exportDays >= 1 && exportDays <= 62 ? (
Download Excel
diff --git a/src/components/roster/rotations/admin/round-form.tsx b/src/components/roster/rotations/admin/round-form.tsx
index c425446f61..ff6175586b 100644
--- a/src/components/roster/rotations/admin/round-form.tsx
+++ b/src/components/roster/rotations/admin/round-form.tsx
@@ -21,6 +21,7 @@ import { formatTermDates } from "@/components/roster/rotations/rotation-format";
import { useRotations, type RotationsRead } from "@/components/roster/rotations/use-rotations";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { announce } from "@/components/ui/live-announcer";
+import { useDirtyStateGuard } from "@/components/ui/use-dirty-state-guard";
import { cn, controlDisabled } from "@/components/ui-primitives";
import type { ManagedRound } from "@/lib/roster/rotations/model";
import { zonedDateOf } from "@/lib/work-time/format";
@@ -211,6 +212,8 @@ export function RoundForm({
const [step, setStep] = useState("terms");
const [problem, setProblem] = useState(null);
const [busy, setBusy] = useState(false);
+ const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(initial), [draft, initial]);
+ useDirtyStateGuard(isDirty && !busy);
const now = useRosterNow();
const toast = useWorkUndoToast();
const makeId = useIdMaker(read.source);
diff --git a/src/components/roster/use-roster-shifts.ts b/src/components/roster/use-roster-shifts.ts
index 4bf7076173..dafeb6ff2f 100644
--- a/src/components/roster/use-roster-shifts.ts
+++ b/src/components/roster/use-roster-shifts.ts
@@ -16,6 +16,7 @@ import type {
} from "@/lib/roster/shifts/model";
import { zonedToday } from "@/lib/work-time/format";
import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone";
+import { hasSupabaseAuthSession } from "@/lib/supabase/session-hint";
/**
* The signed-in doctor's own shifts, fetched from `/api/roster/shifts`.
@@ -128,10 +129,11 @@ function unionShifts(listed: readonly OnCallShift[], older: readonly OnCallShift
*/
export function useRosterShifts(
teamRange?: { from: string; to: string },
- options?: { readonly ownHistory?: boolean },
+ options?: { readonly ownHistory?: boolean; readonly skipWhenUnauthenticated?: boolean },
): RosterShiftsState {
const { zone } = useWorkTimeZone();
- const teams = useRosterTeams();
+ const skipGuest = Boolean(options?.skipWhenUnauthenticated && !hasSupabaseAuthSession());
+ const teams = useRosterTeams({ skip: skipGuest });
const today = zonedToday(zone);
const from = teamRange?.from ?? addDaysToDate(today, -LISTED_PAST_DAYS);
const to = teamRange?.to ?? addDaysToDate(today, 40);
@@ -153,6 +155,7 @@ export function useRosterShifts(
}, []);
useEffect(() => {
if (!wantsHistory) return;
+ if (options?.skipWhenUnauthenticated && !hasSupabaseAuthSession()) return;
const controller = new AbortController();
const url = `${ROSTER_SHIFTS_URL}?${new URLSearchParams({ from, to: historyTo })}`;
// A re-read after a change goes to the network itself, never joining a read that began before the change.
@@ -171,7 +174,7 @@ export function useRosterShifts(
if (!controller.signal.aborted) setHistory({ from, to: historyTo, shifts: [], failed: true });
});
return () => controller.abort();
- }, [wantsHistory, from, historyTo, historyReload]);
+ }, [wantsHistory, from, historyTo, historyReload, options?.skipWhenUnauthenticated]);
const [teamData, setTeamData] = useState<{
payload: typeof teams.data;
owner: string;
@@ -217,7 +220,9 @@ export function useRosterShifts(
});
return () => controller.abort();
}, [actorId, teamPayload, from, to]);
- const [status, setStatus] = useState("loading");
+ const [status, setStatus] = useState(() =>
+ options?.skipWhenUnauthenticated && !hasSupabaseAuthSession() ? "signed-out" : "loading",
+ );
const [shifts, setShifts] = useState([]);
const [latestImport, setLatestImport] = useState(null);
const [demoMode, setDemoMode] = useState(false);
@@ -247,10 +252,13 @@ export function useRosterShifts(
const load = useCallback(async () => apply(await fetchShifts()), [apply]);
useEffect(() => {
+ if (options?.skipWhenUnauthenticated && !hasSupabaseAuthSession()) {
+ return;
+ }
const controller = new AbortController();
fetchShifts(controller.signal).then(apply, () => undefined);
return () => controller.abort();
- }, [apply]);
+ }, [apply, options?.skipWhenUnauthenticated]);
const save = useCallback(
async (request: OnCallShiftImportRequest) => {
diff --git a/src/components/roster/use-roster-team.ts b/src/components/roster/use-roster-team.ts
index ffa358440d..56cacd1c85 100644
--- a/src/components/roster/use-roster-team.ts
+++ b/src/components/roster/use-roster-team.ts
@@ -107,8 +107,9 @@ function useLoaded(url: string | null, what: RosterReadWhat | "teams"): Roste
export type RosterTeamsPayload = { teams: RosterTeam[]; actorId?: string; sample?: boolean };
/** The teams I belong to. */
-export function useRosterTeams(): RosterReadState {
- const loaded = useLoaded("/api/roster/team", "teams");
+export function useRosterTeams(options?: { readonly skip?: boolean }): RosterReadState {
+ const skip = Boolean(options?.skip);
+ const loaded = useLoaded(skip ? null : "/api/roster/team", "teams");
// The invented team served while the real-staff release is held shows only
// while Roster shows example data. With the switch off, nothing made up shows,
// labelled or not: the reader has no teams yet.
@@ -119,7 +120,11 @@ export function useRosterTeams(): RosterReadState {
() => (hideSample && loaded.data ? { ...loaded.data, teams: [] } : null),
[hideSample, loaded.data],
);
- const state = hiddenData ? { ...loaded, data: hiddenData } : loaded;
+ const state: RosterReadState = skip
+ ? { status: "signed-out", data: null, message: SIGNED_OUT_MESSAGE, readAt: null, reload: loaded.reload }
+ : hiddenData
+ ? { ...loaded, data: hiddenData }
+ : loaded;
useEffect(() => {
if (state.status !== "ready") return;
const enabled = Array.isArray(state.data?.teams) ? state.data.teams.some((team) => team.enabled) : false;
diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx
index c3f2170866..bc8305c4bf 100644
--- a/src/components/specifiers/specifiers-home-page.tsx
+++ b/src/components/specifiers/specifiers-home-page.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
-import { useId, useMemo, useState } from "react";
+import { useCallback, useDeferredValue, useId, useMemo, useState } from "react";
import { ArrowRight, Search } from "lucide-react";
import { pairCompareHref } from "@/components/compare";
@@ -138,11 +138,16 @@ function SpecifierCatalogueMatches({ matches }: { matches: SpecifierCatalogMatch
function SpecifierResults({ query }: { query: string }) {
const searchParams = useSearchParams();
+ const serializedParams = searchParams.toString();
+ const deferredQuery = useDeferredValue(query);
const filterPanelId = useId();
const [filterOpen, setFilterOpen] = useState(false);
- const allGuideMatches = useMemo(() => searchSpecifiers(query, { family: "all", diagnosis: "" }), [query]);
- const allCatalogueMatches = useMemo(() => searchSpecifierCatalog(query, {}, true), [query]);
- const smartInterpretation = useMemo(() => interpretSmartSearch("specifiers", query), [query]);
+ const allGuideMatches = useMemo(
+ () => searchSpecifiers(deferredQuery, { family: "all", diagnosis: "" }),
+ [deferredQuery],
+ );
+ const allCatalogueMatches = useMemo(() => searchSpecifierCatalog(deferredQuery, {}, true), [deferredQuery]);
+ const smartInterpretation = useMemo(() => interpretSmartSearch("specifiers", deferredQuery), [deferredQuery]);
const defaultScope: SpecifierResultScope =
smartInterpretation.naturalLanguage && allCatalogueMatches.length > 0
? "catalogue"
@@ -154,184 +159,263 @@ function SpecifierResults({ query }: { query: string }) {
const diagnosisValues = useMemo(() => new Set(diagnosisOptions.map((option) => option.value)), []);
const family = readResultFilterValue(searchParams, "family", familyValues, "all") as "all" | SpecifierFamily;
const diagnosis = readResultFilterValue(searchParams, "diagnosis", diagnosisValues, "");
+ const fullCatalogueMatches = useMemo(() => searchSpecifierCatalog("", {}, true), []);
const categoryOptions = useMemo(
() =>
- [...new Map(allCatalogueMatches.map(({ item }) => [item.categoryId, item.category])).entries()]
+ [...new Map(fullCatalogueMatches.map(({ item }) => [item.categoryId, item.category])).entries()]
.map(([value, label]) => ({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label)),
- [allCatalogueMatches],
+ [fullCatalogueMatches],
);
const categoryValues = useMemo(() => new Set(categoryOptions.map((option) => option.value)), [categoryOptions]);
- const selectedCategories = new Set(readResultFilterValues(searchParams, "category", categoryValues));
+ const selectedCategories = useMemo(
+ () => new Set(readResultFilterValues(new URLSearchParams(serializedParams), "category", categoryValues)),
+ [serializedParams, categoryValues],
+ );
const reviewedOnly = searchParams.get("reviewed") === "1";
// Nothing typed is browsing, not a search: the list is shown A to Z with no
// "Top match", so it never reads as the app having picked something. Sorting
// here (display only) leaves the search library's ranking untouched.
- const browsing = query.length === 0;
+ const browsing = deferredQuery.length === 0;
const guideMatches = useMemo(() => {
- const matches = searchSpecifiers(query, { family, diagnosis });
+ const matches = searchSpecifiers(deferredQuery, { family, diagnosis });
return browsing
? [...matches].sort((left, right) =>
left.record.name.localeCompare(right.record.name, "en", { sensitivity: "base" }),
)
: matches;
- }, [browsing, diagnosis, family, query]);
- const filteredCatalogueMatches = allCatalogueMatches.filter(
- ({ item }) =>
- (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)) &&
- (!reviewedOnly || item.src === "source-verified"),
+ }, [browsing, diagnosis, family, deferredQuery]);
+ const filteredEntries = useMemo(
+ () =>
+ allCatalogueMatches.filter(
+ ({ item }) =>
+ (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)) &&
+ (!reviewedOnly || item.src === "source-verified"),
+ ),
+ [allCatalogueMatches, selectedCategories, reviewedOnly],
);
- const catalogueMatches = browsing
- ? [...filteredCatalogueMatches].sort((left, right) =>
- left.item.label.localeCompare(right.item.label, "en", { sensitivity: "base" }),
- )
- : filteredCatalogueMatches;
+ const filteredCatalogueMatches = filteredEntries;
+ const sortedEntries = useMemo(
+ () =>
+ browsing
+ ? [...filteredCatalogueMatches].sort((left, right) =>
+ left.item.label.localeCompare(right.item.label, "en", { sensitivity: "base" }),
+ )
+ : filteredCatalogueMatches,
+ [browsing, filteredCatalogueMatches],
+ );
+ const catalogueMatches = sortedEntries;
const matchingTotal = scope === "guides" ? guideMatches.length : catalogueMatches.length;
- const resultSignature = `${query}|${scope}|${[...selectedCategories].sort().join(",")}|${reviewedOnly}`;
+ const resultSignature = `${deferredQuery}|${scope}|${[...selectedCategories].sort().join(",")}|${reviewedOnly}`;
const [visibleState, setVisibleState] = useState({ signature: resultSignature, count: CATALOGUE_RESULT_LIMIT });
if (visibleState.signature !== resultSignature) {
setVisibleState({ signature: resultSignature, count: CATALOGUE_RESULT_LIMIT });
}
- const visibleCatalogueMatches = catalogueMatches.slice(0, visibleState.count);
+ const visibleCatalogueMatches = useMemo(
+ () => catalogueMatches.slice(0, visibleState.count),
+ [catalogueMatches, visibleState.count],
+ );
const familyOptions = useMemo(
() => specifierFamilies.map((option) => ({ value: option.id, label: option.label })),
[],
);
- const updateLens = (
- key: "scope" | "family" | "diagnosis",
- value: Value,
- fallback: Value,
- allowed: ReadonlySet,
- ) => replaceResultFilterUrl((params) => writeResultFilterValue(params, key, value, fallback, allowed));
- const toggleCategory = (value: string) =>
- replaceResultFilterUrl((params) => {
- const next = new Set(readResultFilterValues(params, "category", categoryValues));
- if (!next.delete(value)) next.add(value);
- writeResultFilterValues(params, "category", next, categoryValues);
- });
- const setReviewedOnly = (enabled: boolean) =>
- replaceResultFilterUrl((params) => {
- if (enabled) params.set("reviewed", "1");
- else params.delete("reviewed");
- });
- const clearFilters = () =>
- replaceResultFilterUrl((params) => {
- params.delete("scope");
- params.delete("family");
- params.delete("diagnosis");
- params.delete("category");
- params.delete("reviewed");
- });
+ const updateLens = useCallback(
+ (
+ key: "scope" | "family" | "diagnosis",
+ value: Value,
+ fallback: Value,
+ allowed: ReadonlySet,
+ ) => replaceResultFilterUrl((params) => writeResultFilterValue(params, key, value, fallback, allowed)),
+ [],
+ );
+ const toggleCategory = useCallback(
+ (value: string) =>
+ replaceResultFilterUrl((params) => {
+ const next = new Set(readResultFilterValues(params, "category", categoryValues));
+ if (!next.delete(value)) next.add(value);
+ writeResultFilterValues(params, "category", next, categoryValues);
+ }),
+ [categoryValues],
+ );
+ const setReviewedOnly = useCallback(
+ (enabled: boolean) =>
+ replaceResultFilterUrl((params) => {
+ if (enabled) params.set("reviewed", "1");
+ else params.delete("reviewed");
+ }),
+ [],
+ );
+ const clearFilters = useCallback(
+ () =>
+ replaceResultFilterUrl((params) => {
+ params.delete("scope");
+ params.delete("family");
+ params.delete("diagnosis");
+ params.delete("category");
+ params.delete("reviewed");
+ }),
+ [],
+ );
const activeFilterCount =
Number(scope !== defaultScope) +
(scope === "guides"
? Number(family !== "all") + Number(Boolean(diagnosis))
: selectedCategories.size + Number(reviewedOnly));
- const appliedFilters: AppliedFilterChip[] = [];
- if (scope !== defaultScope) {
- appliedFilters.push({
- id: "scope",
- groupLabel: "Search in",
- valueLabel: scope === "guides" ? "Clinical guides" : "Full catalogue",
- onRemove: () => updateLens("scope", defaultScope, defaultScope, specifierScopeValues),
- });
- }
- if (scope === "guides") {
- if (family !== "all") {
- appliedFilters.push({
- id: "family",
- groupLabel: "Family",
- valueLabel: familyOptions.find((option) => option.value === family)?.label ?? family,
- onRemove: () => updateLens("family", "all", "all", familyValues),
- });
- }
- if (diagnosis) {
- appliedFilters.push({
- id: "diagnosis",
- groupLabel: "Diagnosis",
- valueLabel: diagnosisOptions.find((option) => option.value === diagnosis)?.label ?? diagnosis,
- onRemove: () => updateLens("diagnosis", "", "", diagnosisValues),
+ const appliedFilters = useMemo(() => {
+ const chips: AppliedFilterChip[] = [];
+ if (scope !== defaultScope) {
+ chips.push({
+ id: "scope",
+ groupLabel: "Search in",
+ valueLabel: scope === "guides" ? "Clinical guides" : "Full catalogue",
+ onRemove: () => updateLens("scope", defaultScope, defaultScope, specifierScopeValues),
});
}
- } else {
- for (const category of selectedCategories) {
- appliedFilters.push({
- id: `category-${category}`,
- groupLabel: "Category",
- valueLabel: categoryOptions.find((option) => option.value === category)?.label ?? category,
- onRemove: () => toggleCategory(category),
- });
+ if (scope === "guides") {
+ if (family !== "all") {
+ chips.push({
+ id: "family",
+ groupLabel: "Family",
+ valueLabel: familyOptions.find((option) => option.value === family)?.label ?? family,
+ onRemove: () => updateLens("family", "all", "all", familyValues),
+ });
+ }
+ if (diagnosis) {
+ chips.push({
+ id: "diagnosis",
+ groupLabel: "Diagnosis",
+ valueLabel: diagnosisOptions.find((option) => option.value === diagnosis)?.label ?? diagnosis,
+ onRemove: () => updateLens("diagnosis", "", "", diagnosisValues),
+ });
+ }
+ } else {
+ for (const category of selectedCategories) {
+ chips.push({
+ id: `category-${category}`,
+ groupLabel: "Category",
+ valueLabel: categoryOptions.find((option) => option.value === category)?.label ?? category,
+ onRemove: () => toggleCategory(category),
+ });
+ }
+ if (reviewedOnly) {
+ chips.push({
+ id: "reviewed",
+ groupLabel: "Source",
+ valueLabel: "Source verified",
+ onRemove: () => setReviewedOnly(false),
+ });
+ }
}
- if (reviewedOnly) {
- appliedFilters.push({
- id: "reviewed",
- groupLabel: "Source",
- valueLabel: "Source verified",
- onRemove: () => setReviewedOnly(false),
- });
+ return chips;
+ }, [
+ scope,
+ defaultScope,
+ family,
+ familyOptions,
+ familyValues,
+ diagnosis,
+ diagnosisValues,
+ selectedCategories,
+ categoryOptions,
+ reviewedOnly,
+ updateLens,
+ toggleCategory,
+ setReviewedOnly,
+ ]);
+
+ const categoryCounts = useMemo(() => {
+ const counts = new Map();
+ for (const option of categoryOptions) {
+ const next = new Set(selectedCategories);
+ if (!next.has(option.value)) next.add(option.value);
+ const count = allCatalogueMatches.filter(
+ ({ item }) => next.has(item.categoryId) && (!reviewedOnly || item.src === "source-verified"),
+ ).length;
+ counts.set(option.value, count);
}
- }
+ return counts;
+ }, [categoryOptions, selectedCategories, allCatalogueMatches, reviewedOnly]);
- const groups =
- scope === "guides"
- ? [
- resultFilterGroup({
- id: "family",
- label: "Family",
- value: family,
- options: familyOptions.map((option) => ({
- ...option,
- hint: String(searchSpecifiers(query, { family: option.value, diagnosis }).length),
- })),
- onChange: (value) => updateLens("family", value, "all", familyValues),
- }),
- resultFilterGroup({
- id: "diagnosis",
- label: "Diagnosis",
- value: diagnosis,
- options: diagnosisOptions.map((option) => ({
- ...option,
- hint: String(searchSpecifiers(query, { family, diagnosis: option.value }).length),
- })),
- onChange: (value) => updateLens("diagnosis", value, "", diagnosisValues),
- }),
- ]
- : [
- resultFilterFacetGroup({
- id: "category",
- label: "Category",
- selected: selectedCategories,
- options: categoryOptions.map((option) => {
- const next = new Set(selectedCategories);
- if (!next.has(option.value)) next.add(option.value);
- const count = allCatalogueMatches.filter(
- ({ item }) => next.has(item.categoryId) && (!reviewedOnly || item.src === "source-verified"),
- ).length;
- return { ...option, hint: String(count), disabled: count === 0 && !selectedCategories.has(option.value) };
+ const grouped = useMemo(
+ () =>
+ scope === "guides"
+ ? [
+ resultFilterGroup({
+ id: "family",
+ label: "Family",
+ value: family,
+ options: familyOptions.map((option) => ({
+ ...option,
+ hint: String(searchSpecifiers(deferredQuery, { family: option.value, diagnosis }).length),
+ })),
+ onChange: (value) => updateLens("family", value, "all", familyValues),
+ }),
+ resultFilterGroup({
+ id: "diagnosis",
+ label: "Diagnosis",
+ value: diagnosis,
+ options: diagnosisOptions.map((option) => ({
+ ...option,
+ hint: String(searchSpecifiers(deferredQuery, { family, diagnosis: option.value }).length),
+ })),
+ onChange: (value) => updateLens("diagnosis", value, "", diagnosisValues),
+ }),
+ ]
+ : [
+ resultFilterFacetGroup({
+ id: "category",
+ label: "Category",
+ selected: selectedCategories,
+ options: categoryOptions.map((option) => {
+ const count = categoryCounts.get(option.value) ?? 0;
+ return {
+ ...option,
+ hint: String(count),
+ disabled: count === 0 && !selectedCategories.has(option.value),
+ };
+ }),
+ onToggle: toggleCategory,
}),
- onToggle: toggleCategory,
- }),
- resultFilterFacetGroup({
- id: "reviewed",
- label: "Source verification",
- selected: new Set(reviewedOnly ? ["reviewed"] : []),
- options: [
- {
- value: "reviewed",
- label: "Source verified",
- hint: String(
- allCatalogueMatches.filter(
- ({ item }) =>
- item.src === "source-verified" &&
- (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)),
- ).length,
- ),
- },
- ],
- onToggle: () => setReviewedOnly(!reviewedOnly),
- }),
- ];
+ resultFilterFacetGroup({
+ id: "reviewed",
+ label: "Source verification",
+ selected: new Set(reviewedOnly ? ["reviewed"] : []),
+ options: [
+ {
+ value: "reviewed",
+ label: "Source verified",
+ hint: String(
+ allCatalogueMatches.filter(
+ ({ item }) =>
+ item.src === "source-verified" &&
+ (selectedCategories.size === 0 || selectedCategories.has(item.categoryId)),
+ ).length,
+ ),
+ },
+ ],
+ onToggle: () => setReviewedOnly(!reviewedOnly),
+ }),
+ ],
+ [
+ scope,
+ family,
+ familyOptions,
+ familyValues,
+ diagnosis,
+ diagnosisValues,
+ deferredQuery,
+ selectedCategories,
+ categoryOptions,
+ categoryCounts,
+ reviewedOnly,
+ allCatalogueMatches,
+ updateLens,
+ toggleCategory,
+ setReviewedOnly,
+ ],
+ );
+ const groups = grouped;
return (
diff --git a/src/components/teaching/assessments/assess-kit.tsx b/src/components/teaching/assessments/assess-kit.tsx
index 0dd30e8e6d..604d9b6a50 100644
--- a/src/components/teaching/assessments/assess-kit.tsx
+++ b/src/components/teaching/assessments/assess-kit.tsx
@@ -21,6 +21,7 @@ import { useModeBandHeading, useModeBandShown } from "@/components/mode-band/mod
import { WorkGlassButton } from "@/components/mode-kit/work";
import { universalHeaderLeadingSlotId } from "@/components/work-frame/work-frame-header";
import { useClaimWorkFrameBack } from "@/components/work-frame/work-frame-store";
+import { ignoreUnavailableActivation } from "@/components/ui-primitives";
import { RATING_LABELS, type Rating } from "@/lib/teaching/assessments/content";
import { looksLikePatientDetails } from "@/lib/teaching/assessments/model";
import { withUnit } from "@/components/teaching/teaching-number";
@@ -845,6 +846,7 @@ export function AssessButton({
icon: Icon,
disabled,
describedBy,
+ title,
onClick,
}: {
readonly children: ReactNode;
@@ -853,6 +855,7 @@ export function AssessButton({
readonly icon?: LucideIcon;
readonly disabled?: boolean;
readonly describedBy?: string;
+ readonly title?: string;
readonly onClick: () => void;
}) {
return (
@@ -861,9 +864,10 @@ export function AssessButton({
className="work-button"
data-variant={variant}
data-size={block ? "wide" : undefined}
- disabled={disabled}
+ aria-disabled={disabled ? "true" : undefined}
aria-describedby={describedBy}
- onClick={onClick}
+ title={title}
+ onClick={disabled ? ignoreUnavailableActivation : onClick}
>
{Icon ? : null}
{children}
diff --git a/src/components/teaching/assessments/assess.css b/src/components/teaching/assessments/assess.css
index 3b3792e0fb..b93231785d 100644
--- a/src/components/teaching/assessments/assess.css
+++ b/src/components/teaching/assessments/assess.css
@@ -943,6 +943,11 @@ select.assess-txt {
min-height: var(--spacing-tap, 3rem);
resize: none;
}
+@media (max-width: 639px) {
+ .assess-txt {
+ font-size: 1rem;
+ }
+}
.assess-txt::placeholder {
color: var(--work-ink-muted);
}
@@ -1126,9 +1131,27 @@ select.assess-txt {
.assess-slot > *::before,
.assess-diff > div,
.assess-call,
- .assess-undo {
+ .assess-undo,
+ .assess-cb,
+ .assess-rd,
+ .assess-sw {
border: 1px solid CanvasText;
}
+ .assess-rate > label:has(:checked),
+ .assess-opt:has(:checked),
+ .assess-cb[data-on],
+ .assess-opt:has(:checked) .assess-rd,
+ .assess-tg:has(:checked) .assess-sw {
+ border-color: Highlight;
+ outline: 2px solid Highlight;
+ }
+ .assess-sw::after {
+ border: 1px solid Canvas;
+ background: CanvasText;
+ }
+ .assess-tg:has(:checked) .assess-sw::after {
+ background: Highlight;
+ }
.assess-stepbar > i[data-state="done"],
.assess-meter > i {
background: CanvasText;
diff --git a/src/components/teaching/assessments/assessments-term-overview.tsx b/src/components/teaching/assessments/assessments-term-overview.tsx
index b800da9e6b..0a109845e9 100644
--- a/src/components/teaching/assessments/assessments-term-overview.tsx
+++ b/src/components/teaching/assessments/assessments-term-overview.tsx
@@ -43,7 +43,7 @@ import { ChoiceChip } from "@/components/ui/chip";
import { announce } from "@/components/ui/live-announcer";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Sheet } from "@/components/ui/sheet";
-import { cn } from "@/components/ui-primitives";
+import { cn, ignoreUnavailableActivation } from "@/components/ui-primitives";
import { guardExampleAction } from "@/lib/example-data/guards";
import { useExampleData } from "@/lib/example-data/store";
import { samSignOff } from "@/lib/teaching/assessments/dct";
@@ -184,29 +184,39 @@ function Totals({ done, due, overdue }: { done: number; due: number; overdue: nu
type BellState = "pending" | "reminded" | "offline" | "none";
function BellButton({ state, label, onClick }: { state: BellState; label: string; onClick: () => void }) {
+ const reasonId = useId();
if (state === "none") return ;
const live = state === "pending";
return (
-
- {state === "reminded" ? (
-
- ) : (
-
- )}
-
+ <>
+
+ {state === "reminded" ? (
+
+ ) : (
+
+ )}
+
+ {!live ? (
+
+ {label}
+
+ ) : null}
+ >
);
}
@@ -506,12 +516,11 @@ function OverviewHome({ s, role, params, dct }: ScreenProps) {
{
- if (!remindWhyNot) setSheet("bulk");
- }}
+ onClick={remindWhyNot ? ignoreUnavailableActivation : () => setSheet("bulk")}
testId="assessments-overview-bulk-open"
>
Remind
@@ -766,7 +775,15 @@ function ExportSheet({
{blocker ? (
<>
-
+
Download spreadsheet
{blocker}
diff --git a/src/components/teaching/assessments/teaching-assessments.tsx b/src/components/teaching/assessments/teaching-assessments.tsx
index 69605ba42f..f3561eeb03 100644
--- a/src/components/teaching/assessments/teaching-assessments.tsx
+++ b/src/components/teaching/assessments/teaching-assessments.tsx
@@ -16,38 +16,25 @@ import {
rememberedStory,
type AssessRole,
} from "@/components/teaching/assessments/assess-memory";
-import { AssessorForm } from "@/components/teaching/assessments/assessments-assessor";
-import { DctHome, DctPlan, DctSignoff, type DctProps } from "@/components/teaching/assessments/assessments-dct";
-import { AssessmentsHome } from "@/components/teaching/assessments/assessments-home";
-import {
- AllAssessments,
- BeginningOfTerm,
- TermDetails,
- YearRequirements,
-} from "@/components/teaching/assessments/assessments-year";
-import { AssessmentForm } from "@/components/teaching/assessments/assessments-form";
-import { AskSupervisor, BookMeeting, EndOfTermSteps } from "@/components/teaching/assessments/assessments-steps";
-import { AssessmentReport, SignForm } from "@/components/teaching/assessments/assessments-report";
-import { ConcernsHelp, AssessmentsSheets, type SheetState } from "@/components/teaching/assessments/assessments-help";
-import {
- DoctorRecord,
- SideBySide,
- SupervisorHistory,
- SupervisorHome,
- SupervisorProgress,
- SupervisorTimes,
- SupervisorWords,
-} from "@/components/teaching/assessments/assessments-supervisor";
+import { Award, BookOpen, TriangleAlert } from "lucide-react";
+import type { DctProps } from "@/components/teaching/assessments/assessments-dct";
+import { AssessHeader, AssessNote } from "@/components/teaching/assessments/assess-kit";
+import { AssessmentsSheets, ConcernsHelp, type SheetState } from "@/components/teaching/assessments/assessments-help";
import { AssessmentsExtrasProvider } from "@/components/teaching/assessments/assessments-extras";
import { AssessmentsKeptInCla, useAssessmentsAccess } from "@/components/teaching/assessments/assessments-kept-in-cla";
import { ExampleOnlyGate } from "@/components/example-data/example-only-gate";
-import { AssessmentsInbox } from "@/components/teaching/assessments/assessments-inbox";
-import { AssessmentsTermOverview } from "@/components/teaching/assessments/assessments-term-overview";
-import { viewHref, type AssessmentsView } from "@/components/teaching/assessments/assessments-parts";
+import {
+ List,
+ Row,
+ SectionLabel,
+ viewHref,
+ type AssessmentsView,
+} from "@/components/teaching/assessments/assessments-parts";
import { TeachingAccountPage } from "@/components/teaching/teaching-depth-page";
import { perthDateKey } from "@/components/teaching/teaching-dates";
import { useTeachingNow } from "@/components/teaching/use-teaching-now";
import { useWorkFrameAction } from "@/components/work-frame/work-frame-store";
+import { GLOSSARY } from "@/lib/teaching/assessments/content";
import {
assessmentsReducer,
dayLabel,
@@ -62,6 +49,98 @@ import { dctReducer, dctWaiting, initialDctState, type DctState } from "@/lib/te
import { useAuthSession } from "@/lib/supabase/client";
import { useExampleData } from "@/lib/example-data/store";
+const sampleLoading = () => ;
+
+const AssessorForm = dynamic(
+ () => import("@/components/teaching/assessments/assessments-assessor").then((m) => m.AssessorForm),
+ { loading: sampleLoading },
+);
+const DctHome = dynamic(() => import("@/components/teaching/assessments/assessments-dct").then((m) => m.DctHome), {
+ loading: sampleLoading,
+});
+const DctPlan = dynamic(() => import("@/components/teaching/assessments/assessments-dct").then((m) => m.DctPlan), {
+ loading: sampleLoading,
+});
+const DctSignoff = dynamic(
+ () => import("@/components/teaching/assessments/assessments-dct").then((m) => m.DctSignoff),
+ { loading: sampleLoading },
+);
+const AssessmentsHome = dynamic(
+ () => import("@/components/teaching/assessments/assessments-home").then((m) => m.AssessmentsHome),
+ { loading: sampleLoading },
+);
+const AllAssessments = dynamic(
+ () => import("@/components/teaching/assessments/assessments-year").then((m) => m.AllAssessments),
+ { loading: sampleLoading },
+);
+const BeginningOfTerm = dynamic(
+ () => import("@/components/teaching/assessments/assessments-year").then((m) => m.BeginningOfTerm),
+ { loading: sampleLoading },
+);
+const TermDetails = dynamic(
+ () => import("@/components/teaching/assessments/assessments-year").then((m) => m.TermDetails),
+ { loading: sampleLoading },
+);
+const YearRequirements = dynamic(
+ () => import("@/components/teaching/assessments/assessments-year").then((m) => m.YearRequirements),
+ { loading: sampleLoading },
+);
+const AssessmentForm = dynamic(
+ () => import("@/components/teaching/assessments/assessments-form").then((m) => m.AssessmentForm),
+ { loading: sampleLoading },
+);
+const AskSupervisor = dynamic(
+ () => import("@/components/teaching/assessments/assessments-steps").then((m) => m.AskSupervisor),
+ { loading: sampleLoading },
+);
+const BookMeeting = dynamic(
+ () => import("@/components/teaching/assessments/assessments-steps").then((m) => m.BookMeeting),
+ { loading: sampleLoading },
+);
+const EndOfTermSteps = dynamic(
+ () => import("@/components/teaching/assessments/assessments-steps").then((m) => m.EndOfTermSteps),
+ { loading: sampleLoading },
+);
+const AssessmentReport = dynamic(
+ () => import("@/components/teaching/assessments/assessments-report").then((m) => m.AssessmentReport),
+ { loading: sampleLoading },
+);
+const SignForm = dynamic(() => import("@/components/teaching/assessments/assessments-report").then((m) => m.SignForm), {
+ loading: sampleLoading,
+});
+const DoctorRecord = dynamic(
+ () => import("@/components/teaching/assessments/assessments-supervisor").then((m) => m.DoctorRecord),
+ { loading: sampleLoading },
+);
+const SideBySide = dynamic(
+ () => import("@/components/teaching/assessments/assessments-supervisor").then((m) => m.SideBySide),
+ { loading: sampleLoading },
+);
+const SupervisorHistory = dynamic(
+ () => import("@/components/teaching/assessments/assessments-supervisor").then((m) => m.SupervisorHistory),
+ { loading: sampleLoading },
+);
+const SupervisorHome = dynamic(
+ () => import("@/components/teaching/assessments/assessments-supervisor").then((m) => m.SupervisorHome),
+ { loading: sampleLoading },
+);
+const SupervisorProgress = dynamic(
+ () => import("@/components/teaching/assessments/assessments-supervisor").then((m) => m.SupervisorProgress),
+ { loading: sampleLoading },
+);
+const SupervisorTimes = dynamic(
+ () => import("@/components/teaching/assessments/assessments-supervisor").then((m) => m.SupervisorTimes),
+ { loading: sampleLoading },
+);
+const AssessmentsInbox = dynamic(
+ () => import("@/components/teaching/assessments/assessments-inbox").then((m) => m.AssessmentsInbox),
+ { loading: sampleLoading },
+);
+const AssessmentsTermOverview = dynamic(
+ () => import("@/components/teaching/assessments/assessments-term-overview").then((m) => m.AssessmentsTermOverview),
+ { loading: sampleLoading },
+);
+
/* Teaching's term card, with the doctor's own EPA tally, loads only on a signed-in doctor's Progress tab. */
const TeachingTermCard = dynamic(
() => import("@/components/teaching/teaching-term-card").then((m) => m.TeachingTermCard),
@@ -171,6 +250,53 @@ export function resolveRole(view: AssessmentsView, as: string | null, remembered
return role === "dct" && !DCT_VIEWS.has(view) ? "doctor" : role;
}
+const WORDS_FOR: Record = {
+ doctor: { eyebrow: "For doctors in training", back: viewHref("home"), label: "Assessments" },
+ supervisor: { eyebrow: "For supervisors", back: viewHref("home", { as: "supervisor" }), label: "To do" },
+ dct: { eyebrow: "For the DCT", back: viewHref("home", { as: "dct" }), label: "Assessments" },
+};
+
+function SupervisorWords({ role }: ScreenProps) {
+ const words = WORDS_FOR[role];
+ return (
+ <>
+
+ {role === "doctor" ? null : (
+ <>
+ How to rate
+
+
+
+
+
+ >
+ )}
+ Words used here
+
+ {GLOSSARY.map(([term, meaning]) => (
+ 8 ? "" : undefined}>
+
{term}
+ {meaning}
+
+ ))}
+
+
+ Rules from the AMC National Framework 2024
+
+ >
+ );
+}
+
function Screen(props: ScreenProps & Omit & { view: AssessmentsView }) {
const { view, role } = props;
if (view === "help") return ;
@@ -343,15 +469,25 @@ function AssessmentsApp() {
}
setSheet(null);
setSavedNote(null);
- const heading =
- root.current?.querySelector("[data-screen-heading]") ??
- root.current?.querySelector("h2");
- if (heading && !heading.hasAttribute("tabindex")) {
- heading.setAttribute("tabindex", "-1");
- // A title focused for screen readers is not a control, so it gets no focus ring.
- heading.classList.add("outline-none");
- }
- heading?.focus({ preventScroll: true });
+ const focusHeading = () => {
+ const screenHeading = root.current?.querySelector("[data-screen-heading]");
+ const skeletonActive = Boolean(root.current?.querySelector('[data-testid="assess-skeleton"]'));
+ const heading = screenHeading ?? (skeletonActive ? null : root.current?.querySelector("h2"));
+ if (!heading) return false;
+ if (!heading.hasAttribute("tabindex")) {
+ heading.setAttribute("tabindex", "-1");
+ // A title focused for screen readers is not a control, so it gets no focus ring.
+ heading.classList.add("outline-none");
+ }
+ heading.focus({ preventScroll: true });
+ return true;
+ };
+ if (focusHeading() || !root.current || typeof MutationObserver === "undefined") return;
+ const observer = new MutationObserver(() => {
+ if (focusHeading()) observer.disconnect();
+ });
+ observer.observe(root.current, { childList: true, subtree: true });
+ return () => observer.disconnect();
}, [place]);
const switchRole = (next: Role) => {
if (next === role) return;
diff --git a/src/components/therapy-compass/screens/brief-screen.tsx b/src/components/therapy-compass/screens/brief-screen.tsx
index 379f3e36ea..16f2001c8b 100644
--- a/src/components/therapy-compass/screens/brief-screen.tsx
+++ b/src/components/therapy-compass/screens/brief-screen.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
-import { Check, Copy, ExternalLink, FileText, Search, TriangleAlert } from "lucide-react";
+import { AlertTriangle, Check, CheckCircle2, Copy, ExternalLink, FileText, Search, TriangleAlert } from "lucide-react";
import { cardSurface } from "@/components/card-recipes";
import { PageHeader } from "@/components/ui/page-header";
@@ -11,6 +11,7 @@ import { missingValuePhrase } from "@/components/ui/missing-value";
import { Tabs } from "@/components/ui/tabs";
import { BrowserPrintButton, PrintOutput } from "@/components/ui/print-output";
import { cn } from "@/components/ui-primitives";
+import { therapyNeedsReview } from "@/lib/therapies";
import { therapyRecordHref, type TherapyBriefDuration } from "@/lib/therapy-compass-navigation";
import { useTcBindings } from "../bindings";
@@ -45,6 +46,27 @@ const BRIEF_DURATION: Record & Partial),
+ durationLabel = "5-minute",
+ steps: string[] = parseSteps(brief.briefVersion ?? null, 6),
+): string {
+ const scripts = brief.clinicianScripts ?? [];
+ return [
+ `${brief.name} — ${durationLabel} intervention`,
+ ...(therapyNeedsReview(brief) ? ["[Awaiting clinical review — verify against primary guidelines]"] : []),
+ "",
+ ...steps.map((st, i) => `${i + 1}. ${st}`),
+ ...(scripts.length
+ ? [
+ "",
+ "Clinician script:",
+ ...scripts.slice(0, 2).map((c) => (c.scriptType ? `${c.scriptType}: ${c.body ?? ""}` : (c.body ?? ""))),
+ ]
+ : []),
+ ].join("\n");
+}
+
export function BriefScreen() {
const b = useTcBindings();
const t = b.selectedTherapy;
@@ -63,6 +85,7 @@ export function BriefScreen() {
const { notice, saved, toggleFavourite } = useTherapyFavourite(t?.slug ?? null);
if (b.loading || !t) return ;
+ const needsReview = therapyNeedsReview(t);
const duration = BRIEF_DURATION[b.briefTab as TherapyBriefDuration] ?? BRIEF_DURATION["5min"];
const durationLabel = duration.label;
const durationText = duration.text(t);
@@ -73,20 +96,7 @@ export function BriefScreen() {
const duplicatesFullProtocol = Boolean(
durationText && t.deliverySteps && durationText.trim() === t.deliverySteps.trim(),
);
- const interventionText = [
- `${t.name} — ${durationLabel} intervention`,
- "",
- ...steps.map((st, i) => `${i + 1}. ${st}`),
- ...(t.clinicianScripts.length
- ? [
- "",
- "Clinician script:",
- ...t.clinicianScripts
- .slice(0, 2)
- .map((c) => (c.scriptType ? `${c.scriptType}: ${c.body ?? ""}` : (c.body ?? ""))),
- ]
- : []),
- ].join("\n");
+ const interventionText = buildBriefCopy(t, durationLabel, steps);
return (
<>
@@ -176,15 +186,19 @@ export function BriefScreen() {
{x.bestUsedFor ?? x.category}
-
+ {x.reviewStatus === "reviewed" ? (
+
+ ) : (
+
+ )}
);
})}
@@ -206,8 +220,21 @@ export function BriefScreen() {
{durationLabel} mode
-
- {t.reviewStatus === "reviewed" ? "Reviewed" : "Clinician review required"}
+
+ {needsReview ? (
+
+ ) : (
+
+ )}
+ {needsReview ? "Awaiting clinical review" : "Reviewed"}
{/*
diff --git a/src/components/therapy-compass/screens/sheets-screen.tsx b/src/components/therapy-compass/screens/sheets-screen.tsx
index dded4ae877..795b3b8bfb 100644
--- a/src/components/therapy-compass/screens/sheets-screen.tsx
+++ b/src/components/therapy-compass/screens/sheets-screen.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState, type ReactNode } from "react";
-import { ChevronDown, Scale, Search } from "lucide-react";
+import { AlertTriangle, ChevronDown, Scale, Search } from "lucide-react";
import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell";
import { SegmentedControl } from "@/components/ui/segmented-control";
@@ -10,6 +10,7 @@ import { BrowserPrintButton, PrintOutput } from "@/components/ui/print-output";
import { cardSurface } from "@/components/card-recipes";
import { PageHeader } from "@/components/ui/page-header";
import { cn, ToggleSwitch } from "@/components/ui-primitives";
+import { therapyNeedsReview } from "@/lib/therapies";
import { therapyRecordHref } from "@/lib/therapy-compass-navigation";
import { useTcBindings } from "../bindings";
@@ -27,6 +28,7 @@ export function SheetsScreen() {
const { notice, saved, toggleFavourite } = useTherapyFavourite(t?.slug ?? null);
if (b.loading || !t) return
;
+ const needsReview = therapyNeedsReview(t);
const steps = parseSteps(t.deliverySteps, 5);
const template = t.patientSheetTemplates[0];
const about = t.patientExplanation || template?.body || t.clinicalSummary || "";
@@ -149,13 +151,22 @@ export function SheetsScreen() {
className="w-full max-w-[720px] bg-[color:var(--tc-paper-background)] border border-[color:var(--tc-paper-border)] rounded-sm shadow-[var(--tc-paper-shadow)] py-13 px-14 text-[color:var(--tc-paper-ink)]"
>
-
+
Therapy · Patient information
+ {needsReview ? (
+
+
+ Awaiting clinical review
+
+ ) : null}
Prepared for you
diff --git a/src/components/ui/text-field.tsx b/src/components/ui/text-field.tsx
index b44ee19684..3f581b0422 100644
--- a/src/components/ui/text-field.tsx
+++ b/src/components/ui/text-field.tsx
@@ -70,6 +70,12 @@ export function TextField({
{Icon ?
: null}
{
+ if (props.type === "number") {
+ e.currentTarget.blur();
+ }
+ props.onWheel?.(e);
+ }}
id={field.id}
required={field.required}
autoComplete={field.autoComplete}
diff --git a/src/components/work-frame/two-pane-side-menu.css b/src/components/work-frame/two-pane-side-menu.css
index ae4bccfcd6..7bcd27f821 100644
--- a/src/components/work-frame/two-pane-side-menu.css
+++ b/src/components/work-frame/two-pane-side-menu.css
@@ -97,13 +97,13 @@
place-items: center;
top: 0.375rem;
right: 0.3125rem;
- min-width: 1rem;
- height: 1rem;
+ min-width: 1.125rem;
+ height: 1.125rem;
padding: 0 calc(var(--spacing) * 1);
border-radius: var(--radius-pill);
background: var(--text-heading);
color: var(--surface);
- font-size: 0.625rem;
+ font-size: 0.75rem;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
@@ -714,3 +714,12 @@ a.two-pane-menu__today:hover {
animation: none;
}
}
+
+@media (forced-colors: active) {
+ .two-pane-menu__row[aria-current="true"],
+ .two-pane-menu__tile[aria-current="true"] {
+ border: 2px solid Highlight;
+ outline: 2px solid Highlight;
+ outline-offset: -2px;
+ }
+}
diff --git a/src/components/work-frame/two-pane-side-strip.css b/src/components/work-frame/two-pane-side-strip.css
index f2d28e714f..038b4dcd5e 100644
--- a/src/components/work-frame/two-pane-side-strip.css
+++ b/src/components/work-frame/two-pane-side-strip.css
@@ -164,3 +164,17 @@
transition: none;
}
}
+
+@media (forced-colors: active) {
+ .two-pane-menu__rail-item[aria-pressed="true"] .two-pane-menu__indicator,
+ .two-pane-menu__rail-item[data-current="true"] .two-pane-menu__indicator,
+ .two-pane-menu__rail-item[aria-current="page"] .two-pane-menu__indicator {
+ border: 2px solid Highlight;
+ }
+
+ .two-pane-menu__pip {
+ border: 1px solid CanvasText;
+ background: Highlight;
+ forced-color-adjust: none;
+ }
+}
diff --git a/src/components/work-search/work-search-button.tsx b/src/components/work-search/work-search-button.tsx
index 07ab9c95c6..2023d06e26 100644
--- a/src/components/work-search/work-search-button.tsx
+++ b/src/components/work-search/work-search-button.tsx
@@ -125,6 +125,7 @@ export function WorkSearchButton({ modeId, className }: { modeId: AppModeId; cla
const command = event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey) && !event.shiftKey;
const help = event.key === "?" && !event.metaKey && !event.ctrlKey;
if (!slash && !command && !help) return;
+ if (slash && document.querySelector('[data-shortcut-owner="slash"], #agreement-question-input')) return;
// Captured first, so no other page shortcut acts on the same key as well.
event.preventDefault();
event.stopPropagation();
diff --git a/src/lib/alerts/shared-device.ts b/src/lib/alerts/shared-device.ts
index ff9deba120..b5697a7ab4 100644
--- a/src/lib/alerts/shared-device.ts
+++ b/src/lib/alerts/shared-device.ts
@@ -6,6 +6,8 @@ import {
CPD_APPLICATIONS_STORAGE_KEY,
CPD_HOME_SEND_STORAGE_KEY,
REMIND_ME_STORAGE_KEY,
+ TEACHING_EXAM_PREP_STORAGE_KEY,
+ TEACHING_TERM_TRACKER_STORAGE_KEY,
} from "@/lib/account-scoped-browser-state";
/**
@@ -48,6 +50,10 @@ export function setSharedDevice(shared: boolean): void {
// Referee names, the statement and CPD Home send marks stay off a shared device too.
window.localStorage.removeItem(CPD_APPLICATIONS_STORAGE_KEY);
window.localStorage.removeItem(CPD_HOME_SEND_STORAGE_KEY);
+ // Teaching records are also private to the signed-in person and must not
+ // remain recoverable when this computer is marked shared.
+ window.localStorage.removeItem(TEACHING_TERM_TRACKER_STORAGE_KEY);
+ window.localStorage.removeItem(TEACHING_EXAM_PREP_STORAGE_KEY);
} else window.localStorage.removeItem(STORAGE_KEY);
} catch {
// Storage refused (private mode): the switch simply does not stick.
diff --git a/src/lib/calculators/calculator-fixtures.ts b/src/lib/calculators/calculator-fixtures.ts
index 394c6b1e91..740dc5afb4 100644
--- a/src/lib/calculators/calculator-fixtures.ts
+++ b/src/lib/calculators/calculator-fixtures.ts
@@ -78,6 +78,7 @@ export type CalculatorFixture = {
scoringNote: string;
source: string;
caution?: string;
+ disclaimer?: string;
instrumentVersion: string;
administrationMethod: string;
intendedPopulation: string;
diff --git a/src/lib/calculators/clinical-math/qtc.ts b/src/lib/calculators/clinical-math/qtc.ts
index 566460202a..af6024bde1 100644
--- a/src/lib/calculators/clinical-math/qtc.ts
+++ b/src/lib/calculators/clinical-math/qtc.ts
@@ -9,8 +9,8 @@ export function calculateQtc(input: QtcInput): QtcResult {
const errors: string[] = [];
const alerts: string[] = [];
- if (!Number.isFinite(input.qtMs) || input.qtMs <= 0 || input.qtMs > 1000) {
- errors.push("QT interval must be greater than zero and within physiological range (<=1000 ms)");
+ if (!Number.isFinite(input.qtMs) || input.qtMs < 150 || input.qtMs > 800) {
+ errors.push("QT interval must be within physiological range (150–800 ms; enter milliseconds, not seconds)");
}
let rrSeconds: number;
@@ -23,6 +23,9 @@ export function calculateQtc(input: QtcInput): QtcResult {
input.rrIntervalSeconds > 0
) {
rrSeconds = input.rrIntervalSeconds;
+ if (rrSeconds < 0.24 || rrSeconds > 3.0) {
+ errors.push("RR interval must be between 0.24 and 3.00 seconds (convert milliseconds to seconds first)");
+ }
hr = 60 / rrSeconds;
} else if (input.heartRateBpm !== undefined && Number.isFinite(input.heartRateBpm) && input.heartRateBpm > 0) {
hr = input.heartRateBpm;
@@ -41,8 +44,8 @@ export function calculateQtc(input: QtcInput): QtcResult {
};
}
- if (hr > 250) {
- errors.push("Heart rate exceeds physiological range (>250 bpm)");
+ if (hr < 20 || hr > 250) {
+ errors.push("Heart rate must be within physiological range (20–250 bpm)");
}
if (errors.length > 0) {
diff --git a/src/lib/calculators/clinical-math/renal.ts b/src/lib/calculators/clinical-math/renal.ts
index ac492b28f4..79b474d2b8 100644
--- a/src/lib/calculators/clinical-math/renal.ts
+++ b/src/lib/calculators/clinical-math/renal.ts
@@ -30,12 +30,22 @@ export function calculateCockcroftGault(input: CockcroftGaultInput): CockcroftGa
if (!Number.isFinite(input.weightKg) || input.weightKg <= 0) {
errors.push("Weight must be greater than zero kg");
+ } else if (input.weightKg < 20) {
+ errors.push("Weight is below adult physiological bounds (<20 kg)");
} else if (input.weightKg > 350) {
errors.push("Weight exceeds physiological bounds (>350 kg)");
+ } else if (input.weightKg < 35) {
+ warnings.push(
+ "Low body weight (<35 kg): Cockcroft-Gault may overestimate creatinine clearance in cachexia or low muscle mass.",
+ );
}
if (!Number.isFinite(input.serumCreatinine) || input.serumCreatinine <= 0) {
errors.push("Serum creatinine must be greater than zero");
+ } else if (input.creatinineUnit === "umol/L" && (input.serumCreatinine < 15 || input.serumCreatinine > 3000)) {
+ errors.push("Serum creatinine must be within physiological range (15–3000 umol/L; check unit selection)");
+ } else if (input.creatinineUnit === "mg/dL" && (input.serumCreatinine < 0.17 || input.serumCreatinine > 34.0)) {
+ errors.push("Serum creatinine must be within physiological range (0.17–34.0 mg/dL; check unit selection)");
}
if (errors.length > 0) {
@@ -96,6 +106,10 @@ export function calculateCkdEpi2021(input: CkdEpiInput): CkdEpiResult {
}
if (!Number.isFinite(input.serumCreatinine) || input.serumCreatinine <= 0) {
errors.push("Serum creatinine must be greater than zero");
+ } else if (input.creatinineUnit === "umol/L" && (input.serumCreatinine < 15 || input.serumCreatinine > 3000)) {
+ errors.push("Serum creatinine must be within physiological range (15–3000 umol/L; check unit selection)");
+ } else if (input.creatinineUnit === "mg/dL" && (input.serumCreatinine < 0.17 || input.serumCreatinine > 34.0)) {
+ errors.push("Serum creatinine must be within physiological range (0.17–34.0 mg/dL; check unit selection)");
}
if (errors.length > 0) {
diff --git a/src/lib/developer-area/link-access.ts b/src/lib/developer-area/link-access.ts
index 75f2febdc8..504d2eaa19 100644
--- a/src/lib/developer-area/link-access.ts
+++ b/src/lib/developer-area/link-access.ts
@@ -53,12 +53,14 @@ export {
} from "@/lib/developer-area/link-access-shared";
/**
- * Cookie path. Scoped to `/mockups` rather than `/` so this credential is never
- * transmitted on a clinical request — it has no business being attached to a
- * search, a document read, or an API call, and a cookie that is never sent
- * cannot leak from those requests' logs or proxies.
+ * Cookie paths. Each cookie is scoped to one developer-gated subtree so the
+ * credential is not sent with unrelated clinical or API requests.
*/
-export const DEVELOPER_ACCESS_COOKIE_PATH = "/mockups";
+export const DEVELOPER_ACCESS_COOKIE_PATHS = [
+ "/admin/developer-hub",
+ "/mockups/development",
+ "/mockups/care-plan",
+] as const;
/**
* One year. Deliberately inside the ~400-day ceiling Chrome and Safari clamp
diff --git a/src/lib/form-catalog-constants.ts b/src/lib/form-catalog-constants.ts
new file mode 100644
index 0000000000..b90e332093
--- /dev/null
+++ b/src/lib/form-catalog-constants.ts
@@ -0,0 +1,20 @@
+/**
+ * Lightweight constants and metadata for Forms mode client components so that
+ * rendering priority facts and Act section chips does not bundle
+ * `data/forms-catalog.json` or `data/mha-2014-sections.json` into the browser.
+ */
+
+export const PRIORITY_FACT_CARD_LABELS = {
+ clock: "Clock / review",
+ authority: "Made by / authority",
+ criteria: "Criteria / threshold",
+} as const;
+
+export const ACT_SECTION_CHIP_LIMIT = 6;
+
+export const mhaActMetadata = {
+ actVersion: "02-b0-02",
+ actAsAt: "2025-09-25",
+ sourceUrl:
+ "https://www.legislation.wa.gov.au/legislation/prod/filestore.nsf/FileURL/mrdoc_48919.htm/$FILE/Mental%20Health%20Act%202014%20-%20%5B02-b0-02%5D.html?OpenElement",
+} as const;
diff --git a/src/lib/form-catalog.ts b/src/lib/form-catalog.ts
index 50c17edbe6..c90c37eb15 100644
--- a/src/lib/form-catalog.ts
+++ b/src/lib/form-catalog.ts
@@ -72,25 +72,9 @@ function actSections(value: unknown): FormActSection[] | undefined {
return sections.length ? sections : undefined;
}
-/**
- * The Priority-facts card labels. Shared with the component so the grid label and the
- * detail-sheet title fallback can never drift apart again (the sheet used to say
- * "Criteria" where the grid said "Criteria / threshold").
- */
-export const PRIORITY_FACT_CARD_LABELS = {
- clock: "Clock / review",
- authority: "Made by / authority",
- criteria: "Criteria / threshold",
-} as const;
+import { ACT_SECTION_CHIP_LIMIT, PRIORITY_FACT_CARD_LABELS } from "@/lib/form-catalog-constants";
-/**
- * Tiles the Act-section block may show: two full rows of three. Form 5A cites 11 sections
- * and 4A cites 9; rendering all of them as 48px tap targets inside one quarter of the 2x2
- * grid destroys the layout. Where the sections do not fit, the last tile becomes the "+n"
- * control, so the block is always two rows and never a ragged third — that third row set
- * the height of every other priority-facts card in the row.
- */
-export const ACT_SECTION_CHIP_LIMIT = 6;
+export { ACT_SECTION_CHIP_LIMIT, PRIORITY_FACT_CARD_LABELS };
function summaryCardsForDetails(details: FormCatalogDetails, availabilityLabel: string): ServiceSummaryCard[] {
const facts = details.priorityFacts;
diff --git a/src/lib/information-pages.ts b/src/lib/information-pages.ts
index 24f2bb6e7f..ae0da892b7 100644
--- a/src/lib/information-pages.ts
+++ b/src/lib/information-pages.ts
@@ -77,25 +77,14 @@ export function isInformationPage(pathname: string): boolean {
// information page is also what stops the shell drawing a second bar over the
// top. The two facts are one fact, which is why they share this line.
//
- // `isSlugDetail` covers the single-segment children and excludes nothing that
- // still exists: `search` was in the shared `TOOL_SUFFIXES` set and that route
- // is gone. `/on-call` itself is the bare path, not a slug detail, so it needs
- // its own test.
- if (isSlugDetail(pathname, "/on-call")) return true;
- if (pathname === "/on-call") return true;
+ if (pathname === "/on-call" || (pathname.startsWith("/on-call/") && pathname.length > "/on-call/".length))
+ return true;
// Every CME route, the mode home included, for On Call's reason exactly: the
// mode declares no search surface, so it has no composer on any page and this
- // is what keeps the shell from mounting one. `isSlugDetail` covers the
- // single-segment children (`/cme/log`, `/cme/new`, `/cme/routines`,
- // `/cme/plan`, `/cme/programme`, `/cme/setup`, `/cme/customise`) and `/cme`
- // itself is the bare path rather than a slug detail, so it needs its own test.
- // One entry (`/cme/log/[id]`) is two segments deep and `isSlugDetail` stops
- // short of it, so it is named separately — the entry record is the most
- // read-focused page in the mode and must not be the only one wearing a
- // composer.
- if (isSlugDetail(pathname, "/cme")) return true;
- if (pathname === "/cme") return true;
- if (pathname.startsWith("/cme/log/") && !pathname.slice("/cme/log/".length).includes("/")) return true;
+ // is what keeps the shell from mounting one. Match the mode root and any
+ // nested sub-route under `/cme/` (including `/cme/log/[id]` and deep training
+ // or programme routes) so no child route inherits a shell search composer.
+ if (pathname === "/cme" || (pathname.startsWith("/cme/") && pathname.length > "/cme/".length)) return true;
// The Psychiatry dashboard and its own pages (the MHA clock), for On Call's
// reason: the mode declares no search surface, so none of them may wear a
// composer. The sections it links to keep their own routes and their own
@@ -111,11 +100,8 @@ export function isInformationPage(pathname: string): boolean {
// Its pages also own their in-page header (`InPageNavHeader`, the
// DocumentViewer template) rather than the shared mode-nav bar, so being an
// information page is also what stops the shell drawing a second bar over
- // the top. `isSlugDetail` covers the single-segment children (`/roster/shifts`,
- // `/roster/settings`, `/roster/calendar`) and `/roster` itself is the bare
- // path rather than a slug detail, so it needs its own test.
- if (isSlugDetail(pathname, "/roster")) return true;
- if (pathname === "/roster") return true;
+ // the top.
+ if (pathname === "/roster" || (pathname.startsWith("/roster/") && pathname.length > "/roster/".length)) return true;
// Every Open shifts route, the mode home included, for Roster's reason: the
// mode declares no search surface, so no route may wear a composer.
if (pathname === "/open-shifts" || pathname.startsWith("/open-shifts/")) return true;
diff --git a/src/lib/supabase/session-hint.ts b/src/lib/supabase/session-hint.ts
new file mode 100644
index 0000000000..f475f0b33d
--- /dev/null
+++ b/src/lib/supabase/session-hint.ts
@@ -0,0 +1,16 @@
+"use client";
+
+/** Best-effort client-side hint used to avoid unauthenticated roster probes. */
+export function hasSupabaseAuthSession(): boolean {
+ if (typeof document === "undefined") return false;
+ if (/(?:^|;\s*)sb-[^=;]+-auth-token(?:[.\d]*)=/.test(document.cookie)) return true;
+ try {
+ for (let i = 0; i < window.localStorage.length; i += 1) {
+ const key = window.localStorage.key(i);
+ if (key && /^sb-.*-auth-token$/.test(key)) return true;
+ }
+ } catch {
+ // Ignore storage errors.
+ }
+ return false;
+}
diff --git a/src/lib/teaching/term-tracker-store.ts b/src/lib/teaching/term-tracker-store.ts
index 304f3a3b0e..1e43898747 100644
--- a/src/lib/teaching/term-tracker-store.ts
+++ b/src/lib/teaching/term-tracker-store.ts
@@ -7,6 +7,7 @@ import {
TEACHING_EXAM_PREP_STORAGE_KEY,
TEACHING_TERM_TRACKER_STORAGE_KEY,
} from "@/lib/account-scoped-browser-state";
+import { isSharedDevice, SHARED_DEVICE_CHANGE_EVENT } from "@/lib/alerts/shared-device";
import {
isValidExamPrep,
isValidTermTracker,
@@ -31,6 +32,9 @@ import { announceWorkSyncChange } from "@/lib/work-sync/sections";
const listeners = new Set<() => void>();
const memory = new Map
();
subscribeAccountTransition(() => memory.clear());
+if (typeof window !== "undefined") {
+ window.addEventListener(SHARED_DEVICE_CHANGE_EVENT, () => memory.clear());
+}
function notify(): void {
for (const listener of listeners) listener();
@@ -39,15 +43,20 @@ function notify(): void {
function subscribe(onChange: () => void): () => void {
listeners.add(onChange);
window.addEventListener("storage", onChange);
+ window.addEventListener(SHARED_DEVICE_CHANGE_EVENT, onChange);
const unsubscribeTransition = subscribeAccountTransition(onChange);
return () => {
listeners.delete(onChange);
window.removeEventListener("storage", onChange);
+ window.removeEventListener(SHARED_DEVICE_CHANGE_EVENT, onChange);
unsubscribeTransition();
};
}
function read(key: string): string | null {
+ if (isSharedDevice()) {
+ return memory.get(key) ?? null;
+ }
try {
const stored = window.localStorage.getItem(key);
if (stored === null) memory.delete(key);
@@ -59,6 +68,10 @@ function read(key: string): string | null {
function write(key: string, value: string): void {
memory.set(key, value);
+ if (isSharedDevice()) {
+ notify();
+ return;
+ }
try {
window.localStorage.setItem(key, value);
} catch {
@@ -70,6 +83,12 @@ function write(key: string, value: string): void {
announceWorkSyncChange(key);
}
+export function saveTermTrackerState(state: TermTrackerState): void {
+ if (isValidTermTracker(state)) {
+ write(TEACHING_TERM_TRACKER_STORAGE_KEY, JSON.stringify(state));
+ }
+}
+
const serverSnapshot = () => null;
interface DeviceRecord {
@@ -113,10 +132,10 @@ function useDeviceRecord(
return { state: hydrated ? stored : null, update };
}
-export function useTermTrackerStore(sample: TermTrackerState | null): DeviceRecord {
+export function useTermTrackerStore(sample: TermTrackerState | null = null): DeviceRecord {
return useDeviceRecord(TEACHING_TERM_TRACKER_STORAGE_KEY, parseTermTracker, isValidTermTracker, sample);
}
-export function useExamPrepStore(sample: ExamPrepState | null): DeviceRecord {
+export function useExamPrepStore(sample: ExamPrepState | null = null): DeviceRecord {
return useDeviceRecord(TEACHING_EXAM_PREP_STORAGE_KEY, parseExamPrep, isValidExamPrep, sample);
}
diff --git a/src/lib/therapies.ts b/src/lib/therapies.ts
index 5211d01ef3..83926cbc4d 100644
--- a/src/lib/therapies.ts
+++ b/src/lib/therapies.ts
@@ -58,7 +58,7 @@ export function therapySheetSlugs(): string[] {
}
/** True when a therapy still awaits qualified-clinician sign-off. */
-export function therapyNeedsReview(record: TherapyIndexRecord): boolean {
+export function therapyNeedsReview(record: Pick): boolean {
return record.reviewStatus !== "reviewed";
}
diff --git a/src/lib/work-sync/sections.ts b/src/lib/work-sync/sections.ts
index 990dfa2fef..2759d84370 100644
--- a/src/lib/work-sync/sections.ts
+++ b/src/lib/work-sync/sections.ts
@@ -70,7 +70,7 @@ export const WORK_SYNC_ACCOUNT_STORES: Readonly {
const sandbox = mkdtempSync(path.join(tmpdir(), "bundle-budget-cli-"));
const chunksDir = path.join(sandbox, ".next", "static", "chunks");
mkdirSync(chunksDir, { recursive: true });
+ writeFileSync(path.join(sandbox, ".next", "BUILD_ID"), "test-build-id\n");
writeFileSync(path.join(chunksDir, "main.js"), "console.log('ok')");
writeFileSync(
path.join(sandbox, ".next", "app-build-manifest.json"),
@@ -281,6 +282,41 @@ describe("check-bundle-budget CLI exit", () => {
}
});
+ it("skips when .next/static/chunks exists from next dev without .next/BUILD_ID, and fails closed with --refresh-baseline", async () => {
+ const sandbox = mkdtempSync(path.join(tmpdir(), "bundle-budget-devonly-"));
+ const chunksDir = path.join(sandbox, ".next", "static", "chunks");
+ mkdirSync(chunksDir, { recursive: true });
+ writeFileSync(path.join(chunksDir, "main.js"), "console.log('dev')");
+ try {
+ const checkResult = await runCli(sandbox);
+ expect(checkResult.code).toBe(0);
+ expect(checkResult.stdout).toContain("Skipping.");
+
+ const refreshResult = await runCli(sandbox, ["--refresh-baseline"]);
+ expect(refreshResult.code).toBe(1);
+ expect(refreshResult.stderr).toContain("cannot refresh baseline without a build");
+ } finally {
+ rmSync(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ }
+ });
+
+ it("consolidates MasterSearchHeader desktop slot observation and enforces search input privacy and popover back hit target", () => {
+ const headerSrc = readFileSync(
+ path.join(process.cwd(), "src/components/clinical-dashboard/master-search-header.tsx"),
+ "utf8",
+ );
+ const observerCalls = headerSrc.match(/observeDesktopComposerSlots\(/g) ?? [];
+ expect(observerCalls).toHaveLength(1);
+ expect(headerSrc).toContain('id="global-search-input"');
+ expect(headerSrc).toContain('autoComplete="off"');
+ expect(headerSrc).toContain("spellCheck={false}");
+ expect(headerSrc).toContain('data-gramm="false"');
+ expect(headerSrc).toContain('data-enable-grammarly="false"');
+ expect(headerSrc).toMatch(
+ /]|=>)*\bdata-testid="app-mode-popover-back"[^>]*\bclassName="(?:[^"]*\s)?size-tap(?:\s[^"]*)?"[^>]*>/,
+ );
+ });
+
it("fails closed when baseline source SHA is unresolvable during --refresh-baseline", async () => {
const sandbox = makeSandbox();
try {
diff --git a/tests/calculator-completion-and-quarantine.test.ts b/tests/calculator-completion-and-quarantine.test.ts
new file mode 100644
index 0000000000..d340aa8c26
--- /dev/null
+++ b/tests/calculator-completion-and-quarantine.test.ts
@@ -0,0 +1,127 @@
+// @vitest-environment jsdom
+import "./setup/jsdom.setup";
+
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createElement as h } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { calculators as mockupCalculators } from "@/components/calculator-mockups/calculator-fixtures";
+import { CalculatorsBedsideSheetMockup } from "@/components/calculators/bedside-sheet";
+import { calculators as productionCalculators } from "@/components/calculators/calculator-fixtures";
+import { deriveCalculator } from "@/components/calculators/calculator-ui";
+import { CalculatorsClinicalConsoleMockup } from "@/components/calculators/clinical-console";
+import { CalculatorsGuidedFlowMockup } from "@/components/calculators/guided-flow";
+import { CalculatorsSearchDetailMockup, ScorePanel } from "@/components/calculators/search-detail";
+import { ToastProvider } from "@/components/ui/toast";
+
+const EXPECTED_DISCLAIMER =
+ "Clinical reference — not validated decision support. Confirm scoring and interpretation against the source instrument.";
+
+describe("Calculator Completion Policy, Disclaimers & Quarantine Parity (P1-7)", () => {
+ it("prevents copying incomplete bedside scores and copies only completed scales", async () => {
+ const user = userEvent.setup();
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+
+ render(h(ToastProvider, null, h(CalculatorsBedsideSheetMockup)));
+
+ const copyButton = screen.getByRole("button", { name: "Copy summary" });
+ expect(copyButton).toBeDisabled();
+
+ // Answer only the first item of PHQ-9 (1 of 9 items -> started, but incomplete)
+ const phq9Section = document.getElementById("sheet-phq9")!;
+ const firstItemButtons = within(phq9Section).getAllByRole("button", { name: /Nearly every day/i });
+ await user.click(firstItemButtons[0]);
+
+ // Copy summary must remain disabled because PHQ-9 is incomplete (1/9)
+ expect(copyButton).toBeDisabled();
+ expect(writeText).not.toHaveBeenCalled();
+
+ // Open CAGE (4 checkbox items) and answer all 4 items so CAGE is complete
+ const cageSection = document.getElementById("sheet-cage")!;
+ const cageToggle = within(cageSection).getByRole("button", { name: /CAGE/i });
+ await user.click(cageToggle);
+
+ const cageCheckboxes = within(cageSection).getAllByRole("checkbox");
+ expect(cageCheckboxes).toHaveLength(4);
+ for (const btn of cageCheckboxes) {
+ await user.click(btn);
+ }
+
+ // Now CAGE is complete while PHQ-9 is still incomplete; Copy summary is enabled
+ expect(copyButton).toBeEnabled();
+ await user.click(copyButton);
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ const copiedText = String(writeText.mock.calls[0]?.[0] ?? "");
+ expect(copiedText).toContain("CAGE 4/4");
+ expect(copiedText).not.toContain("PHQ-9");
+ });
+
+ it("renders the clinical reference disclaimer inside the sticky score strip / footer of bedside-sheet, search-detail, clinical-console, and guided-flow", async () => {
+ const user = userEvent.setup();
+ const phq9 = productionCalculators.find((c) => c.id === "phq9")!;
+ const cage = productionCalculators.find((c) => c.id === "cage")!;
+
+ // 1. Bedside sheet: pinned inside Session summary dock even when all sections are collapsed or multiple sections are open
+ const { unmount: unmountBedside } = render(h(ToastProvider, null, h(CalculatorsBedsideSheetMockup)));
+ const sessionSummaryDock = screen.getByRole("region", { name: "Session summary" });
+ expect(within(sessionSummaryDock).getByText(EXPECTED_DISCLAIMER)).toBeInTheDocument();
+ const phq9Section = document.getElementById("sheet-phq9")!;
+ await user.click(within(phq9Section).getByRole("button", { name: /PHQ-9/i }));
+ // With all sections collapsed, the disclaimer remains visible in the sticky session summary dock
+ expect(within(sessionSummaryDock).getByText(EXPECTED_DISCLAIMER)).toBeInTheDocument();
+ unmountBedside();
+
+ // 2. Search detail (ScorePanel with custom disclaimer + CalculatorsSearchDetailMockup sticky Live score strip)
+ const { unmount: unmountScorePanel } = render(
+ h(
+ ToastProvider,
+ null,
+ h(ScorePanel, {
+ calc: { ...phq9, disclaimer: EXPECTED_DISCLAIMER },
+ derived: deriveCalculator(phq9, {}),
+ onReset: () => {},
+ }),
+ ),
+ );
+ expect(screen.getByText(EXPECTED_DISCLAIMER)).toBeInTheDocument();
+ unmountScorePanel();
+
+ const originalScrollTo = window.scrollTo;
+ window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
+ const { unmount: unmountSearchDetail } = render(h(ToastProvider, null, h(CalculatorsSearchDetailMockup)));
+ await user.click(screen.getByRole("button", { name: /PHQ-9/i }));
+ const liveScoreStrip = screen.getByRole("region", { name: "Live score" });
+ expect(within(liveScoreStrip).getByText(EXPECTED_DISCLAIMER)).toBeInTheDocument();
+ unmountSearchDetail();
+ window.scrollTo = originalScrollTo;
+
+ // 3. Clinical console: pinned inside sticky Live result strip
+ const { unmount: unmountConsole } = render(h(ToastProvider, null, h(CalculatorsClinicalConsoleMockup)));
+ const liveResultStrip = screen.getByRole("region", { name: "Live result" });
+ expect(within(liveResultStrip).getByText(EXPECTED_DISCLAIMER)).toBeInTheDocument();
+ unmountConsole();
+
+ // 4. Guided flow ResultScreen (complete 4-item CAGE to reach ResultScreen)
+ const { unmount: unmountGuided } = render(h(ToastProvider, null, h(CalculatorsGuidedFlowMockup)));
+ await user.click(screen.getByRole("button", { name: new RegExp(cage.abbrev, "i") }));
+ for (let i = 0; i < cage.items.length; i++) {
+ await user.click(screen.getByRole("button", { name: /^No/i }));
+ }
+ expect(screen.getByText(EXPECTED_DISCLAIMER)).toBeInTheDocument();
+ unmountGuided();
+ });
+
+ it("excludes quarantined instruments (mdq, sadpersons, ybocs) from mockup calculator-fixtures", () => {
+ const mockupIds = mockupCalculators.map((calc) => calc.id);
+ expect(mockupIds).not.toContain("mdq");
+ expect(mockupIds).not.toContain("sadpersons");
+ expect(mockupIds).not.toContain("ybocs");
+ expect(mockupIds).toEqual(["phq9", "gad7", "k10", "cage", "auditc"]);
+ });
+});
diff --git a/tests/clinical-input-privacy-and-transitions.test.ts b/tests/clinical-input-privacy-and-transitions.test.ts
new file mode 100644
index 0000000000..d94c3201f2
--- /dev/null
+++ b/tests/clinical-input-privacy-and-transitions.test.ts
@@ -0,0 +1,195 @@
+/** @vitest-environment jsdom */
+
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const router = vi.hoisted(() => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ refresh: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({
+ useRouter: () => router,
+ usePathname: () => "/on-call/handover",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+import { ClinicalAskAnswerSurface } from "@/components/clinical-dashboard/clinical-ask-answer-surface";
+import { CmeEntryForm } from "@/components/cme/cme-entry-form";
+import { DsmDiagnosisNoteBuilder } from "@/components/dsm/dsm-diagnosis-note-builder";
+import { FormulationBuilderPage } from "@/components/formulation/formulation-builder-page";
+import { OnCallHandoverPage } from "@/components/on-call/handover/handover-page";
+import {
+ clearAccountScopedBrowserStorage,
+ TEACHING_TERM_TRACKER_STORAGE_KEY,
+} from "@/lib/account-scoped-browser-state";
+import { setSharedDevice } from "@/lib/alerts/shared-device";
+import { dsmDiagnoses } from "@/lib/dsm";
+import { dsmNoteBuilderRecord } from "@/lib/dsm-note";
+import { clearOnCallHandover } from "@/lib/on-call/handover";
+import { sampleTermTracker } from "@/lib/teaching/term-tracker";
+import { saveTermTrackerState, useTermTrackerStore } from "@/lib/teaching/term-tracker-store";
+
+function expectPrivacyAttributes(element: Element | null) {
+ expect(element).not.toBeNull();
+ expect(element?.getAttribute("autocomplete")).toBe("off");
+ expect(element?.getAttribute("spellcheck")).toBe("false");
+ expect(element?.getAttribute("data-gramm")).toBe("false");
+ expect(element?.getAttribute("data-enable-grammarly")).toBe("false");
+}
+
+describe("Clinical input privacy attributes & account transitions", () => {
+ beforeEach(() => {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ setSharedDevice(false);
+ clearOnCallHandover();
+ });
+
+ afterEach(() => {
+ cleanup();
+ setSharedDevice(false);
+ });
+
+ it("renders privacy attributes on OnCallHandoverPage NoteArea, TextLine, and bed/ward inputs", () => {
+ const { container } = render(React.createElement(OnCallHandoverPage));
+ const bedInput = container.querySelector('[data-testid="on-call-handover-bed"]');
+ const wardInput = container.querySelector('[data-testid="on-call-handover-ward"]');
+ const impression = container.querySelector('[data-testid="on-call-handover-impression"]');
+ const story = container.querySelector('[data-testid="on-call-handover-story"]');
+ const referrals = container.querySelector('[data-testid="on-call-handover-referrals"]');
+ const plan = container.querySelector('[data-testid="on-call-handover-plan"]');
+
+ expectPrivacyAttributes(bedInput);
+ expectPrivacyAttributes(wardInput);
+ expectPrivacyAttributes(impression);
+ expectPrivacyAttributes(story);
+ expectPrivacyAttributes(referrals);
+ expectPrivacyAttributes(plan);
+ });
+
+ it("renders privacy attributes on FormulationBuilderPage textareas and resets state on account transition", () => {
+ const { container } = render(React.createElement(FormulationBuilderPage));
+ const stepButtons = container.querySelectorAll('ol[aria-label="Formulation builder steps"] button');
+ expect(stepButtons.length).toBe(4);
+
+ // Step 1 is default; click step 2 (Structure)
+ fireEvent.click(stepButtons[1]!);
+ const structureTextareas = Array.from(container.querySelectorAll("textarea"));
+ expect(structureTextareas.length).toBeGreaterThan(0);
+ for (const textarea of structureTextareas) {
+ expectPrivacyAttributes(textarea);
+ }
+
+ fireEvent.change(structureTextareas[0]!, { target: { value: "Synthetic clinical note" } });
+ expect(window.sessionStorage.getItem("psychsift_formulation_draft")).toContain("Synthetic clinical note");
+
+ // Step 3 (Review)
+ fireEvent.click(stepButtons[2]!);
+ const reviewTextareas = Array.from(container.querySelectorAll("textarea"));
+ expect(reviewTextareas.length).toBeGreaterThan(0);
+ for (const textarea of reviewTextareas) {
+ expectPrivacyAttributes(textarea);
+ }
+
+ // Step 4 (Draft)
+ fireEvent.click(stepButtons[3]!);
+ const draftTextarea = container.querySelector('textarea[aria-label="Formulation draft"]');
+ expectPrivacyAttributes(draftTextarea);
+
+ // Account transition clears live React state and sessionStorage without re-populating it
+ act(() => {
+ clearAccountScopedBrowserStorage();
+ });
+ expect(window.sessionStorage.getItem("psychsift_formulation_draft")).toBeNull();
+ });
+
+ it("renders privacy attributes on DsmDiagnosisNoteBuilder and resets state on account transition", () => {
+ const panic = dsmNoteBuilderRecord(dsmDiagnoses.find((d) => d.slug === "panic-disorder")!);
+ const { container } = render(React.createElement(DsmDiagnosisNoteBuilder, { record: panic }));
+
+ const specifierInput = container.querySelector("#dsm-note-specifier-text") as HTMLInputElement | null;
+ expectPrivacyAttributes(specifierInput);
+
+ fireEvent.change(specifierInput!, { target: { value: "Synthetic specifier" } });
+ const storageKey = `psychsift_dsm_draft_${panic.icdCode}`;
+ expect(window.sessionStorage.getItem(storageKey)).toContain("Synthetic specifier");
+
+ act(() => {
+ clearAccountScopedBrowserStorage();
+ });
+
+ expect(specifierInput?.value).toBe("");
+ expect(window.sessionStorage.getItem(storageKey)).toBeNull();
+ });
+
+ it("renders privacy attributes on CmeEntryForm and ClinicalAskAnswerSurface free-text inputs", () => {
+ const { container: cmeContainer } = render(
+ React.createElement(CmeEntryForm, { onSubmit: vi.fn(), existingEntries: [] }),
+ );
+ expectPrivacyAttributes(cmeContainer.querySelector("#cme-entry-title"));
+ expectPrivacyAttributes(cmeContainer.querySelector("#cme-entry-reflection"));
+
+ const { container: askContainer } = render(
+ React.createElement(ClinicalAskAnswerSurface, {
+ response: {
+ state: "clarification_required",
+ mode: "services",
+ suggestions: [],
+ clarifications: [
+ {
+ id: "serviceLocation",
+ field: "serviceLocation",
+ prompt: "Specify synthetic context",
+ required: true,
+ },
+ ],
+ },
+ onClarificationChange: vi.fn(),
+ }),
+ );
+ expectPrivacyAttributes(askContainer.querySelector('input[name="serviceLocation"]'));
+ });
+
+ it("prevents term-tracker-store from reading or writing localStorage when Shared Device mode is active", () => {
+ setSharedDevice(true);
+ const sample = sampleTermTracker("2026-10-06");
+ saveTermTrackerState(sample);
+ expect(window.localStorage.getItem(TEACHING_TERM_TRACKER_STORAGE_KEY)).toBeNull();
+
+ function Probe() {
+ const { state, update } = useTermTrackerStore(null);
+ return React.createElement(
+ "button",
+ {
+ type: "button",
+ onClick: () => update(() => sample),
+ "data-terms": state?.terms.length ?? 0,
+ },
+ "Save term",
+ );
+ }
+
+ render(React.createElement(Probe));
+ fireEvent.click(screen.getByRole("button", { name: "Save term" }));
+ expect(window.localStorage.getItem(TEACHING_TERM_TRACKER_STORAGE_KEY)).toBeNull();
+ });
+});
diff --git a/tests/clinical-math-bounds.test.ts b/tests/clinical-math-bounds.test.ts
new file mode 100644
index 0000000000..cd7a9f68f9
--- /dev/null
+++ b/tests/clinical-math-bounds.test.ts
@@ -0,0 +1,294 @@
+// @vitest-environment jsdom
+import "./setup/jsdom.setup";
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createElement as h } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context";
+import { PatientProfilePanel } from "@/components/clinical-dashboard/patient-profile-panel";
+import { TextField } from "@/components/ui/text-field";
+import { calculateQtc } from "@/lib/calculators/clinical-math/qtc";
+import { calculateCkdEpi2021, calculateCockcroftGault } from "@/lib/calculators/clinical-math/renal";
+
+describe("Clinical Math Physiological Bounds & Input Ergonomics", () => {
+ describe("calculateQtc physiological bounds (P0-2)", () => {
+ it("rejects QT interval below 150 ms (e.g. seconds entered instead of milliseconds) and above 800 ms", () => {
+ const tooLowSeconds = calculateQtc({ qtMs: 0.42, heartRateBpm: 60, sex: "male" });
+ expect(tooLowSeconds.isValid).toBe(false);
+ expect(tooLowSeconds.bazettMs).toBeNull();
+ expect(tooLowSeconds.errors).toContain(
+ "QT interval must be within physiological range (150–800 ms; enter milliseconds, not seconds)",
+ );
+
+ const belowMin = calculateQtc({ qtMs: 149, heartRateBpm: 60, sex: "female" });
+ expect(belowMin.isValid).toBe(false);
+ expect(belowMin.errors).toContain(
+ "QT interval must be within physiological range (150–800 ms; enter milliseconds, not seconds)",
+ );
+
+ const aboveMax = calculateQtc({ qtMs: 801, heartRateBpm: 60, sex: "female" });
+ expect(aboveMax.isValid).toBe(false);
+ expect(aboveMax.errors).toContain(
+ "QT interval must be within physiological range (150–800 ms; enter milliseconds, not seconds)",
+ );
+ });
+
+ it("accepts QT interval at exact boundaries (150 ms and 800 ms)", () => {
+ expect(calculateQtc({ qtMs: 150, heartRateBpm: 60, sex: "male" }).isValid).toBe(true);
+ expect(calculateQtc({ qtMs: 800, heartRateBpm: 60, sex: "male" }).isValid).toBe(true);
+ });
+
+ it("rejects RR interval outside 0.24–3.00 seconds (e.g. milliseconds entered as seconds)", () => {
+ const msEnteredAsSeconds = calculateQtc({ qtMs: 400, rrIntervalSeconds: 800, sex: "male" });
+ expect(msEnteredAsSeconds.isValid).toBe(false);
+ expect(msEnteredAsSeconds.errors).toContain(
+ "RR interval must be between 0.24 and 3.00 seconds (convert milliseconds to seconds first)",
+ );
+
+ const tooShortRr = calculateQtc({ qtMs: 400, rrIntervalSeconds: 0.23, sex: "female" });
+ expect(tooShortRr.isValid).toBe(false);
+ expect(tooShortRr.errors).toContain(
+ "RR interval must be between 0.24 and 3.00 seconds (convert milliseconds to seconds first)",
+ );
+
+ const tooLongRr = calculateQtc({ qtMs: 400, rrIntervalSeconds: 3.01, sex: "female" });
+ expect(tooLongRr.isValid).toBe(false);
+ expect(tooLongRr.errors).toContain(
+ "RR interval must be between 0.24 and 3.00 seconds (convert milliseconds to seconds first)",
+ );
+ });
+
+ it("accepts RR interval at exact boundaries (0.24 s and 3.00 s)", () => {
+ expect(calculateQtc({ qtMs: 400, rrIntervalSeconds: 0.24, sex: "male" }).isValid).toBe(true);
+ expect(calculateQtc({ qtMs: 400, rrIntervalSeconds: 3.0, sex: "male" }).isValid).toBe(true);
+ });
+
+ it("rejects heart rate below 20 bpm and above 250 bpm", () => {
+ const bradyOut = calculateQtc({ qtMs: 400, heartRateBpm: 19, sex: "male" });
+ expect(bradyOut.isValid).toBe(false);
+ expect(bradyOut.errors).toContain("Heart rate must be within physiological range (20–250 bpm)");
+
+ const tachyOut = calculateQtc({ qtMs: 400, heartRateBpm: 251, sex: "female" });
+ expect(tachyOut.isValid).toBe(false);
+ expect(tachyOut.errors).toContain("Heart rate must be within physiological range (20–250 bpm)");
+ });
+
+ it("accepts heart rate at exact boundaries (20 bpm and 250 bpm)", () => {
+ expect(calculateQtc({ qtMs: 400, heartRateBpm: 20, sex: "male" }).isValid).toBe(true);
+ expect(calculateQtc({ qtMs: 400, heartRateBpm: 250, sex: "female" }).isValid).toBe(true);
+ });
+ });
+
+ describe("calculateCockcroftGault & calculateCkdEpi2021 physiological bounds (P0-1)", () => {
+ it("rejects adult weight below 20 kg and warns below 35 kg in calculateCockcroftGault", () => {
+ const lowWeight = calculateCockcroftGault({
+ age: 45,
+ weightKg: 19.9,
+ sex: "female",
+ serumCreatinine: 80,
+ creatinineUnit: "umol/L",
+ });
+ expect(lowWeight.isValid).toBe(false);
+ expect(lowWeight.crClMlMin).toBeNull();
+ expect(lowWeight.errors).toContain("Weight is below adult physiological bounds (<20 kg)");
+
+ const minWeight = calculateCockcroftGault({
+ age: 45,
+ weightKg: 20,
+ sex: "female",
+ serumCreatinine: 80,
+ creatinineUnit: "umol/L",
+ });
+ expect(minWeight.isValid).toBe(true);
+ expect(minWeight.warnings).toContain(
+ "Low body weight (<35 kg): Cockcroft-Gault may overestimate creatinine clearance in cachexia or low muscle mass.",
+ );
+ });
+
+ it("enforces unit-aware serum creatinine bounds (15–3000 umol/L and 0.17–34.0 mg/dL) in Cockcroft-Gault", () => {
+ const unitMismatchLowUmol = calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 1.1,
+ creatinineUnit: "umol/L",
+ });
+ expect(unitMismatchLowUmol.isValid).toBe(false);
+ expect(unitMismatchLowUmol.errors).toContain(
+ "Serum creatinine must be within physiological range (15–3000 umol/L; check unit selection)",
+ );
+
+ const aboveMaxUmol = calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 3001,
+ creatinineUnit: "umol/L",
+ });
+ expect(aboveMaxUmol.isValid).toBe(false);
+ expect(aboveMaxUmol.errors).toContain(
+ "Serum creatinine must be within physiological range (15–3000 umol/L; check unit selection)",
+ );
+
+ const unitMismatchHighMgDl = calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 90,
+ creatinineUnit: "mg/dL",
+ });
+ expect(unitMismatchHighMgDl.isValid).toBe(false);
+ expect(unitMismatchHighMgDl.errors).toContain(
+ "Serum creatinine must be within physiological range (0.17–34.0 mg/dL; check unit selection)",
+ );
+
+ const belowMinMgDl = calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 0.16,
+ creatinineUnit: "mg/dL",
+ });
+ expect(belowMinMgDl.isValid).toBe(false);
+ expect(belowMinMgDl.errors).toContain(
+ "Serum creatinine must be within physiological range (0.17–34.0 mg/dL; check unit selection)",
+ );
+
+ expect(
+ calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 15,
+ creatinineUnit: "umol/L",
+ }).isValid,
+ ).toBe(true);
+ expect(
+ calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 3000,
+ creatinineUnit: "umol/L",
+ }).isValid,
+ ).toBe(true);
+ expect(
+ calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 0.17,
+ creatinineUnit: "mg/dL",
+ }).isValid,
+ ).toBe(true);
+ expect(
+ calculateCockcroftGault({
+ age: 50,
+ weightKg: 70,
+ sex: "male",
+ serumCreatinine: 34.0,
+ creatinineUnit: "mg/dL",
+ }).isValid,
+ ).toBe(true);
+ });
+
+ it("enforces unit-aware serum creatinine bounds (15–3000 umol/L and 0.17–34.0 mg/dL) in CKD-EPI 2021", () => {
+ const lowUmol = calculateCkdEpi2021({
+ age: 50,
+ sex: "female",
+ serumCreatinine: 14.9,
+ creatinineUnit: "umol/L",
+ });
+ expect(lowUmol.isValid).toBe(false);
+ expect(lowUmol.errors).toContain(
+ "Serum creatinine must be within physiological range (15–3000 umol/L; check unit selection)",
+ );
+
+ const highUmol = calculateCkdEpi2021({
+ age: 50,
+ sex: "female",
+ serumCreatinine: 3001,
+ creatinineUnit: "umol/L",
+ });
+ expect(highUmol.isValid).toBe(false);
+ expect(highUmol.errors).toContain(
+ "Serum creatinine must be within physiological range (15–3000 umol/L; check unit selection)",
+ );
+
+ const lowMgDl = calculateCkdEpi2021({
+ age: 50,
+ sex: "female",
+ serumCreatinine: 0.16,
+ creatinineUnit: "mg/dL",
+ });
+ expect(lowMgDl.isValid).toBe(false);
+ expect(lowMgDl.errors).toContain(
+ "Serum creatinine must be within physiological range (0.17–34.0 mg/dL; check unit selection)",
+ );
+
+ const highMgDl = calculateCkdEpi2021({
+ age: 50,
+ sex: "female",
+ serumCreatinine: 34.1,
+ creatinineUnit: "mg/dL",
+ });
+ expect(highMgDl.isValid).toBe(false);
+ expect(highMgDl.errors).toContain(
+ "Serum creatinine must be within physiological range (0.17–34.0 mg/dL; check unit selection)",
+ );
+
+ expect(
+ calculateCkdEpi2021({ age: 50, sex: "female", serumCreatinine: 15, creatinineUnit: "umol/L" }).isValid,
+ ).toBe(true);
+ expect(
+ calculateCkdEpi2021({ age: 50, sex: "female", serumCreatinine: 0.17, creatinineUnit: "mg/dL" }).isValid,
+ ).toBe(true);
+ });
+ });
+
+ describe("TextField wheel-blur and PatientProfilePanel creatinine step (P1-6)", () => {
+ beforeEach(() => {
+ window.sessionStorage.clear();
+ });
+
+ it("blurs type='number' inputs on wheel events while preserving caller onWheel", () => {
+ const onWheel = vi.fn();
+ render(
+ h(
+ "div",
+ null,
+ h(TextField, { label: "Numeric field", type: "number", onWheel }),
+ h(TextField, { label: "Text field", type: "text" }),
+ ),
+ );
+
+ const numberInput = screen.getByLabelText("Numeric field");
+ numberInput.focus();
+ expect(document.activeElement).toBe(numberInput);
+
+ fireEvent.wheel(numberInput);
+ expect(document.activeElement).not.toBe(numberInput);
+ expect(onWheel).toHaveBeenCalledTimes(1);
+
+ const textInput = screen.getByLabelText("Text field");
+ textInput.focus();
+ expect(document.activeElement).toBe(textInput);
+ fireEvent.wheel(textInput);
+ expect(document.activeElement).toBe(textInput);
+ });
+
+ it("sets step='1' for µmol/L and step='0.01' for mg/dL on the PatientProfilePanel Serum creatinine input", async () => {
+ const user = userEvent.setup();
+ render(h(PatientProfileProvider, null, h(PatientProfilePanel, { defaultOpen: true })));
+
+ const scrUmolInput = screen.getByTestId("patient-scr");
+ expect(scrUmolInput).toHaveAttribute("step", "1");
+
+ await user.click(screen.getByRole("radio", { name: "mg/dL" }));
+ const scrMgDlInput = screen.getByTestId("patient-scr");
+ expect(scrMgDlInput).toHaveAttribute("step", "0.01");
+ });
+ });
+});
diff --git a/tests/developer-cookie-and-shift-probe.test.ts b/tests/developer-cookie-and-shift-probe.test.ts
new file mode 100644
index 0000000000..47b8df95a8
--- /dev/null
+++ b/tests/developer-cookie-and-shift-probe.test.ts
@@ -0,0 +1,70 @@
+/** @vitest-environment jsdom */
+
+import { act, cleanup, render } from "@testing-library/react";
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ShiftNowProbe } from "@/components/last-place/shift-now-probe";
+import { DEVELOPER_ACCESS_COOKIE_PATHS } from "@/lib/developer-area/link-access";
+
+function clearCookies() {
+ for (const raw of document.cookie.split(";")) {
+ const eq = raw.indexOf("=");
+ const name = (eq >= 0 ? raw.slice(0, eq) : raw).trim();
+ if (name) {
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
+ }
+ }
+}
+
+describe("Developer access cookie path & ShiftNowProbe guest guard", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ clearCookies();
+ });
+
+ afterEach(() => {
+ cleanup();
+ clearCookies();
+ vi.restoreAllMocks();
+ });
+
+ it("scopes the developer access cookie to each gated subtree", () => {
+ expect(DEVELOPER_ACCESS_COOKIE_PATHS).toEqual([
+ "/admin/developer-hub",
+ "/mockups/development",
+ "/mockups/care-plan",
+ ]);
+ });
+
+ it("skips /api/roster/shifts network requests for unauthenticated guests and immediately reports false", async () => {
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(new Response(JSON.stringify({ shifts: [] }), { status: 200 }));
+ const onResult = vi.fn();
+
+ render(React.createElement(ShiftNowProbe, { onResult }));
+ await act(async () => {});
+
+ expect(onResult).toHaveBeenCalledWith(false);
+ const rosterShiftCalls = fetchSpy.mock.calls.filter(([input]) => String(input).includes("/api/roster/shifts"));
+ expect(rosterShiftCalls).toHaveLength(0);
+ });
+
+ it("fetches /api/roster/shifts when a Supabase auth token is present in cookies or localStorage", async () => {
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ shifts: [], sample: false, demoMode: false }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ document.cookie = "sb-psychsift-auth-token=synthetic-session-token; path=/";
+ const onResult = vi.fn();
+
+ render(React.createElement(ShiftNowProbe, { onResult }));
+ await act(async () => {});
+
+ const rosterShiftCalls = fetchSpy.mock.calls.filter(([input]) => String(input).includes("/api/roster/shifts"));
+ expect(rosterShiftCalls.length).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/dirty-guards-and-external-links.test.ts b/tests/dirty-guards-and-external-links.test.ts
new file mode 100644
index 0000000000..a4853169e8
--- /dev/null
+++ b/tests/dirty-guards-and-external-links.test.ts
@@ -0,0 +1,218 @@
+/** @vitest-environment jsdom */
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import fs from "node:fs";
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { RotationsRead } from "@/components/roster/rotations/use-rotations";
+import { EXAMPLE_ROTATION_SERVICE_ID, exampleRotationRounds } from "@/lib/example-data/datasets/roster-rotations";
+
+const router = vi.hoisted(() => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ refresh: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({
+ useRouter: () => router,
+ usePathname: () => "/roster/manage/rotations/new",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+const rotationsRead: { current: RotationsRead } = { current: null as unknown as RotationsRead };
+vi.mock("@/components/roster/rotations/use-rotations", () => ({
+ useRotations: () => rotationsRead.current,
+}));
+
+import { syntheticPatients } from "@/components/care-plan/mockups/fixtures";
+import { CarePlanPrototypeProvider } from "@/components/care-plan/mockups/prototype-provider";
+import { SafetyPlanFormSurface } from "@/components/care-plan/mockups/safety-plan-form";
+import { OnCallEntryEditor } from "@/components/on-call/on-call-entry-editor";
+import { NewRotationRoundPage } from "@/components/roster/rotations/admin/round-form";
+
+function isBeforeUnloadPrevented(): boolean {
+ const event = new Event("beforeunload", { cancelable: true });
+ window.dispatchEvent(event);
+ return event.defaultPrevented;
+}
+
+describe("Dirty-state guards, download attributes & external link rel attributes", () => {
+ beforeEach(() => {
+ const example = exampleRotationRounds(new Date());
+ const exampleTeam = example.rounds[0]!.round;
+ const teams = [{ serviceId: EXAMPLE_ROTATION_SERVICE_ID, name: "Adult team", people: exampleTeam.people }];
+ rotationsRead.current = {
+ status: "ready",
+ source: "example",
+ mine: [],
+ managed: example.rounds,
+ canManage: true,
+ teams,
+ team: teams[0]!,
+ actions: { createRound: vi.fn() } as unknown as RotationsRead["actions"],
+ retry: vi.fn(),
+ };
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("guards SafetyPlanFormSurface with useDirtyStateGuard when draft values are modified", () => {
+ const patientId = syntheticPatients[0]!.id;
+ const { container } = render(
+ React.createElement(
+ CarePlanPrototypeProvider,
+ null,
+ React.createElement(SafetyPlanFormSurface, {
+ patientId,
+ scenario: "normal",
+ navigate: vi.fn(),
+ }),
+ ),
+ );
+
+ expect(isBeforeUnloadPrevented()).toBe(false);
+
+ fireEvent.click(screen.getByRole("button", { name: /Start a new version/i }));
+
+ expect(isBeforeUnloadPrevented()).toBe(false);
+
+ const textarea = container.querySelector("textarea");
+ expect(textarea).not.toBeNull();
+ fireEvent.change(textarea!, { target: { value: "Updated synthetic warning sign" } });
+
+ expect(isBeforeUnloadPrevented()).toBe(true);
+ });
+
+ it("guards RoundForm (NewRotationRoundPage) with useDirtyStateGuard when draft is modified", () => {
+ const { container } = render(React.createElement(NewRotationRoundPage));
+ expect(isBeforeUnloadPrevented()).toBe(false);
+
+ const textInputs = Array.from(container.querySelectorAll('input[type="text"], input:not([type])'));
+ expect(textInputs.length).toBeGreaterThan(0);
+ fireEvent.change(textInputs[0]!, { target: { value: "2027 Modified Round Name" } });
+
+ expect(isBeforeUnloadPrevented()).toBe(true);
+ });
+
+ it("guards OnCallEntryEditor with useDirtyStateGuard when open and dirty", () => {
+ render(
+ React.createElement(OnCallEntryEditor, {
+ open: true,
+ onClose: vi.fn(),
+ section: "contacts",
+ entry: null,
+ onSaved: vi.fn(),
+ onDeleted: vi.fn(),
+ }),
+ );
+
+ expect(isBeforeUnloadPrevented()).toBe(false);
+
+ const dialog = screen.getByTestId("on-call-entry-editor");
+ const titleInput = dialog.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null;
+ expect(titleInput).not.toBeNull();
+ fireEvent.change(titleInput!, { target: { value: "After-hours Psychiatry Registrar" } });
+
+ expect(isBeforeUnloadPrevented()).toBe(true);
+ });
+
+ it("releases beforeunload guard when OnCallEntryEditor is closed (open=false)", () => {
+ const { rerender } = render(
+ React.createElement(OnCallEntryEditor, {
+ open: true,
+ onClose: vi.fn(),
+ section: "contacts",
+ entry: null,
+ onSaved: vi.fn(),
+ onDeleted: vi.fn(),
+ }),
+ );
+
+ const dialog = screen.getByTestId("on-call-entry-editor");
+ const titleInput = dialog.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null;
+ fireEvent.change(titleInput!, { target: { value: "Dirty title" } });
+ expect(isBeforeUnloadPrevented()).toBe(true);
+
+ rerender(
+ React.createElement(OnCallEntryEditor, {
+ open: false,
+ onClose: vi.fn(),
+ section: "contacts",
+ entry: null,
+ onSaved: vi.fn(),
+ onDeleted: vi.fn(),
+ }),
+ );
+ expect(isBeforeUnloadPrevented()).toBe(false);
+ });
+
+ it("includes download on RosterCoverTab Excel export link and overflow-x-auto + noopener noreferrer across Workstream B external link files", () => {
+ const rosterCoverSource = fs.readFileSync("src/components/roster/manage/roster-cover-tab.tsx", "utf8");
+ expect(rosterCoverSource).toMatch(/]*download[\s>]/);
+ expect(rosterCoverSource).toContain("Download Excel");
+
+ const cmeTrainingSource = fs.readFileSync("src/components/cme/cme-training-page.tsx", "utf8");
+ expect(cmeTrainingSource).toMatch(
+ /\s*
]*data-testid="cme-training-experience-grid"/,
+ );
+ expect(cmeTrainingSource).toContain('rel="noopener noreferrer"');
+
+ const mhaClockSource = fs.readFileSync("src/components/psychiatry/mha-clock-page.tsx", "utf8");
+ expect(mhaClockSource).toContain('rel="noopener noreferrer"');
+
+ const askSurfaceSource = fs.readFileSync(
+ "src/components/clinical-dashboard/clinical-ask-answer-surface.tsx",
+ "utf8",
+ );
+ expect(askSurfaceSource).toContain('rel="noopener noreferrer"');
+
+ const workstreamBExternalLinkFiles = [
+ "src/components/cme/applications/applications-cv-page.tsx",
+ "src/components/cme/applications/applications-page.tsx",
+ "src/components/cme/cme-training-page.tsx",
+ "src/components/cme/cpd-home/cpd-home-page.tsx",
+ "src/components/first-nations/acknowledgement.tsx",
+ "src/components/first-nations/liaison-hero.tsx",
+ "src/components/first-nations/phrase-line.tsx",
+ "src/components/first-nations/place-card.tsx",
+ "src/components/medicines/medicines-home-page.tsx",
+ "src/components/medicines/medicines-home.tsx",
+ "src/components/my-day/profile/agreement-profile-page.tsx",
+ "src/components/on-call/call/call-page.tsx",
+ "src/components/on-call/first-week/first-week-section-view.tsx",
+ "src/components/psychiatry/mha-clock-page.tsx",
+ "src/components/psychiatry/psychiatry-home-page.tsx",
+ "src/components/psychiatry/psychiatry-home.tsx",
+ "src/components/teaching/teaching-home-page.tsx",
+ "src/components/teaching/assessments/assessments-home.tsx",
+ "src/components/teaching/term-assessment.tsx",
+ "src/components/teaching/term-home-page.tsx",
+ "src/components/clinical-dashboard/clinical-ask-answer-surface.tsx",
+ ];
+
+ const existingFiles = workstreamBExternalLinkFiles.filter((filePath) => fs.existsSync(filePath));
+ expect(existingFiles.length).toBeGreaterThanOrEqual(11);
+
+ for (const filePath of existingFiles) {
+ const source = fs.readFileSync(filePath, "utf8");
+ const relMatches = Array.from(source.matchAll(/\brel="([^"]*)"/g)).map((m) => m[1]!);
+ for (const relValue of relMatches) {
+ if (relValue.includes("noreferrer")) {
+ expect(relValue, `${filePath} has rel="${relValue}" without noopener`).toContain("noopener");
+ }
+ }
+ const targetBlankTags = Array.from(source.matchAll(/]*target="_blank"[^>]*>/g)).map((m) => m[0]!);
+ for (const tag of targetBlankTags) {
+ expect(tag, `${filePath} has target="_blank" without noopener: ${tag}`).toMatch(/\brel="[^"]*noopener[^"]*"/);
+ expect(tag, `${filePath} has target="_blank" without noreferrer: ${tag}`).toMatch(
+ /\brel="[^"]*noreferrer[^"]*"/,
+ );
+ }
+ }
+ });
+});
diff --git a/tests/forms-and-work-bundle-isolation.test.ts b/tests/forms-and-work-bundle-isolation.test.ts
new file mode 100644
index 0000000000..45f9d4d487
--- /dev/null
+++ b/tests/forms-and-work-bundle-isolation.test.ts
@@ -0,0 +1,86 @@
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+import mhaSections from "../data/mha-2014-sections.json";
+import { ACT_SECTION_CHIP_LIMIT, mhaActMetadata, PRIORITY_FACT_CARD_LABELS } from "@/lib/form-catalog-constants";
+import {
+ ACT_SECTION_CHIP_LIMIT as reexportedLimit,
+ PRIORITY_FACT_CARD_LABELS as reexportedLabels,
+} from "@/lib/form-catalog";
+
+const ROOT = process.cwd();
+
+function readWorkspaceFile(relativePath: string): string {
+ return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
+}
+
+describe("Forms and Work client bundle isolation", () => {
+ it("keeps form-catalog-constants.ts free of JSON imports and aligned with mha-2014-sections.json", () => {
+ const constantsSource = readWorkspaceFile("src/lib/form-catalog-constants.ts");
+
+ expect(constantsSource).not.toMatch(/\.json["']/);
+ expect(PRIORITY_FACT_CARD_LABELS).toEqual({
+ clock: "Clock / review",
+ authority: "Made by / authority",
+ criteria: "Criteria / threshold",
+ });
+ expect(ACT_SECTION_CHIP_LIMIT).toBe(6);
+ expect(reexportedLabels).toEqual(PRIORITY_FACT_CARD_LABELS);
+ expect(reexportedLimit).toBe(ACT_SECTION_CHIP_LIMIT);
+ expect(mhaActMetadata).toEqual({
+ actVersion: mhaSections.exportMetadata.actVersion,
+ actAsAt: mhaSections.exportMetadata.actAsAt,
+ sourceUrl: mhaSections.exportMetadata.sourceUrl,
+ });
+ });
+
+ it("prevents form-priority-facts-section.tsx from pulling form-catalog or mha-act-sections into client bundles", () => {
+ const source = readWorkspaceFile("src/components/forms/form-priority-facts-section.tsx");
+
+ expect(source).not.toMatch(/from\s+["']@\/lib\/form-catalog["']/);
+ expect(source).not.toMatch(/from\s+["']@\/lib\/mha-act-sections["']/);
+ expect(source).toMatch(/from\s+["']@\/lib\/form-catalog-constants["']/);
+ expect(source).toMatch(/from\s+["']@\/lib\/form-ranker["']/);
+ expect(source).toContain("ignoreUnavailableActivation");
+ expect(source).toContain('aria-disabled="true"');
+ expect(source).toContain("onClick={ignoreUnavailableActivation}");
+ expect(source).not.toContain('rel="noreferrer"');
+ expect(source).toContain('rel="noopener noreferrer"');
+ expect(source).toContain("form-act-section-overflow-drafted-");
+ expect(source).toContain("Awaiting clinical review");
+ });
+
+ it("keeps my-day-week-page.tsx isolated from my-day-today-cards and removes the dead month view branch", () => {
+ const source = readWorkspaceFile("src/components/my-day/my-day-week-page.tsx");
+
+ expect(source).not.toContain("my-day-today-cards");
+ expect(source).not.toContain('view === "month"');
+ expect(source).not.toContain(" {
+ const source = readWorkspaceFile("src/components/teaching/assessments/teaching-assessments.tsx");
+
+ const sampleModules = [
+ "@/components/teaching/assessments/assessments-assessor",
+ "@/components/teaching/assessments/assessments-home",
+ "@/components/teaching/assessments/assessments-year",
+ "@/components/teaching/assessments/assessments-form",
+ "@/components/teaching/assessments/assessments-steps",
+ "@/components/teaching/assessments/assessments-report",
+ "@/components/teaching/assessments/assessments-supervisor",
+ "@/components/teaching/assessments/assessments-inbox",
+ "@/components/teaching/assessments/assessments-term-overview",
+ ];
+
+ for (const modulePath of sampleModules) {
+ expect(source).not.toMatch(new RegExp(`^import\\s+(?!type\\b).*from\\s+["']${modulePath}["']`, "m"));
+ expect(source).toContain(`import("${modulePath}")`);
+ }
+ expect(source).not.toMatch(
+ /^import\s+\{[^}]*\b(DctHome|DctPlan|DctSignoff)\b[^}]*\}\s+from\s+["']@\/components\/teaching\/assessments\/assessments-dct["']/m,
+ );
+ expect(source).toContain('import("@/components/teaching/assessments/assessments-dct")');
+ });
+});
diff --git a/tests/information-pages.test.ts b/tests/information-pages.test.ts
index 10d4a86645..45a8ff0651 100644
--- a/tests/information-pages.test.ts
+++ b/tests/information-pages.test.ts
@@ -28,12 +28,20 @@ describe("information pages and the search composer", () => {
}
});
- it("treats every CPD route as an information page, and Job applications and its CV in Admin", () => {
+ it("treats every CPD, On Call, and Roster route (including nested paths) as an information page, and Job applications and its CV in Admin", () => {
for (const path of [
"/cme",
"/cme/log",
"/cme/log/abc",
+ "/cme/training",
+ "/cme/training/log",
"/cme/cpd-home",
+ "/on-call",
+ "/on-call/card",
+ "/on-call/handover/night",
+ "/roster",
+ "/roster/shifts",
+ "/roster/shifts/swap",
"/admin/applications",
"/admin/applications/cv",
]) {
diff --git a/tests/landmarks-headings-and-wiring.test.ts b/tests/landmarks-headings-and-wiring.test.ts
new file mode 100644
index 0000000000..27b11c983e
--- /dev/null
+++ b/tests/landmarks-headings-and-wiring.test.ts
@@ -0,0 +1,132 @@
+/** @vitest-environment jsdom */
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { cleanup, render, screen } from "@testing-library/react";
+import React from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ReminderSettingsBlock } from "@/components/clinical-dashboard/settings-reminders";
+import { SectionHeading } from "@/components/open-shifts/open-shifts-ui";
+import { DEFAULT_REMINDER_SETTINGS } from "@/lib/reminders/settings";
+
+const root = process.cwd();
+const readSource = (relPath: string) => readFileSync(path.join(root, relPath), "utf8");
+
+describe("Task 7 & 8: focus restoration, shortcut guards, landmarks, headings, and explanatory aria-disabled wiring", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("restores focus to the opener when SettingsDialog closes via a single owner", () => {
+ const lazyDialogs = readSource("src/components/clinical-dashboard/lazy-sidebar-dialogs.tsx");
+ const settingsDialog = readSource("src/components/clinical-dashboard/settings-dialog.tsx");
+
+ expect(lazyDialogs).not.toContain("restoreFocusUnlessMoved");
+ expect(settingsDialog).toContain("triggerRef.current = document.activeElement");
+ expect(settingsDialog).toContain("restoreFocusUnlessMoved(target)");
+ expect(settingsDialog).toContain("returnFocusRef={triggerRef}");
+ });
+
+ it("wires each ReminderSettingsBlock phone calendar alert select to its type heading and label via aria-labelledby", () => {
+ render(
+ React.createElement(ReminderSettingsBlock, {
+ reminders: DEFAULT_REMINDER_SETTINGS,
+ onChange: vi.fn(),
+ }),
+ );
+
+ const comboboxes = screen.getAllByRole("combobox");
+ expect(comboboxes.length).toBeGreaterThan(0);
+ for (const select of comboboxes) {
+ const labelledBy = select.getAttribute("aria-labelledby") ?? "";
+ expect(labelledBy.trim().length).toBeGreaterThan(0);
+ }
+ });
+
+ it("guards UniversalSearchCommandSurface / and Cmd+K shortcuts when an aria-modal dialog is open", () => {
+ const commandSurface = readSource("src/components/clinical-dashboard/universal-search-command-surface.tsx");
+
+ expect(commandSurface).toContain("function handleSearchFocusShortcut(event: KeyboardEvent)");
+ expect(commandSurface).toContain('if (document.querySelector(\'[role="dialog"][aria-modal="true"]\')) return;');
+ });
+
+ it('yields the WorkSearchButton / shortcut when a page marks data-shortcut-owner="slash" (such as Ask the agreement)', () => {
+ const workSearchButton = readSource("src/components/work-search/work-search-button.tsx");
+ const agreementAskPage = readSource("src/components/agreement-ask/agreement-ask-page.tsx");
+
+ expect(workSearchButton).toContain(
+ "if (slash && document.querySelector('[data-shortcut-owner=\"slash\"], #agreement-question-input')) return;",
+ );
+ expect(agreementAskPage).toContain('data-shortcut-owner="slash"');
+ });
+
+ it("guards CalculatorSheet backdrop click dismissal once answers have been started", () => {
+ const calculatorSheet = readSource("src/components/calculators/calculator-sheet.tsx");
+
+ expect(calculatorSheet).toContain("const dismissFromBackdrop = () => {");
+ expect(calculatorSheet).toContain("if (derived.started) {");
+ expect(calculatorSheet).toContain("closeRef.current?.focus();");
+ });
+
+ it("renders and landmarks on /factsheets/search, /factsheets/topics, /cme boundaries, and /first-nations", () => {
+ const factsheetsSearch = readSource("src/components/factsheets/factsheets-search-page.tsx");
+ const factsheetsTopics = readSource("src/components/factsheets/factsheets-topics-page.tsx");
+ const cmeOwnerBoundary = readSource("src/components/cme/cme-owner-boundary.tsx");
+ const cmeStateNotice = readSource("src/components/cme/cme-state-notice.tsx");
+ const bedsideHome = readSource("src/components/first-nations/bedside-home.tsx");
+
+ expect(factsheetsSearch).toMatch(/Search patient information ");
+
+ expect(factsheetsTopics).toMatch(/CPD<\/h1>/g)?.length).toBeGreaterThanOrEqual(3);
+ expect(cmeStateNotice).toContain('First Nations ');
+ });
+
+ it("separates the Open Shifts SectionHeading label from its count with a space at runtime", () => {
+ render(SectionHeading({ count: 2, children: "Thursday 8 October" }));
+ const heading = screen.getByRole("heading", { level: 2 });
+ expect(heading.textContent).toBe("Thursday 8 October 2");
+ });
+
+ it("wires explanatory unavailable controls with aria-disabled, aria-describedby, title, and ignoreUnavailableActivation", () => {
+ const assessKit = readSource("src/components/teaching/assessments/assess-kit.tsx");
+ const termOverview = readSource("src/components/teaching/assessments/assessments-term-overview.tsx");
+ const numberSheets = readSource("src/components/favourites/number-sheets.tsx");
+ const filmstrip = readSource("src/components/document-viewer/document-image-filmstrip.tsx");
+ const overviewLanding = readSource("src/components/document-viewer/document-overview-landing.tsx");
+ const documentViewer = readSource("src/components/DocumentViewer.tsx");
+
+ expect(assessKit).toContain('aria-disabled={disabled ? "true" : undefined}');
+ expect(assessKit).toContain("onClick={disabled ? ignoreUnavailableActivation : onClick}");
+
+ expect(termOverview).toContain("onClick={live ? onClick : ignoreUnavailableActivation}");
+ expect(termOverview).toContain('onClick={remindWhyNot ? ignoreUnavailableActivation : () => setSheet("bulk")}');
+ expect(termOverview).toContain("onClick={ignoreUnavailableActivation}");
+
+ expect(numberSheets).toContain('aria-disabled={!dialable ? "true" : undefined}');
+ expect(numberSheets).toContain("aria-describedby={!dialable ? `${item.id}-copy-unavailable` : undefined}");
+ expect(numberSheets).toContain("onClick={!dialable ? ignoreUnavailableActivation : handleCopy}");
+
+ expect(filmstrip).toContain('aria-disabled={page === null ? "true" : undefined}');
+ expect(filmstrip).toContain("onClick={page === null ? ignoreUnavailableActivation : () => onSelectPage(page)}");
+ expect(filmstrip).toContain("aria-describedby={pageUnavailableId}");
+
+ expect(overviewLanding).toContain('aria-disabled={answerUnavailable ? "true" : undefined}');
+ expect(overviewLanding).toContain("aria-describedby={answerUnavailable ? answerReasonId : undefined}");
+ expect(overviewLanding).toContain("onClick={answerUnavailable ? ignoreUnavailableActivation : onAskFromDocument}");
+
+ expect(documentViewer).toContain('aria-disabled={!canSummarizeDocument ? "true" : undefined}');
+ expect(documentViewer).toContain(
+ 'aria-describedby={!canSummarizeDocument ? "document-actions-summarize-reason" : undefined}',
+ );
+ });
+});
diff --git a/tests/mobile-ergonomics-and-forced-colors.test.ts b/tests/mobile-ergonomics-and-forced-colors.test.ts
new file mode 100644
index 0000000000..caf9878768
--- /dev/null
+++ b/tests/mobile-ergonomics-and-forced-colors.test.ts
@@ -0,0 +1,85 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+function readRepoFile(relativePath: string): string {
+ return readFileSync(path.join(process.cwd(), relativePath), "utf8");
+}
+
+describe("Mobile ergonomics, touch targets, forced-colors, and 12px floor (Task 9)", () => {
+ it("prevents iOS Safari auto-zoom by enforcing 1rem font-size below 640px on assessment and admin inputs", () => {
+ const assessCss = readRepoFile("src/components/teaching/assessments/assess.css");
+ expect(assessCss).toMatch(
+ /@media\s*\(max-width:\s*639px\)\s*\{[\s\S]*?\.assess-txt\s*\{\s*font-size:\s*1rem;\s*\}/,
+ );
+
+ const adminWorkCss = readRepoFile("src/components/admin/admin-work.module.css");
+ expect(adminWorkCss).toMatch(
+ /@media\s*\(max-width:\s*639px\)\s*\{\s*\.searchBox,\s*\.field,\s*\.select,\s*\.copyBox\s*\{\s*font-size:\s*1rem;\s*\}\s*\}/,
+ );
+ });
+
+ it("prevents 320px-375px horizontal overflow on medication and differential loading skeletons", () => {
+ const medicationLoading = readRepoFile("src/app/(search-app)/medications/[slug]/loading.tsx");
+ expect(medicationLoading).toContain("w-full max-w-96");
+ expect(medicationLoading).not.toMatch(/(?= 44px touch targets on credentials wallet Edit, mode switcher, and home recent rows", () => {
+ const credentialsWallet = readRepoFile("src/components/admin/admin-credentials-wallet.tsx");
+ expect(credentialsWallet).toContain("min-h-tap min-w-tap items-center justify-center");
+
+ const modeActionPopup = readRepoFile("src/components/clinical-dashboard/mode-action-popup.tsx");
+ expect(modeActionPopup).toContain(
+ "-mx-1.5 flex min-h-tap max-w-full items-center gap-1 rounded-lg px-1.5 py-0.5 text-left transition",
+ );
+
+ const answerStatus = readRepoFile("src/components/clinical-dashboard/answer-status.tsx");
+ expect(answerStatus).toContain("focus-ring-contained flex min-h-12 w-full items-center");
+ });
+
+ it("provides Windows High Contrast (@media (forced-colors: active)) borders and state outlines for custom controls", () => {
+ const assessCss = readRepoFile("src/components/teaching/assessments/assess.css");
+ expect(assessCss).toContain(".assess-cb,");
+ expect(assessCss).toContain(".assess-rd,");
+ expect(assessCss).toContain(".assess-sw {");
+ expect(assessCss).toContain(".assess-cb[data-on],");
+ expect(assessCss).toContain(".assess-opt:has(:checked) .assess-rd,");
+ expect(assessCss).toContain(".assess-tg:has(:checked) .assess-sw {");
+
+ const cmeCss = readRepoFile("src/components/cme/cme-work.css");
+ expect(cmeCss).toMatch(
+ /@media\s*\(forced-colors:\s*active\)\s*\{[\s\S]*?\.cpd-switch\s*\{\s*border:\s*1px solid CanvasText;\s*\}/,
+ );
+ expect(cmeCss).toContain('[aria-checked="true"] > .cpd-switch');
+
+ const stripCss = readRepoFile("src/components/work-frame/two-pane-side-strip.css");
+ expect(stripCss).toMatch(
+ /@media\s*\(forced-colors:\s*active\)\s*\{[\s\S]*?\.two-pane-menu__rail-item\[aria-pressed="true"\]\s+\.two-pane-menu__indicator/,
+ );
+ expect(stripCss).toContain(".two-pane-menu__pip");
+
+ const menuCss = readRepoFile("src/components/work-frame/two-pane-side-menu.css");
+ expect(menuCss).toMatch(
+ /@media\s*\(forced-colors:\s*active\)\s*\{[\s\S]*?\.two-pane-menu__row\[aria-current="true"\],\s*\.two-pane-menu__tile\[aria-current="true"\]/,
+ );
+ });
+
+ it("raises sub-12px font-size rules to at least 0.75rem (12px)", () => {
+ const notifyCss = readRepoFile("src/components/needs-you/notification-centre.css");
+ expect(notifyCss).not.toMatch(/font-size:\s*0\.6(25|5|875)rem/);
+ expect(notifyCss).toMatch(/\.notify-badge\s*\{[\s\S]*?font-size:\s*0\.75rem;/);
+
+ const menuCss = readRepoFile("src/components/work-frame/two-pane-side-menu.css");
+ expect(menuCss).not.toMatch(/font-size:\s*0\.6(25|5|875)rem/);
+ expect(menuCss).toMatch(/\.two-pane-menu__bell-count\s*\{[\s\S]*?font-size:\s*0\.75rem;/);
+
+ const carePlanCss = readRepoFile("src/components/care-plan/mockups/care-plan.module.css");
+ expect(carePlanCss).not.toMatch(/font-size:\s*0\.6(25|5|875)rem/);
+ expect(carePlanCss).toMatch(/\.appRoot\s+\.definitionTerm\s*\{[\s\S]*?font-size:\s*0\.75rem;/);
+ });
+});
diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts
index 49742afb06..d095f65b09 100644
--- a/tests/proxy.test.ts
+++ b/tests/proxy.test.ts
@@ -424,31 +424,60 @@ describe("passwordless developer-area access (?devkey)", () => {
return response.headers.getSetCookie().find((cookie) => cookie.startsWith(`${DEVELOPER_ACCESS_COOKIE}=`));
}
- it("exchanges a correct key for a signed cookie and redirects the key out of the URL", async () => {
+ it.each(["http:", "https:"])(
+ "exchanges a correct key for three scoped cookies over %s and strips the key",
+ async (protocol) => {
+ await withKey(async () => {
+ const response = await proxy(
+ new NextRequest(`${protocol}//localhost/mockups/development?${DEVELOPER_ACCESS_QUERY_PARAM}=${KEY}`),
+ );
+
+ const location = response.headers.get("location");
+ expect(location).toBeTruthy();
+ // The whole point of the redirect: the secret must not survive into the
+ // address bar, the shared history entry, or an onward Referer header.
+ expect(location).not.toContain(KEY);
+ expect(location).not.toContain(DEVELOPER_ACCESS_QUERY_PARAM);
+ // A success carries no rejection marker, so a stale one from an earlier
+ // wrong guess cannot follow the visitor into the opened area.
+ expect(location).not.toContain(DEVELOPER_ACCESS_ERROR_PARAM);
+
+ const cookies = response.headers
+ .getSetCookie()
+ .filter((cookie) => cookie.startsWith(`${DEVELOPER_ACCESS_COOKIE}=`));
+ expect(cookies).toHaveLength(3);
+ expect(cookies.map((cookie) => cookie.match(/(?:^|;\s*)Path=([^;]+)/)?.[1])).toEqual([
+ "/admin/developer-hub",
+ "/mockups/development",
+ "/mockups/care-plan",
+ ]);
+ for (const cookie of cookies) {
+ expect(cookie).toContain("HttpOnly");
+ expect(cookie).toContain("SameSite=lax");
+ expect(cookie).toContain("Max-Age=31536000");
+ expect(cookie.includes("; Secure")).toBe(protocol === "https:");
+ // Each subtree receives a signature, never the URL key.
+ expect(cookie).not.toContain(KEY);
+ const token = cookie.slice(cookie.indexOf("=") + 1).split(";")[0];
+ expect(developerAccessTokenValid(token, { DEVELOPER_AREA_ACCESS_KEY: KEY })).toBe(true);
+ }
+ });
+ },
+ );
+
+ it("issues exactly three cookies when a key exchange also renews an existing valid cookie", async () => {
await withKey(async () => {
- const response = await proxy(requestFor(`/mockups/development?${DEVELOPER_ACCESS_QUERY_PARAM}=${KEY}`));
-
- const location = response.headers.get("location");
- expect(location).toBeTruthy();
- // The whole point of the redirect: the secret must not survive into the
- // address bar, the shared history entry, or an onward Referer header.
- expect(location).not.toContain(KEY);
- expect(location).not.toContain(DEVELOPER_ACCESS_QUERY_PARAM);
- // A success carries no rejection marker, so a stale one from an earlier
- // wrong guess cannot follow the visitor into the opened area.
- expect(location).not.toContain(DEVELOPER_ACCESS_ERROR_PARAM);
-
- const cookie = accessCookie(response);
- expect(cookie).toBeTruthy();
- expect(cookie).toContain("HttpOnly");
- expect(cookie).toContain("SameSite=lax");
- // The cookie is never sent on a clinical request.
- expect(cookie).toContain("Path=/mockups");
- // And it carries a signature, not the key.
- expect(cookie).not.toContain(KEY);
-
- const token = cookie!.slice(cookie!.indexOf("=") + 1).split(";")[0];
- expect(developerAccessTokenValid(token, { DEVELOPER_AREA_ACCESS_KEY: KEY })).toBe(true);
+ const token = issueDeveloperAccessToken({ DEVELOPER_AREA_ACCESS_KEY: KEY }) as string;
+ const response = await proxy(
+ requestWithCookie(
+ `/mockups/development?${DEVELOPER_ACCESS_QUERY_PARAM}=${KEY}`,
+ DEVELOPER_ACCESS_COOKIE,
+ token,
+ ),
+ );
+ expect(
+ response.headers.getSetCookie().filter((cookie) => cookie.startsWith(`${DEVELOPER_ACCESS_COOKIE}=`)),
+ ).toHaveLength(3);
});
});
@@ -495,7 +524,9 @@ describe("passwordless developer-area access (?devkey)", () => {
const token = issueDeveloperAccessToken({ DEVELOPER_AREA_ACCESS_KEY: KEY }) as string;
const renewed = await proxy(requestWithCookie("/mockups/development", DEVELOPER_ACCESS_COOKIE, token));
- expect(accessCookie(renewed)).toBeTruthy();
+ expect(
+ renewed.headers.getSetCookie().filter((cookie) => cookie.startsWith(`${DEVELOPER_ACCESS_COOKIE}=`)),
+ ).toHaveLength(3);
// Rolling renewal must extend only what already verifies; a forged value
// is left to be refused by DeveloperAreaGate, never re-stamped as valid.
diff --git a/tests/search-workspace-memoization.test.ts b/tests/search-workspace-memoization.test.ts
new file mode 100644
index 0000000000..6c2381b2f0
--- /dev/null
+++ b/tests/search-workspace-memoization.test.ts
@@ -0,0 +1,59 @@
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+const ROOT = process.cwd();
+
+function readWorkspaceFile(relativePath: string): string {
+ return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
+}
+
+describe("search workspace INP memoization and deferred filtering", () => {
+ it("memoizes DSM search filtering, chapter grouping, and category counts with useDeferredValue", () => {
+ const source = readWorkspaceFile("src/components/dsm/dsm-search-page.tsx");
+
+ expect(source).toContain("useDeferredValue");
+ expect(source).toContain("const deferredQuery = useDeferredValue(query)");
+ expect(source).toContain("const deferredResults = useDeferredValue(queryResults)");
+ expect(source).toMatch(/const\s+filtered\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+grouped\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+categoryCounts\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+totalMatchingConditions\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+filterGroups\s*=\s*useMemo\(/);
+ });
+
+ it("memoizes Dictionary catalogue filtering, letter grouping, and facet counts with useDeferredValue", () => {
+ const source = readWorkspaceFile("src/components/dictionary/dictionary-catalogue-pages.tsx");
+
+ expect(source).toContain("useDeferredValue");
+ expect(source).toContain("const deferredParams = useDeferredValue(params)");
+ expect(source).toMatch(/const\s+filteredEntries\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+groupedByLetter\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+categoryCounts\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+groups\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+letterGroups\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+appliedFilters\s*=\s*useMemo\(/);
+ });
+
+ it("memoizes Specifiers filtering, sorting, category counts, and facet groups with useDeferredValue", () => {
+ const source = readWorkspaceFile("src/components/specifiers/specifiers-home-page.tsx");
+
+ expect(source).toContain("useDeferredValue");
+ expect(source).toContain("const deferredQuery = useDeferredValue(query)");
+ expect(source).toMatch(/const\s+filteredEntries\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+sortedEntries\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+categoryCounts\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+grouped\s*=\s*useMemo\(/);
+ });
+
+ it("memoizes Medication Prescribing visible cards, grouped sections, and filter chip counts with useDeferredValue", () => {
+ const source = readWorkspaceFile("src/components/clinical-dashboard/medication-prescribing-workspace.tsx");
+
+ expect(source).toContain("useDeferredValue");
+ expect(source).toContain("const deferredQuery = useDeferredValue(query)");
+ expect(source).toMatch(/const\s+filterChipCounts\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+visibleCards\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+groupedSections\s*=\s*useMemo\(/);
+ expect(source).toMatch(/const\s+appliedFilters\s*=\s*useMemo\(/);
+ });
+});
diff --git a/tests/therapy-review-status-governance.test.ts b/tests/therapy-review-status-governance.test.ts
new file mode 100644
index 0000000000..37725497cc
--- /dev/null
+++ b/tests/therapy-review-status-governance.test.ts
@@ -0,0 +1,163 @@
+// @vitest-environment jsdom
+import "./setup/jsdom.setup";
+
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createElement as h } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { TcProvider } from "@/components/therapy-compass/bindings";
+import type { Therapy } from "@/components/therapy-compass/data/types";
+import { BriefScreen, buildBriefCopy } from "@/components/therapy-compass/screens/brief-screen";
+import { SheetsScreen } from "@/components/therapy-compass/screens/sheets-screen";
+
+const navigation = vi.hoisted(() => ({ pathname: "/therapy-compass/unreviewed-cbt/brief", search: "" }));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => navigation.pathname,
+ useSearchParams: () => new URLSearchParams(navigation.search),
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
+}));
+
+vi.mock("@/components/account-data-provider", () => ({
+ useAccountData: () => ({ isAuthenticated: false, isSaved: () => false, setFavourite: vi.fn(async () => true) }),
+}));
+
+const makeTherapy = vi.hoisted(
+ () =>
+ (slug: string, name: string, reviewStatus: "reviewed" | "needs_review"): Therapy => ({
+ slug,
+ name,
+ category: "Cognitive and behavioural",
+ clinicalSummary: `${name} summary`,
+ bestUsedFor: "Anxiety",
+ indications: "Anxiety",
+ contraindicationsOrCautions: "Check acuity before use.",
+ deliverySteps: "1. Orient the patient\n2. Practise the skill",
+ patientExplanation: "A structured skill for managing anxiety.",
+ sourceNotes: null,
+ targetSymptoms: "Anxiety",
+ patientPopulation: "Adults",
+ setting: "Outpatient",
+ sessionLength: "50 minutes",
+ timeRequired: "5 minutes",
+ complexity: "Low",
+ mechanism: "Cognitive restructuring",
+ briefVersion: "1. Orient the patient\n2. Practise the skill",
+ fifteenMinuteVersion: "1. Orient\n2. Practise\n3. Review",
+ fullSessionVersion: null,
+ homework: "Practise daily.",
+ materials: null,
+ commonPitfalls: null,
+ alternatives: null,
+ relatedTherapies: null,
+ evidenceLevel: "High",
+ evidenceNotes: null,
+ limitations: null,
+ references: null,
+ reviewStatus,
+ confidenceLevel: null,
+ contentOrigin: null,
+ patientSheetAvailable: true,
+ briefInterventionAvailable: true,
+ sourceCompleteness: null,
+ indexCompleteness: null,
+ reviewCompleteness: null,
+ tags: ["Anxiety"],
+ warnings: [],
+ aliases: [],
+ sources: [],
+ patientSheetTemplates: [],
+ clinicianScripts: [{ scriptType: "Opening", title: "Intro", body: "Let us try a brief exercise." }],
+ reviewChecklist: null,
+ }),
+);
+
+vi.mock("@/components/therapy-compass/data/use-therapy-data", () => ({
+ useTherapyData: () => ({
+ data: {
+ therapies: [
+ makeTherapy("unreviewed-cbt", "Unreviewed CBT", "needs_review"),
+ makeTherapy("reviewed-act", "Reviewed ACT", "reviewed"),
+ ],
+ pathways: [],
+ reference: { categories: [], tags: [], measures: [] },
+ },
+ loading: false,
+ error: null,
+ retry: vi.fn(),
+ }),
+}));
+
+afterEach(() => {
+ navigation.pathname = "/therapy-compass/unreviewed-cbt/brief";
+ navigation.search = "";
+});
+
+describe("Therapy Review Status Governance on SheetsScreen and BriefScreen (P2-1)", () => {
+ describe("buildBriefCopy", () => {
+ it("includes the clinical review warning line when therapyNeedsReview is true", () => {
+ const unreviewed = makeTherapy("unreviewed-cbt", "Unreviewed CBT", "needs_review");
+ const copied = buildBriefCopy(unreviewed, "5-minute", ["Orient the patient", "Practise the skill"]);
+ expect(copied).toContain("[Awaiting clinical review — verify against primary guidelines]");
+ });
+
+ it("omits the clinical review warning line when therapyNeedsReview is false", () => {
+ const reviewed = makeTherapy("reviewed-act", "Reviewed ACT", "reviewed");
+ const copied = buildBriefCopy(reviewed, "5-minute", ["Orient the patient", "Practise the skill"]);
+ expect(copied).not.toContain("[Awaiting clinical review — verify against primary guidelines]");
+ });
+ });
+
+ describe("BriefScreen review-status badge and clipboard output", () => {
+ it("renders 'Awaiting clinical review' with warning styling and copies warning text when needsReview is true", async () => {
+ const user = userEvent.setup();
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+
+ navigation.pathname = "/therapy-compass/unreviewed-cbt/brief";
+ render(h(TcProvider, null, h(BriefScreen)));
+
+ const badge = screen.getByTestId("therapy-brief-review-badge");
+ expect(badge).toHaveTextContent("Awaiting clinical review");
+ expect(badge.className).toContain("--warning-bg");
+
+ await user.click(screen.getByRole("button", { name: "Copy intervention" }));
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText.mock.calls[0]?.[0]).toMatch(
+ /\[Awaiting clinical review [—-] verify against primary guidelines\]/,
+ );
+ });
+
+ it("renders 'Reviewed' with success styling when needsReview is false", () => {
+ navigation.pathname = "/therapy-compass/reviewed-act/brief";
+ render(h(TcProvider, null, h(BriefScreen)));
+
+ const badge = screen.getByTestId("therapy-brief-review-badge");
+ expect(badge).toHaveTextContent("Reviewed");
+ expect(badge.className).toContain("--success-bg");
+ });
+ });
+
+ describe("SheetsScreen review-status warning badge", () => {
+ it("renders an 'Awaiting clinical review' warning badge when therapyNeedsReview(t) is true", () => {
+ navigation.pathname = "/therapy-compass/unreviewed-cbt/sheet";
+ render(h(TcProvider, null, h(SheetsScreen)));
+
+ const badge = screen.getByTestId("therapy-sheet-review-warning");
+ expect(badge).toBeInTheDocument();
+ expect(badge).toHaveTextContent("Awaiting clinical review");
+ });
+
+ it("does not render 'Awaiting clinical review' when therapyNeedsReview(t) is false", () => {
+ navigation.pathname = "/therapy-compass/reviewed-act/sheet";
+ render(h(TcProvider, null, h(SheetsScreen)));
+
+ expect(screen.queryByTestId("therapy-sheet-review-warning")).not.toBeInTheDocument();
+ expect(screen.queryByText("Awaiting clinical review")).not.toBeInTheDocument();
+ });
+ });
+});