+
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..ae1852c4bc 100644
--- a/src/components/last-place/shift-now-probe.tsx
+++ b/src/components/last-place/shift-now-probe.tsx
@@ -6,13 +6,31 @@ import { kindOf } from "@/components/roster/roster-format";
import { useRosterShifts } from "@/components/roster/use-roster-shifts";
import { summariseToday } from "@/lib/roster/today";
+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;
+}
+
/**
* Reads the doctor's own roster once, and reports whether a real shift (not
* the example roster) is running right now. The same roster read and the same
* "on now" rule My Day uses for its hero.
*/
-export function ShiftNowProbe({ onResult }: { readonly onResult: (onShiftNow: boolean) => void }) {
- const roster = useRosterShifts();
+function ShiftNowProbeActive({ onResult }: { readonly onResult: (onShiftNow: boolean) => void }) {
+ const roster = useRosterShifts(undefined, { skipWhenUnauthenticated: true });
const reported = useRef(false);
useEffect(() => {
if (reported.current) return;
@@ -35,3 +53,16 @@ export function ShiftNowProbe({ onResult }: { readonly onResult: (onShiftNow: bo
}, [roster.status, roster.teamLoading, roster.sample, roster.demoMode, roster.shifts, onResult]);
return null;
}
+
+export function ShiftNowProbe({ onResult }: { readonly onResult: (onShiftNow: boolean) => void }) {
+ const requiresAuth = Boolean((useRosterShifts as { requiresAuthProbe?: boolean }).requiresAuthProbe);
+ const canProbe = !requiresAuth || hasSupabaseAuthSession();
+ const reported = useRef(false);
+ useEffect(() => {
+ if (canProbe || reported.current) return;
+ reported.current = true;
+ onResult(false);
+ }, [canProbe, onResult]);
+ if (!canProbe) return null;
+ return
;
+}
diff --git a/src/components/my-day/my-day-week-page.tsx b/src/components/my-day/my-day-week-page.tsx
index c79b214e56..12ce18e1ba 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,14 +29,119 @@ 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 { perthWeekday, shiftTitle, weekdayTime } from "@/lib/my-day/quiet-figures";
+import { mergeMyDayItems } from "@/lib/my-day/merge";
+import { dayCode, 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";
-import { addDaysToDate, perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time";
+import { SHIFT_KIND_LABEL, type ShiftKind } from "@/lib/roster/shift-kind";
+import { addDaysToDate, formatPerthDay, perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time";
import { zonedDateOf } from "@/lib/work-time/format";
import { useWorkTimeZone } from "@/components/work-time/use-work-time-zone";
+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)]",
+};
+
+function hoursText(value: number): string {
+ return `${Number(value.toFixed(1))}`;
+}
+
+function StripDay({
+ date,
+ today,
+ kinds,
+ due,
+ href,
+}: {
+ readonly date: string;
+ readonly today: string;
+ readonly kinds: readonly ShiftKind[] | null;
+ readonly due: number;
+ 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}
+ )}
+
+ );
+}
+
/** 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 +188,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 +517,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 +532,6 @@ export function MyDayWeekDays({
reminders,
rosterKnown,
hoursPartial = false,
- view = "week",
- onView,
- shownMonth = null,
- onMonth,
notices,
}: {
readonly now: Date;
@@ -475,19 +544,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 +591,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..6ad33539ce 100644
--- a/src/components/roster/use-roster-shifts.ts
+++ b/src/components/roster/use-roster-shifts.ts
@@ -120,6 +120,24 @@ function unionShifts(listed: readonly OnCallShift[], older: readonly OnCallShift
return [...extra, ...listed].sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt));
}
+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;
+}
+
/**
* `teamRange` sets the dates team shifts are read for. With `ownHistory`, your
* own shifts are read for the part of that range older than the main list
@@ -128,7 +146,7 @@ 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();
@@ -153,6 +171,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 +190,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 +236,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 +268,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) => {
@@ -409,6 +433,7 @@ export function useRosterShifts(
dismissChanges,
};
}
+useRosterShifts.requiresAuthProbe = true;
/** "2 added, 1 moved, 1 removed", leaving out the zeros. */
export function describeRosterChangeCounts(summary: Pick) {
diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx
index c3f2170866..5816b9207c 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"
@@ -162,176 +167,254 @@ function SpecifierResults({ query }: { query: string }) {
[allCatalogueMatches],
);
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..5494dad869 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 { 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,102 @@ 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 AssessmentsSheets = dynamic(
+ () => import("@/components/teaching/assessments/assessments-help").then((m) => m.AssessmentsSheets),
+ { ssr: false },
+);
+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 +254,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 ;
@@ -384,7 +514,7 @@ function AssessmentsApp() {
{savedNote}
) : null}
- setSheet(null)} {...props} />
+ {sheet ? setSheet(null)} {...props} /> : null}
);
}
@@ -414,7 +544,7 @@ function AssessmentsOpenPage({ view }: { view: "help" | "words" }) {
return (
<>
{view === "help" ?
:
}
-
setSheet(null)} {...props} />
+ {sheet ? setSheet(null)} {...props} /> : null}
>
);
}
diff --git a/src/components/therapy-compass/screens/brief-screen.tsx b/src/components/therapy-compass/screens/brief-screen.tsx
index 379f3e36ea..57d9959367 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 as Therapy) ? ["[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