Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-18 | claude/therapy-modes-visibility-bb37d2 | 9b4b3056b1f4ef7355e8947d6b210dee63de182e | Therapy production visibility: remove devOnly gate, route-layout not-found gate and production review filter; add catalogue review notice + needsReviewCount; retire PLAYWRIGHT_OFFLINE_MODE bypass; update pinning contracts | Approved — reachability now disclosed rather than gated; per-record reviewStatus badges retained on every surface; single-commit revert restores all three gates | verify:pr-local (docs/ledger/lint/typecheck passed; test failed only on unrelated load-flaky tests/codex-cloud-setup.test.ts, which passes in isolation at HEAD and with the change); build from wiped .next compiled successfully with all 9 therapy routes; check:rag:fixtures, check:medication-interactions, check:medication-lexicon-report passed; focused therapy+route-reachability contracts 77 passed; eslint+tsc clean; dev-server route 200s. verify:ui not run - coordinator heavy lock held by another worktree |
1 change: 1 addition & 0 deletions docs/codebase-index.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,6 +64,7 @@ Smaller top-level directories that are easy to miss:
- **Home:** `src/app/(search-app)/page.tsx` — dashboard rendered by shell
- **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/`
- **Modes (15):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, DSM-5 diagnosis, specifiers, formulation, prescribing, tools, calculators, Therapy, Factsheets, Dictionary
- **Therapy review disclosure.** Therapy was `devOnly` while its 205-record catalogue awaited qualified-clinician sign-off. That hid the mode from production navigation, 404'd `/therapy-compass` in the route layout, and made `therapyRecordsForEnvironment` filter every record out — so all 205 detail/brief/sheet routes and every universal-search therapy hit 404'd for real users while working locally. The owner's decision (2026-08-19) replaced the gate with disclosure: reachability is no longer conditioned on review status anywhere, and the caveat is stated instead — catalogue-wide by `TherapyReviewNotice` above the Therapy home hero (counts from the generated `THERAPY_CATALOGUE_SUMMARY.needsReviewCount`, kept in step by the index generator's check mode), and per record by the `reviewStatus` badge on every card, detail page, brief, sheet, comparison, pathway, and universal-search result. `therapyNeedsReview` survives as the label source only. Pinned by `tests/app-modes.test.ts` (reachability), `tests/therapy-review-regressions.test.ts` (the notice and the per-record badges), and `tests/therapy-pr-unblocking-contract.test.ts` (the retired `PLAYWRIGHT_OFFLINE_MODE` bypass that existed only to reach the gated route).

### Product pages (`src/app/`)

Expand Down
7 changes: 7 additions & 0 deletions scripts/build-therapies-index.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,7 @@ function renderManifest(current, previous, summary) {
"// not download and parse a record projection before its LCP can paint.",
"export const THERAPY_CATALOGUE_SUMMARY = {",
` totalCount: ${summary.totalCount},`,
` needsReviewCount: ${summary.needsReviewCount},`,
` defaultBriefSlug: ${JSON.stringify(summary.defaultBriefSlug)},`,
` defaultSheetSlug: ${JSON.stringify(summary.defaultSheetSlug)},`,
"} as const;\n",
Expand DownExpand Up@@ -152,6 +153,10 @@ const browserHomeProjected = therapies

const catalogueSummary = {
totalCount: browserHomeProjected.length,
// Records still awaiting qualified-clinician sign-off. Therapy ships its review
// state rather than hiding unreviewed records, so the library notice states this
// count; computing it here keeps the notice from drifting away from the data.
needsReviewCount: browserHomeProjected.filter((therapy) => therapy.reviewStatus !== "reviewed").length,
defaultBriefSlug: browserHomeProjected.find((therapy) => therapy.briefInterventionAvailable)?.slug ?? null,
defaultSheetSlug: browserHomeProjected.find((therapy) => therapy.patientSheetAvailable)?.slug ?? null,
};
Expand All@@ -160,11 +165,13 @@ if (checkOnly) {
const summaryBlock = extractConstObjectBody(currentManifest, "THERAPY_CATALOGUE_SUMMARY");
const recordedSummary = {
totalCount: Number(summaryBlock.match(/totalCount: (\d+)/)?.[1] ?? Number.NaN),
needsReviewCount: Number(summaryBlock.match(/needsReviewCount: (\d+)/)?.[1] ?? Number.NaN),
defaultBriefSlug: summaryBlock.match(/defaultBriefSlug: (null|"[^"]+")/)?.[1] ?? "",
defaultSheetSlug: summaryBlock.match(/defaultSheetSlug: (null|"[^"]+")/)?.[1] ?? "",
};
if (
recordedSummary.totalCount !== catalogueSummary.totalCount ||
recordedSummary.needsReviewCount !== catalogueSummary.needsReviewCount ||
recordedSummary.defaultBriefSlug !== JSON.stringify(catalogueSummary.defaultBriefSlug) ||
recordedSummary.defaultSheetSlug !== JSON.stringify(catalogueSummary.defaultSheetSlug)
) {
Expand Down
19 changes: 8 additions & 11 deletions src/app/(search-app)/therapy-compass/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
import { Suspense } from "react";
import type { ReactNode } from "react";
import { notFound } from "next/navigation";

import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton";
import { TherapyCompassRouteLayout } from "@/components/therapy-compass/therapy-compass-route-layout";
import { isAppModeVisible } from "@/lib/app-modes";

// Therapy-only state belongs at the deepest shared route segment. Keeping this
// provider out of the global search shell prevents every other mode from
// downloading Therapy's client graph, while the client boundary can still read
// current pathname/search params on each navigation.
//
// This segment previously returned a not-found response in production, because
// Therapy was a dev-only mode while its catalogue awaited qualified-clinician
// sign-off. That gate is gone: the review state is now disclosed on the library
// notice and on every record instead of removing the mode, so this layout has no
// environment branch at all. See `src/lib/therapies.ts` and the contracts in
// tests/therapy-review-regressions.test.ts, which assert the gate stays absent
// by scanning this file — keep the prose here free of the literal call.
export default function TherapyCompassLayout({ children }: { children: ReactNode }) {
const offlineReviewBuild = process.env.PLAYWRIGHT_OFFLINE_MODE === "true";
if (
process.env.NODE_ENV === "production" &&
!offlineReviewBuild &&
!isAppModeVisible("therapy-compass", "production")
) {
notFound();
}

return (
<Suspense fallback={<ModeHomeRouteLoading />}>
<TherapyCompassRouteLayout>{children}</TherapyCompassRouteLayout>
Expand Down
1 change: 1 addition & 0 deletions src/components/therapy-compass/data/generated-assets.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ export const THERAPY_CATALOGUE_ASSETS_PREVIOUS = {
// not download and parse a record projection before its LCP can paint.
export const THERAPY_CATALOGUE_SUMMARY = {
totalCount: 205,
needsReviewCount: 205,
defaultBriefSlug: "acceptance-and-commitment-therapy-act",
defaultSheetSlug: "acceptance-and-commitment-therapy-act",
} as const;
6 changes: 6 additions & 0 deletions src/components/therapy-compass/screens/home-screen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ import { ModeHomeMain, ModeHomeTemplate } from "@/components/mode-home-template"
import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
import { therapyHrefWithSearchParams, therapyScreenHref } from "@/lib/therapy-compass-navigation";

import { TherapyReviewNotice } from "../therapy-review-notice";

import { THERAPY_CATALOGUE_SUMMARY } from "../data/generated-assets";

const SUGGESTIONS = [
Expand All@@ -28,6 +30,10 @@ export function HomeScreen() {

return (
<ModeHomeMain testId="therapy-compass-home" contentAlign="startOnPhone">
{/* Above the hero, not in the footer: the catalogue-wide review caveat is
the first thing a reader of this library needs, and the quiet footer
line is not load-bearing enough to carry it alone. */}
<TherapyReviewNotice className="mb-3 sm:mb-4" />
<ModeHomeTemplate
testId="therapy-compass"
title="Therapy"
Expand Down
49 changes: 49 additions & 0 deletions src/components/therapy-compass/therapy-review-notice.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { ShieldAlert } from "lucide-react";

import { cn } from "@/components/ui-primitives";

import { THERAPY_CATALOGUE_SUMMARY } from "./data/generated-assets";

/**
* Library-level review disclosure for Therapy.
*
* Therapy is reachable in production with its review state stated rather than
* hidden — the mode was previously `devOnly`, which 404'd the route and all 205
* records for real users. This notice carries the catalogue-wide half of that
* disclosure; the per-record half is the badge every result card, detail page,
* brief and patient sheet already renders from `reviewStatus`.
*
* Counts come from the generated catalogue summary, which the index generator's
* check mode keeps in step with the data, so the wording cannot drift as records
* are signed off. Non-interactive on purpose: no tap target, no link, nothing to
* dismiss — a caveat the reader can turn off is not a caveat.
*/
export function TherapyReviewNotice({ className }: { className?: string }) {
const total: number = THERAPY_CATALOGUE_SUMMARY.totalCount;
const needsReview: number = THERAPY_CATALOGUE_SUMMARY.needsReviewCount;

if (needsReview === 0) return null;

const scope =
needsReview === total
? `No therapy record in this library has completed clinician review yet.`
: `${needsReview} of ${total} therapy records have not completed clinician review yet.`;

return (
<div
role="note"
data-testid="therapy-review-notice"
className={cn(
"mx-auto flex w-full max-w-none items-start gap-2 rounded-xl border border-[color:var(--warning-border)] bg-[color:var(--warning-bg)] px-3 py-2.5 text-left sm:max-w-[60rem] sm:px-4",
className,
)}
>
<ShieldAlert className="mt-0.5 size-icon-sm shrink-0 text-[color:var(--warning)]" aria-hidden />
<p className="m-0 text-xs leading-5 text-[color:var(--warning-text)]">
<span className="font-semibold">Awaiting clinician review. </span>
{scope} Records are shown so they can be read and checked against their cited sources — verify each one before
using it clinically.
</p>
</div>
);
}
11 changes: 7 additions & 4 deletions src/lib/app-modes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -368,10 +368,13 @@ export const appModeDefinitions = [
label: "Therapy",
description: "Source-grounded therapy decision support",
href: "/therapy-compass",
// Keep Therapy available for local clinical review while its catalogue is
// awaiting qualified-clinician sign-off. Removing this gate requires the
// catalogue review-status contract to prove production-ready records.
devOnly: true,
// Therapy ships in production with its review state disclosed rather than
// hidden. It was previously `devOnly`, which 404'd the route and every
// record for real users; the owner's decision is that a catalogue labelled
// "needs source review" on the library notice, every result card and every
// record page is more useful — and no less honest — than an absent mode.
// Per-record sign-off is still tracked by `therapyNeedsReview` and surfaced
// everywhere the record appears; it no longer gates reachability.
search: {
kind: "therapies",
// The longer phrase became the late portal's LCP element on Therapy Home.
Expand Down
57 changes: 26 additions & 31 deletions src/lib/therapies.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,57 +28,52 @@ export const therapyRecords = therapiesIndexJson as TherapyIndexRecord[];

const bySlug = new Map(therapyRecords.map((record) => [record.slug, record]));

function defaultTherapyEnvironment(): string | undefined {
return process.env.PLAYWRIGHT_OFFLINE_MODE === "true" ? "development" : process.env.NODE_ENV;
}

/** Production may expose only records that have completed clinical review. */
export function therapyRecordsForEnvironment(environment = defaultTherapyEnvironment()): TherapyIndexRecord[] {
return environment === "production" ? therapyRecords.filter((record) => !therapyNeedsReview(record)) : therapyRecords;
}
// The whole catalogue is reachable in every environment. Reachability is
// deliberately NOT gated on review status: a record awaiting qualified-clinician
// sign-off is disclosed as such (`therapyNeedsReview` drives the library notice,
// the result-card badge and the record-page badge) rather than hidden. The
// previous production filter removed all 205 records, so every Therapy route and
// every universal-search therapy hit 404'd for real users while working locally.

export function findTherapyRecord(
slug: string,
environment = defaultTherapyEnvironment(),
): TherapyIndexRecord | undefined {
const record = bySlug.get(slug);
return record && (environment !== "production" || !therapyNeedsReview(record)) ? record : undefined;
export function findTherapyRecord(slug: string): TherapyIndexRecord | undefined {
return bySlug.get(slug);
}

export function therapyRecordExists(slug: string, environment = defaultTherapyEnvironment()): boolean {
return Boolean(findTherapyRecord(slug, environment));
export function therapyRecordExists(slug: string): boolean {
return bySlug.has(slug);
}

export function therapySlugs(environment = defaultTherapyEnvironment()): string[] {
return therapyRecordsForEnvironment(environment).map((record) => record.slug);
export function therapySlugs(): string[] {
return therapyRecords.map((record) => record.slug);
}

/** Slugs whose record ships a brief-intervention version (the rest 404 that route). */
export function therapyBriefSlugs(environment = defaultTherapyEnvironment()): string[] {
return therapyRecordsForEnvironment(environment)
.filter((record) => record.briefInterventionAvailable)
.map((record) => record.slug);
export function therapyBriefSlugs(): string[] {
return therapyRecords.filter((record) => record.briefInterventionAvailable).map((record) => record.slug);
}

/** Slugs whose record ships a patient sheet (the rest 404 that route). */
export function therapySheetSlugs(environment = defaultTherapyEnvironment()): string[] {
return therapyRecordsForEnvironment(environment)
.filter((record) => record.patientSheetAvailable)
.map((record) => record.slug);
export function therapySheetSlugs(): string[] {
return therapyRecords.filter((record) => record.patientSheetAvailable).map((record) => record.slug);
}

/** True when a therapy still awaits qualified-clinician sign-off. */
export function therapyNeedsReview(record: TherapyIndexRecord): boolean {
return record.reviewStatus !== "reviewed";
}

/** Count of records still awaiting sign-off — drives the library review notice. */
export function therapyNeedsReviewCount(): number {
return therapyRecords.filter(therapyNeedsReview).length;
}

export type TherapySearchMatch = { record: TherapyIndexRecord; score: number };

/**
* Rank the production-safe therapy library through the shared Therapy scorer.
* An empty query returns the alphabetical library (stable order) so the
* universal-search domain can still surface a browse list.
* Rank the therapy library through the shared Therapy scorer. An empty query
* returns the alphabetical library (stable order) so the universal-search
* domain can still surface a browse list.
*/
export function searchTherapyRecords(query: string, environment = defaultTherapyEnvironment()): TherapySearchMatch[] {
return rankTherapyCandidates(therapyRecordsForEnvironment(environment), query);
export function searchTherapyRecords(query: string): TherapySearchMatch[] {
return rankTherapyCandidates(therapyRecords, query);
}
14 changes: 9 additions & 5 deletions tests/app-modes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -316,7 +316,7 @@ describe("app mode search contract", () => {
expect(isAppModeVisible("prescribing", "production")).toBe(true);
expect(isAppModeVisible("tools", "production")).toBe(true);
expect(isAppModeVisible("calculators", "production")).toBe(true);
expect(isAppModeVisible("therapy-compass", "production")).toBe(false);
expect(isAppModeVisible("therapy-compass", "production")).toBe(true);
expect(isAppModeVisible("factsheets", "production")).toBe(true);
expect(productionModes).not.toContain("evidence");
expect(productionModes).toContain("services");
Expand All@@ -329,7 +329,7 @@ describe("app mode search contract", () => {
expect(productionModes).toContain("prescribing");
expect(productionModes).toContain("tools");
expect(productionModes).toContain("calculators");
expect(productionModes).not.toContain("therapy-compass");
expect(productionModes).toContain("therapy-compass");
expect(productionModes).toContain("factsheets");
expect(developmentModes).toEqual(
expect.arrayContaining([
Expand All@@ -352,12 +352,16 @@ describe("app mode search contract", () => {
expect(developmentModes).not.toContain("evidence");
});

it("keeps Therapy Compass behind clinical review in production", () => {
// Therapy was `devOnly` while its catalogue awaited clinician sign-off, which
// hid the mode and 404'd all 205 records for real users. The owner's decision
// is to ship it with its review state disclosed instead, so production and
// development must now agree — a re-added `devOnly: true` fails here.
it("keeps Therapy reachable in production with its review state disclosed, not hidden", () => {
expect(isAppModeId("therapy-compass")).toBe(true);
expect(isAppModeVisible("therapy-compass", "development")).toBe(true);
expect(isAppModeVisible("therapy-compass", "production")).toBe(false);
expect(isAppModeVisible("therapy-compass", "production")).toBe(true);
expect(visibleAppModeDefinitions("development").map((mode) => mode.id)).toContain("therapy-compass");
expect(visibleAppModeDefinitions("production").map((mode) => mode.id)).not.toContain("therapy-compass");
expect(visibleAppModeDefinitions("production").map((mode) => mode.id)).toContain("therapy-compass");
});

it("gates Favourites mode to authenticated or demo sessions", () => {
Expand Down
17 changes: 11 additions & 6 deletions tests/therapy-pr-unblocking-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,16 +10,21 @@ describe("Therapy PR unblocking contracts", () => {
expect(source).toContain('import("@/lib/therapies")');
});

it("keeps the production content gate while allowing isolated offline UI verification", () => {
// The Therapy production content gate is gone (see tests/app-modes.test.ts and
// tests/therapy-review-regressions.test.ts). Its PLAYWRIGHT_OFFLINE_MODE bypass
// existed only to let offline UI verification reach the gated route, so it must
// go with it: a bypass left behind an absent gate is how a half-restored gate
// ends up passing locally and 404ing in production.
it("retires the offline bypass along with the Therapy production content gate", () => {
const layoutSource = read("src/app/(search-app)/therapy-compass/layout.tsx");
const therapiesSource = read("src/lib/therapies.ts");

expect(layoutSource).toContain('process.env.PLAYWRIGHT_OFFLINE_MODE === "true"');
expect(layoutSource).toContain("!offlineReviewBuild");
expect(layoutSource).toContain("notFound()");
expect(layoutSource).not.toContain("PLAYWRIGHT_OFFLINE_MODE");
expect(layoutSource).not.toContain("offlineReviewBuild");
expect(layoutSource).not.toContain("notFound()");
expect(layoutSource).not.toContain("NEXT_PUBLIC_DEMO_MODE");
expect(therapiesSource).toContain('process.env.PLAYWRIGHT_OFFLINE_MODE === "true" ? "development"');
expect(therapiesSource).toContain('environment === "production"');
expect(therapiesSource).not.toContain("PLAYWRIGHT_OFFLINE_MODE");
expect(therapiesSource).not.toContain('environment === "production"');
});

it("canonicalises hidden shared-home modes instead of retaining impossible URL state", () => {
Expand Down
Loading
Loading