+ Awaiting clinician review. + {scope} Records are shown so they can be read and checked against their cited sources — verify each one before + using it clinically. +
+ + ); +} diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 00193ba8ae..9162919a62 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -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. diff --git a/src/lib/therapies.ts b/src/lib/therapies.ts index 3cfb5eb0ce..3ed946fc89 100644 --- a/src/lib/therapies.ts +++ b/src/lib/therapies.ts @@ -28,43 +28,33 @@ 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. */ @@ -72,13 +62,18 @@ 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); } diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index a4ae603758..0986369a41 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -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"); @@ -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([ @@ -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", () => { diff --git a/tests/therapy-pr-unblocking-contract.test.ts b/tests/therapy-pr-unblocking-contract.test.ts index 9b806fc7c4..83bbda7ef7 100644 --- a/tests/therapy-pr-unblocking-contract.test.ts +++ b/tests/therapy-pr-unblocking-contract.test.ts @@ -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", () => { diff --git a/tests/therapy-ranking.test.ts b/tests/therapy-ranking.test.ts index 5b7d0ff215..45c6c41f40 100644 --- a/tests/therapy-ranking.test.ts +++ b/tests/therapy-ranking.test.ts @@ -8,8 +8,8 @@ import { rankTherapyCandidates, scoreTherapyCandidate } from "@/lib/therapy-rank import { findTherapyRecord, searchTherapyRecords, + therapyNeedsReview, therapyRecords, - therapyRecordsForEnvironment, therapySlugs, } from "@/lib/therapies"; @@ -50,12 +50,16 @@ describe("shared Therapy ranker", () => { expect(rankTherapyCandidates(records, "CBT")[0]?.record.name).toBe("Cognitive behavioural therapy"); }); - it("excludes unreviewed Therapy content from production discovery and routes", () => { + // The inverse of this used to hold: production filtered every unreviewed record + // out, which emptied discovery and 404'd all 205 routes. Review status is now a + // disclosure, not a reachability gate, so an unreviewed record must resolve. + it("keeps unreviewed Therapy content discoverable and routable, flagged rather than dropped", () => { expect(therapyRecords.length).toBeGreaterThan(0); - expect(therapyRecordsForEnvironment("production")).toEqual([]); - expect(searchTherapyRecords("CBT", "production")).toEqual([]); - expect(therapySlugs("production")).toEqual([]); - expect(findTherapyRecord(therapyRecords[0].slug, "production")).toBeUndefined(); + const unreviewed = therapyRecords.find(therapyNeedsReview); + expect(unreviewed).toBeDefined(); + expect(therapySlugs()).toContain(unreviewed!.slug); + expect(findTherapyRecord(unreviewed!.slug)).toBe(unreviewed); + expect(searchTherapyRecords("CBT").length).toBeGreaterThan(0); }); it.each([ @@ -74,7 +78,7 @@ describe("shared Therapy ranker", () => { const catalogueOrder = searchTherapies(fullTherapyRecords, { ...EMPTY_SEARCH, query }) .slice(0, 5) .map((record) => record.slug); - const universalOrder = searchTherapyRecords(query, "development") + const universalOrder = searchTherapyRecords(query) .slice(0, 5) .map(({ record }) => record.slug); @@ -87,6 +91,6 @@ describe("shared Therapy ranker", () => { ["DBT", "dialectical-behaviour-therapy-dbt"], ["EMDR", "eye-movement-desensitisation-and-reprocessing-emdr"], ])("ranks the exact %s alias first", (query, expectedSlug) => { - expect(searchTherapyRecords(query, "development")[0]?.record.slug).toBe(expectedSlug); + expect(searchTherapyRecords(query)[0]?.record.slug).toBe(expectedSlug); }); }); diff --git a/tests/therapy-review-regressions.test.ts b/tests/therapy-review-regressions.test.ts index d0c1fdc1b0..723d6f3992 100644 --- a/tests/therapy-review-regressions.test.ts +++ b/tests/therapy-review-regressions.test.ts @@ -29,16 +29,53 @@ describe("Therapy review regression contracts", () => { ); }); - it("keeps Therapy unavailable in production until clinical review is complete", () => { + // Replaces the former "keeps Therapy unavailable in production" contract. That + // gate hid the mode and 404'd all 205 records for real users; the owner's + // decision is to ship the library with its review state disclosed. These + // assertions pin the disclosure so the caveat cannot be dropped once the mode + // is reachable — the reachability half is pinned in tests/app-modes.test.ts. + it("keeps Therapy reachable with its review state disclosed instead of hidden", () => { const layout = source("src/app/(search-app)/therapy-compass/layout.tsx"); const modes = source("src/lib/app-modes.ts"); const therapies = source("src/lib/therapies.ts"); - expect(layout).toContain('isAppModeVisible("therapy-compass", "production")'); - expect(layout).toContain("notFound()"); - expect(modes).toMatch(/id: "therapy-compass"[\s\S]*?devOnly: true/); - expect(therapies).toContain('environment === "production"'); - expect(therapies).toContain("therapyNeedsReview(record)"); + // No environment gate may reappear on the route or the catalogue. + expect(layout).not.toContain("notFound()"); + expect(modes).not.toMatch(/id: "therapy-compass"[\s\S]*?devOnly: true/); + expect(therapies).not.toContain('environment === "production"'); + // Review status must survive as a label, not be deleted along with the gate. + expect(therapies).toContain("export function therapyNeedsReview"); + }); + + it("keeps the catalogue-wide review notice on the Therapy library, above the hero", () => { + const notice = source("src/components/therapy-compass/therapy-review-notice.tsx"); + const home = source("src/components/therapy-compass/screens/home-screen.tsx"); + + expect(notice).toContain('role="note"'); + expect(notice).toContain("THERAPY_CATALOGUE_SUMMARY.needsReviewCount"); + expect(notice).toContain("No therapy record in this library has completed clinician review yet."); + // Non-interactive: a caveat the reader can dismiss is not a caveat. + expect(notice).not.toContain("