diff --git a/docs/clinical-badge-system-guide.md b/docs/clinical-badge-system-guide.md index 63a6b5b602..c57ca45ece 100644 --- a/docs/clinical-badge-system-guide.md +++ b/docs/clinical-badge-system-guide.md @@ -342,17 +342,17 @@ Search match examples: Answer badges should clarify grounding and evidence strength. -| Evidence state | Tone | -| -------------------------------------------------- | ------- | -| Direct source-backed support | Success | -| Strong source | Success | -| Partial support | Warning | -| Nearby only | Warning | -| No direct support where direct support is required | Danger | -| Source current | Success | -| Source review due | Warning | -| Source outdated | Danger | -| Page/source metadata | Neutral | +| Evidence state | Tone | +| -------------------------------------------------- | -------- | +| Direct source-backed support | Success | +| Strong source | Success | +| Partial support | Warning | +| Nearby only | Warning | +| No direct support where direct support is required | Danger | +| Source current | No badge | +| Source review due | Warning | +| Source outdated | Danger | +| Page/source metadata | Neutral | Do not use badges to decorate answer prose. Use them at the answer header, source rows, evidence panels, and compact provenance areas. @@ -369,13 +369,30 @@ Document labels classify the document. UI badges render only selected labels or | Manual override | Info | | Needs review | Warning | | Ambiguous site | Warning | -| Current source | Success | +| Current source | No badge (see the note below) | | Review due | Warning | | Outdated source | Danger | | Processing/indexing | Info | | Failed ingestion | Danger | | Indexed/completed | Success | +**A source that is simply current gets no badge.** Two reasons, and the second is the one +that keeps getting undone: + +1. Green endorses. A freshness signal only records when a source was last looked at; nothing + in the pipeline verifies that its content is still correct. Success tone reads as that + stronger claim, and re-toning it down to neutral or info does not fix the second problem. +2. An always-on chip is not free. Clusters render with a `limit` and drop the overflow into a + plain, non-interactive `+N` chip, so a badge for the unremarkable case does not add a row — + it evicts a badge that carries information. Measured on the medication identity cluster + (limit 5, 330 snapshot records): a "Source checked …" chip whose text was identical for 327 + of them displaced the Poisons Schedule on 122 records and the TGA/OFF indication tag on 77. + +Badge the deficiencies instead — review due, source date unknown, no sources recorded, +superseded — and render a healthy record's last-checked date as text where its sources are +listed. `src/lib/medication-badges.ts` and `tests/medication-identity-badge-cluster.dom.test.tsx` +hold the worked example. + Limit visible tags. Use show-more behaviour for document tag clouds. ## Admin And Ingestion Rules diff --git a/src/app/(search-app)/medications/[slug]/page.tsx b/src/app/(search-app)/medications/[slug]/page.tsx index 028f8ab0a0..65bc1bd48d 100644 --- a/src/app/(search-app)/medications/[slug]/page.tsx +++ b/src/app/(search-app)/medications/[slug]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { MedicationRecordPage } from "@/components/clinical-dashboard/medication-record-page"; -import { deriveGovernanceFromSections } from "@/lib/medication-records"; +import { deriveMedicationSourceGovernance } from "@/lib/medication-records"; import { getMedicationRecord, loadMedicationSnapshot } from "@/lib/medication-snapshot"; type MedicationPageProps = { @@ -40,11 +40,16 @@ export default async function MedicationPage({ params }: MedicationPageProps) { const record = getMedicationRecord(slug); const fallbackGovernance = record ? (() => { - const derived = deriveGovernanceFromSections(record); + const derived = deriveMedicationSourceGovernance(record.sections); // Validation/review status is a governance decision that must come from // the live/authoritative response, not a hard-coded guess used only for // the pre-fetch content-first paint. - return { sourceStatus: derived.source_status, validationStatus: "unverified" as const }; + return { + sourceStatus: derived.sourceStatus, + sourceCheckedAt: derived.sourceCheckedAt, + sourcesRecorded: derived.sourcesRecorded, + validationStatus: "unverified" as const, + }; })() : undefined; diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index 3e248b27d7..048f3a282f 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -12,9 +12,9 @@ import { getMedicationRecord } from "@/lib/medication-snapshot"; import { ensureMedicationsSeeded } from "@/lib/medication-seed"; import { safeErrorLogDetails } from "@/lib/privacy"; import { - deriveGovernanceFromSections, + publicMedicationGovernance, normalizeMedicationSlug, - rowGovernance, + rowGovernanceForRecord, rowToMedicationRecord, type MedicationRecordRow, } from "@/lib/medication-records"; @@ -41,13 +41,9 @@ function notFoundResponse(slug: string) { function publicMedicationDetailPayload(slug: string) { const record = getMedicationRecord(slug); if (!record) return null; - const governance = deriveGovernanceFromSections(record); return { record, - governance: { - sourceStatus: governance.source_status, - validationStatus: governance.validation_status, - }, + governance: publicMedicationGovernance(record), }; } @@ -128,9 +124,12 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s } if (!row) return notFoundResponse(normalizedSlug); + // Derive governance from the record just parsed rather than re-parsing the row's + // `sections` behind `rowGovernance`; same answer, one Zod pass instead of two. + const record = rowToMedicationRecord(row); return medicationResponse({ - record: rowToMedicationRecord(row), - governance: rowGovernance(row), + record, + governance: rowGovernanceForRecord(row, record), }); } catch (error) { if (error instanceof AuthenticationError) { diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index c7f759d962..5e1b87490c 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -11,7 +11,7 @@ import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { medicationAliasesForEntity } from "@/lib/medication-entities"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; -import { deriveGovernanceFromSections, rowGovernance, rowToMedicationRecord } from "@/lib/medication-records"; +import { publicMedicationGovernance, rowGovernanceForRecord, rowToMedicationRecord } from "@/lib/medication-records"; import { medicationCatalogInterpretation, searchMedicationCatalog } from "@/lib/medication-query"; import { medicationBrandNames, @@ -125,22 +125,12 @@ function matchesPayload(matches: MedicationSearchMatch[], rankingOnly = false, q // `loadMedicationSnapshot`, so caching these two derivations introduces no // aliasing the route did not already have. Ranking still runs per query. function buildPublicGovernance(records: MedicationRecord[]) { - return Object.fromEntries( - records.map((record) => { - const governance = deriveGovernanceFromSections(record); - return [ - record.slug, - { - sourceStatus: governance.source_status, - validationStatus: governance.validation_status, - }, - ]; - }), - ); + return Object.fromEntries(records.map((record) => [record.slug, publicMedicationGovernance(record)])); } let cachedPublicIndexRecords: MedicationRecord[] | null = null; let cachedPublicGovernance: ReturnType | null = null; +let cachedPublicGovernanceDay: string | null = null; function publicIndexRecords() { cachedPublicIndexRecords ??= toIndexRecords(defaultMedicationRecords()); @@ -149,7 +139,25 @@ function publicIndexRecords() { function publicGovernance(records: MedicationRecord[]) { // Slugs are identical for the full and index projections, so one map serves both. - cachedPublicGovernance ??= buildPublicGovernance(records); + // + // Keyed by UTC day, not cached outright. Source freshness is a function of the + // reading clock, so a lifetime cache would re-freeze exactly what this module + // stopped freezing: a process that started before a record aged out would keep + // serving the pre-ageing status until it happened to restart. A day is the + // finest granularity the status can actually change at, so this preserves the + // per-request-mapping saving the latency audit bought while keeping the answer + // honest across a date boundary. + // + // This is the tightest link, not the only one: anonymous responses go out with + // `public, max-age=300, s-maxage=3600, stale-while-revalidate=86400` + // (`src/lib/fixture-response-cache.ts`), so a CDN may keep serving a day-old + // governance map for about 25 h past the flip. Immaterial against a 365-day review + // interval, but do not read the day key as a same-day guarantee at the edge. + const today = new Date().toISOString().slice(0, 10); + if (cachedPublicGovernance === null || cachedPublicGovernanceDay !== today) { + cachedPublicGovernance = buildPublicGovernance(records); + cachedPublicGovernanceDay = today; + } return cachedPublicGovernance; } @@ -212,7 +220,14 @@ export async function GET(request: Request) { const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS); const fullRecords = rows.map(rowToMedicationRecord); const records = fields === "index" ? toIndexRecords(fullRecords) : fullRecords; - const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])); + // Governance is derived from `fullRecords`, not re-read from the rows: `rowGovernance` + // would Zod-parse the identical `sections` payload a second time, which measured about + // 6 ms per 330 rows on top of the 7 ms `rowToMedicationRecord` already spends — roughly + // 9 ms of wasted synchronous event-loop time per request at the MEDICATION_MAX_RECORDS + // cap, for an answer already in hand (latency audit 2026-07-28, L2-9). + const governanceBySlug = Object.fromEntries( + rows.map((row, index) => [row.slug, rowGovernanceForRecord(row, fullRecords[index]!)]), + ); const ranked = q ? rankCatalogMatches(fullRecords, q, limit, fields === "index") : undefined; return medicationResponse({ diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 8c52cfa410..e9734f91a8 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -24,7 +24,10 @@ type MedicationCatalogResponse = { matches?: MedicationCatalogMatch[]; interpretation?: MedicationCatalogInterpretation; total: number; - governance?: Record; + governance?: Record< + string, + { sourceStatus: string; validationStatus: string; sourceCheckedAt?: string | null; sourcesRecorded?: boolean } + >; demoMode?: boolean; }; @@ -33,6 +36,8 @@ type MedicationDetailResponse = { governance?: { sourceStatus: string; validationStatus: string; + sourceCheckedAt?: string | null; + sourcesRecorded?: boolean; }; demoMode?: boolean; }; diff --git a/src/lib/medication-badges.ts b/src/lib/medication-badges.ts index 04bedef95b..b91724f2ae 100644 --- a/src/lib/medication-badges.ts +++ b/src/lib/medication-badges.ts @@ -9,6 +9,19 @@ import type { export type MedicationGovernance = { sourceStatus?: string; validationStatus?: string; + /** + * ISO calendar date (yyyy-mm-dd) the record's sources were last checked against + * the publisher, when that is known. Absent or null means the date could not be + * read, which is reported as such rather than smoothed over. + */ + sourceCheckedAt?: string | null; + /** + * Whether the record carries any source text at all. Absent means the caller did + * not derive it, and the badge then makes the weaker of the two claims — asserting + * "no sources recorded" about a record whose sources were simply never inspected + * would be a fabrication, where "date unknown" is merely incomplete. + */ + sourcesRecorded?: boolean; }; export type MedicationBadge = { @@ -217,6 +230,88 @@ function textHeuristicBadges( } } +const SOURCE_CHECK_MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] as const; + +// Formatted from the ISO parts by hand rather than through `toLocaleDateString`, so +// the label a server render produces is byte-identical to the one the browser +// hydrates — no locale or timezone can shift the month by a day. +function sourceCheckedMonthLabel(sourceCheckedAt?: string | null): string | null { + if (!sourceCheckedAt) return null; + const match = /^(\d{4})-(0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/.exec(sourceCheckedAt); + if (!match) return null; + return `${SOURCE_CHECK_MONTHS[Number(match[2]) - 1]} ${match[1]}`; +} + +/** + * Source-freshness badge, or `null` where there is nothing worth a chip. + * + * A DEFICIENCY badge, not a status readout. `current` gets no badge at all: it is the + * unremarkable case, its text was identical for 327 of the 330 snapshot records, and + * the identity cluster is already saturated. Every one of those records produces at + * least five badges and the detail hero renders `limit={5}`, so an always-on chip does + * not add a row — it evicts one, and the tone ordering made it evict prescribing + * information: measured at a 2026-09-02 reference date, 201 of 330 records lost their + * Poisons Schedule (122) or TGA/OFF indication tag (77) chip to a "Source checked …" + * chip carrying almost no information. A record showing neither `TGA` nor `OFF` reads + * as unknown regulatory status rather than approved, and the overflow `+N` chip is + * plain text with no tooltip, so what it swallows is genuinely unreachable. Re-toning + * `current` does not help; the cluster is full either way. Surface a last-checked date + * for a healthy record as text, never as a chip. + * + * What DOES earn a chip is a deficiency the reader cannot otherwise see: sources that + * are due a re-check, sources whose date could not be read, no recorded sources at all, + * or a recorded supersession. Those never look identical to a record checked last month + * again, which is the whole point of the status. + * + * The wording describes when the sources were last checked against the publisher and + * stops there. It never says the entry was "checked" unqualified — that reads as a + * claim that someone validated the content, which is the same conflation `isReviewed` + * above exists to prevent. + */ +function sourceFreshnessBadge(governance: MedicationGovernance): MedicationBadge | null { + const checkedMonth = sourceCheckedMonthLabel(governance.sourceCheckedAt); + + if (governance.sourceStatus === "current") { + return null; + } + if (governance.sourceStatus === "review_due") { + // The actionable half leads, because the chip truncates from the end. + return { + id: "identity-source-review-due", + label: checkedMonth ? `Source check due — sources last checked ${checkedMonth}` : "Source check due", + tone: "warning", + }; + } + if (governance.sourceStatus === "outdated") { + // Dormant by design: nothing derives `outdated` today. Age alone cannot establish + // that guidance has been superseded — that is a clinical judgement nobody has + // recorded — so this branch waits for a recorded-supersession flow instead of + // being inferred from a second, longer age threshold. + return { id: "identity-source-superseded", label: "Source superseded", tone: "danger" }; + } + // Anything else — an explicit `unknown`, a missing status, or a value this build does + // not recognise — degrades to a visible warning rather than to silence. Split by which + // deficiency it actually is: a record with no `src` section whatsoever is worse off + // than one whose date merely would not parse, and saying so costs nothing. + if (governance.sourcesRecorded === false) { + return { id: "identity-source-none", label: "No sources recorded", tone: "warning" }; + } + return { id: "identity-source-unknown", label: "Source date unknown", tone: "warning" }; +} + export function medicationIdentityBadges( record: MedicationRecord, governance?: MedicationGovernance, @@ -267,10 +362,11 @@ export function medicationIdentityBadges( pushBadge(badges, { id: "identity-reviewed", label: "Reviewed", tone: "success" }); } - if (governance?.sourceStatus === "review_due") { - pushBadge(badges, { id: "identity-review-due", label: "Review due", tone: "warning" }); - } else if (governance?.sourceStatus === "outdated") { - pushBadge(badges, { id: "identity-outdated", label: "Outdated", tone: "danger" }); + if (governance) { + const sourceBadge = sourceFreshnessBadge(governance); + if (sourceBadge) { + pushBadge(badges, sourceBadge); + } } return sortBadgesByPriority(dedupeBadges(badges)); diff --git a/src/lib/medication-records.ts b/src/lib/medication-records.ts index 8a9c85c7f1..bf8a4e8e48 100644 --- a/src/lib/medication-records.ts +++ b/src/lib/medication-records.ts @@ -26,26 +26,46 @@ export function medicationValidationStatus(value: string | null | undefined): Me const REVIEW_INTERVAL_DAYS = 365; +// A source date ahead of the reading clock cannot describe a check that has already +// happened. One day of slack absorbs the ordinary timezone gap between the machine +// that wrote the entry (Perth, UTC+8) and the machine reading it in UTC; anything +// further ahead is a typo or a bad import and must not be trusted as a check date. +const FUTURE_DATE_TOLERANCE_DAYS = 1; + export function parseSourceDate(text: string): Date | null { if (/\b(?:not\s+checked|unchecked|unverified)\b/i.test(text)) { return null; } - const match = text.match(/\b(20\d{2})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/); - if (!match) return null; - const parsed = new Date(`${match[0]}T00:00:00.000Z`); - if (Number.isNaN(parsed.getTime())) return null; - // The Date constructor silently normalizes impossible calendar dates (e.g. 2026-02-29 - // in a non-leap year rolls forward to March 1) instead of rejecting them. Confirm the - // parsed UTC components exactly match what was matched before trusting the result. - const [, yearText, monthText, dayText] = match; - if ( - parsed.getUTCFullYear() !== Number(yearText) || - parsed.getUTCMonth() + 1 !== Number(monthText) || - parsed.getUTCDate() !== Number(dayText) - ) { - return null; + + // A source block can carry several dates — one per cited publication. The only + // freshness a record can honestly claim is that of its OLDEST source: taking the + // first (or newest) match lets one recently re-checked line vouch for every other + // source beside it, which is the optimistic direction this module must never take. + let oldest: Date | null = null; + // Declared inline: a shared /g regex carries `lastIndex` state, and one future + // `.test()`/`.exec()` call against it elsewhere would silently start skipping dates. + for (const match of text.matchAll(/\b(20\d{2})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b/g)) { + const parsed = new Date(`${match[0]}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime())) return null; + // The Date constructor silently normalizes impossible calendar dates (e.g. 2026-02-29 + // in a non-leap year rolls forward to March 1) instead of rejecting them. Confirm the + // parsed UTC components exactly match what was matched before trusting the result. + const [, yearText, monthText, dayText] = match; + if ( + parsed.getUTCFullYear() !== Number(yearText) || + parsed.getUTCMonth() + 1 !== Number(monthText) || + parsed.getUTCDate() !== Number(dayText) + ) { + // An unreadable date anywhere in the block makes the whole block untrustworthy. + // Skipping it and reporting one of its neighbours would present a date the source + // text does not actually say, so the entire record degrades to "unknown" instead. + return null; + } + if (!oldest || parsed.getTime() < oldest.getTime()) { + oldest = parsed; + } } - return parsed; + return oldest; } export function evaluateSourceStatus( @@ -56,12 +76,57 @@ export function evaluateSourceStatus( if (!checkedDate) return "unknown"; const diffMs = referenceDate.getTime() - checkedDate.getTime(); const diffDays = diffMs / (1000 * 60 * 60 * 24); + // A future date produces a large negative age, which passes the interval test below + // and would read as freshly checked forever — a "2126" typo would never age out. + // Treat it as an unusable date rather than a fresh one. + if (diffDays < -FUTURE_DATE_TOLERANCE_DAYS) { + return "unknown"; + } if (diffDays <= reviewIntervalDays) { return "current"; } return "review_due"; } +export type MedicationSourceGovernance = { + sourceStatus: MedicationSourceStatus; + /** + * ISO calendar date (yyyy-mm-dd) the sources were last checked, when that is + * known. Null whenever the status is `unknown`, so no caller can render a + * "checked on" date the derivation itself does not stand behind. + */ + sourceCheckedAt: string | null; + /** + * Whether the record carries any source text at all. `unknown` covers two + * materially different deficiencies — a source block whose date could not be + * read, and no recorded sources whatsoever — and a prescribing tool must not + * present the second as the first. Three snapshot records (alimemazine, + * edoxaban, levomepromazine) carry no `src` section at all. + */ + sourcesRecorded: boolean; +}; + +/** + * Derive source freshness from a record's own `src` section. This is the single + * implementation for both the write path and the read path — see `rowGovernance` + * for why the read path must never trust a stored status column. + */ +export function deriveMedicationSourceGovernance( + sections: MedicationRecord["sections"], + referenceDate: Date = new Date(), +): MedicationSourceGovernance { + const sourceSection = sections.find((section) => section.type === "src"); + const sourceText = sourceSection?.rows.map((row) => row.val).join(" ") ?? ""; + const parsedDate = parseSourceDate(sourceText); + const sourceStatus = evaluateSourceStatus(parsedDate, referenceDate); + return { + sourceStatus, + sourceCheckedAt: parsedDate && sourceStatus !== "unknown" ? parsedDate.toISOString().slice(0, 10) : null, + // A `src` section holding only empty rows records no more than a missing one. + sourcesRecorded: sourceText.trim().length > 0, + }; +} + export function deriveGovernanceFromSections( record: MedicationRecord, referenceDate: Date = new Date(), @@ -69,10 +134,7 @@ export function deriveGovernanceFromSections( source_status: MedicationSourceStatus; validation_status: MedicationValidationStatus; } { - const sourceSection = record.sections.find((section) => section.type === "src"); - const sourceText = sourceSection?.rows.map((row) => row.val).join(" ") ?? ""; - const parsedDate = parseSourceDate(sourceText); - const sourceStatus: MedicationSourceStatus = evaluateSourceStatus(parsedDate, referenceDate); + const { sourceStatus } = deriveMedicationSourceGovernance(record.sections, referenceDate); return { source_status: sourceStatus, // Derived records carry no evidence of clinical review, so they must not claim it. @@ -168,16 +230,91 @@ export function rowToMedicationRecord(row: MedicationRecordRow): MedicationRecor }; } -export function rowGovernance(row: MedicationRecordRow): { +/** + * Read-time governance for a stored medication row. + * + * Source freshness is RE-DERIVED here from the row's own `sections`, never read back + * from the stored `source_status` column. That column is written once, by + * `recordToRow` at insert time, and then never ages: a row written while its sources + * were fresh keeps claiming `current` indefinitely. Only the snapshot/demo path + * re-derived per request, so ageing worked in exactly the environment that has no + * patients and never worked against the live database. + * + * The column stays in place (it is applied migration history and is still what the + * write path stores); it simply stops being the answer this function returns. + */ +export function rowGovernance(row: MedicationRecordRow, referenceDate: Date = new Date()): MedicationRowGovernance { + return governanceForSections(row, parseMedicationJsonbArray(medicationSectionsSchema, row.sections), referenceDate); +} + +/** + * The same read-time governance for a caller that has ALREADY parsed the row into a + * `MedicationRecord`. The list route maps every row twice — once to a record, once to + * governance — and Zod-parsing the identical `sections` payload a second time cost about + * 6 ms per 330 rows, roughly 9 ms of synchronous event-loop time per request at the + * `MEDICATION_MAX_RECORDS` cap. `rowGovernance` above keeps its fail-closed row-only + * behaviour for callers that hold nothing but a row. + */ +export function rowGovernanceForRecord( + row: MedicationRecordRow, + record: MedicationRecord, + referenceDate: Date = new Date(), +): MedicationRowGovernance { + return governanceForSections(row, record.sections, referenceDate); +} + +type MedicationRowGovernance = { sourceStatus: MedicationSourceStatus; validationStatus: MedicationValidationStatus; + sourceCheckedAt: string | null; + sourcesRecorded: boolean; lastReviewedAt: string | null; reviewDueAt: string | null; -} { +}; + +function governanceForSections( + row: MedicationRecordRow, + sections: MedicationRecord["sections"], + referenceDate: Date, +): MedicationRowGovernance { + const storedStatus = medicationSourceStatus(row.source_status); + const derived = deriveMedicationSourceGovernance(sections, referenceDate); + // `outdated` asserts that the guidance has been superseded. That is a recorded + // clinical judgement, not something age can establish or refute, so a stored + // `outdated` survives re-derivation rather than being quietly downgraded. Every + // other stored value is only ever a frozen age calculation, which the fresh + // derivation replaces. + const sourceStatus = storedStatus === "outdated" ? "outdated" : derived.sourceStatus; return { - sourceStatus: medicationSourceStatus(row.source_status), + sourceStatus, validationStatus: medicationValidationStatus(row.validation_status), + sourceCheckedAt: derived.sourceCheckedAt, + sourcesRecorded: derived.sourcesRecorded, lastReviewedAt: row.last_reviewed_at, reviewDueAt: row.review_due_at, }; } + +/** + * Governance for a record served straight from the curated snapshot (demo mode and + * the anonymous public payload), shaped exactly like the camelCase governance the + * API returns for owner rows so the client renders one thing, not two. + */ +export function publicMedicationGovernance( + record: MedicationRecord, + referenceDate: Date = new Date(), +): { + sourceStatus: MedicationSourceStatus; + validationStatus: MedicationValidationStatus; + sourceCheckedAt: string | null; + sourcesRecorded: boolean; +} { + const columns = deriveGovernanceFromSections(record, referenceDate); + const derived = deriveMedicationSourceGovernance(record.sections, referenceDate); + return { + sourceStatus: columns.source_status, + validationStatus: columns.validation_status, + sourceCheckedAt: derived.sourceCheckedAt, + sourcesRecorded: derived.sourcesRecorded, + }; +} diff --git a/tests/medication-badges.test.ts b/tests/medication-badges.test.ts index 74b4a0ab4c..e8d5f90043 100644 --- a/tests/medication-badges.test.ts +++ b/tests/medication-badges.test.ts @@ -7,7 +7,12 @@ import { medicationRowBadges, medicationStatTone, } from "@/lib/medication-badges"; -import { deriveGovernanceFromSections, evaluateSourceStatus, parseSourceDate } from "@/lib/medication-records"; +import { + deriveGovernanceFromSections, + deriveMedicationSourceGovernance, + evaluateSourceStatus, + parseSourceDate, +} from "@/lib/medication-records"; import type { MedicationRecord } from "@/lib/medications"; describe("medication badge mappers", () => { @@ -225,6 +230,50 @@ describe("medication governance date evaluation", () => { expect(parseSourceDate("checked 2026-02-29 for this entry")).toBeNull(); }); + it("reports the OLDEST date in a multi-source block, not the first one it finds", () => { + // A source section can cite several publications. Reporting the newest (or simply + // the first) lets one recently re-checked line vouch for every stale source beside + // it. No snapshot record carries two distinct dates today, so this pins intent. + expect(parseSourceDate("TGA PI 2026-05-14; RANZCP guideline 2021-03-02")).toEqual( + new Date("2021-03-02T00:00:00.000Z"), + ); + expect(parseSourceDate("RANZCP guideline 2021-03-02; TGA PI 2026-05-14")).toEqual( + new Date("2021-03-02T00:00:00.000Z"), + ); + }); + + it("rejects the whole block when any date in it is an impossible calendar date", () => { + // Reporting the surviving neighbour would present a date the source text does not + // say. An unreadable date anywhere makes the block untrustworthy. + expect(parseSourceDate("checked 2026-02-29 and 2020-01-01")).toBeNull(); + }); + + it("treats a future source date as unknown rather than permanently current", () => { + const refDate = new Date("2026-08-26T00:00:00.000Z"); + // A "2126" typo gives a large negative age, which passes the interval test and + // would read as freshly checked forever. + expect(evaluateSourceStatus(new Date("2126-05-14T00:00:00.000Z"), refDate, 365)).toBe("unknown"); + expect(evaluateSourceStatus(new Date("2026-09-30T00:00:00.000Z"), refDate, 365)).toBe("unknown"); + // One day of timezone slack (Perth is UTC+8) still counts as checked. + expect(evaluateSourceStatus(new Date("2026-08-26T18:00:00.000Z"), refDate, 365)).toBe("current"); + }); + + it("withholds the checked-on date whenever the status is unknown", () => { + const refDate = new Date("2026-08-26T00:00:00.000Z"); + const sections = [{ title: "Sources", type: "src", rows: [{ key: "Source Review", val: "checked 2126-05-14" }] }]; + const derived = deriveMedicationSourceGovernance(sections, refDate); + expect(derived.sourceStatus).toBe("unknown"); + expect(derived.sourceCheckedAt).toBeNull(); + }); + + it("exposes the parsed check date as an ISO calendar day", () => { + const refDate = new Date("2026-08-26T00:00:00.000Z"); + const sections = [{ title: "Sources", type: "src", rows: [{ key: "Source Review", val: "checked 2026-05-14" }] }]; + const derived = deriveMedicationSourceGovernance(sections, refDate); + expect(derived.sourceStatus).toBe("current"); + expect(derived.sourceCheckedAt).toBe("2026-05-14"); + }); + it("evaluates governance status based on review interval", () => { const refDate = new Date("2026-08-26T00:00:00.000Z"); const freshDate = new Date("2026-06-30T00:00:00.000Z"); @@ -260,3 +309,190 @@ describe("medication governance date evaluation", () => { expect(governance.source_status).toBe("review_due"); }); }); + +describe("medication source-freshness badges", () => { + const baseRecord: MedicationRecord = { + slug: "test-med", + name: "Test Med", + class: "", + subclass: "", + category: "", + accent: "#0f766e", + tag: "", + schedule: "", + stats: [], + sections: [], + quick: [], + }; + + // Every source-freshness badge shares the `identity-source-` id prefix, so a + // record either carries exactly one of them or carries none. + function sourceBadgeOf(record: MedicationRecord, governance: Parameters[1]) { + const matches = medicationIdentityBadges(record, governance).filter((badge) => + badge.id.startsWith("identity-source-"), + ); + expect(matches.length).toBeLessThanOrEqual(1); + return matches[0]; + } + + function labelFor(governance: Parameters[1]) { + return sourceBadgeOf(baseRecord, governance); + } + + it("badges nothing at all when the sources are within the review interval", () => { + // The healthy case is the unremarkable one, and the cluster it would join is + // already full: badging it evicts a chip that carries prescribing information + // (see the eviction test below). The last-checked date still reads as text in + // the record's own Sources section, which is where a date belongs. + expect(labelFor({ sourceStatus: "current", validationStatus: "unverified", sourceCheckedAt: "2026-05-14" })).toBe( + undefined, + ); + expect(labelFor({ sourceStatus: "current", validationStatus: "unverified", sourceCheckedAt: null })).toBe( + undefined, + ); + }); + + it("names the last check date on a review-due record instead of saying it is out of date", () => { + const badge = labelFor({ + sourceStatus: "review_due", + validationStatus: "unverified", + sourceCheckedAt: "2026-05-14", + }); + // "Sources last checked" is a recency statement and nothing else. A bare + // "checked" reads as a claim that someone validated the entry — the exact + // conflation the `Reviewed` badge already had to be rescued from. + expect(badge?.label).toBe("Source check due — sources last checked May 2026"); + expect(badge?.tone).toBe("warning"); + }); + + it("renders an unreadable source date as a visible warning, never as silence", () => { + // The whole point of the status: a record whose freshness could not be read must + // not be visually identical to one checked last month. + const badge = labelFor({ + sourceStatus: "unknown", + validationStatus: "unverified", + sourceCheckedAt: null, + sourcesRecorded: true, + }); + expect(badge?.label).toBe("Source date unknown"); + expect(badge?.tone).toBe("warning"); + }); + + it("says so plainly when a record has no recorded sources at all", () => { + // A bigger deficiency than an unparseable date, and a different one: nothing was + // ever cited. Reporting it as "date unknown" would understate it. + const badge = labelFor({ + sourceStatus: "unknown", + validationStatus: "unverified", + sourceCheckedAt: null, + sourcesRecorded: false, + }); + expect(badge?.label).toBe("No sources recorded"); + expect(badge?.tone).toBe("warning"); + }); + + it("makes the weaker claim when the caller never derived whether sources exist", () => { + // Absent `sourcesRecorded` means nobody looked. Asserting "no sources recorded" + // from that would be a fabrication; "date unknown" is merely incomplete. + expect(labelFor({ sourceStatus: "unknown", validationStatus: "unverified" })?.label).toBe("Source date unknown"); + }); + + it("degrades a missing or unrecognised source status to the unknown warning", () => { + expect(labelFor({ validationStatus: "unverified" })?.label).toBe("Source date unknown"); + expect(labelFor({ sourceStatus: "not-a-status", validationStatus: "unverified" })?.label).toBe( + "Source date unknown", + ); + }); + + it("keeps the dormant superseded badge reachable for a recorded supersession", () => { + // Nothing derives `outdated` from age — that is a clinical judgement nobody has + // made — but the branch stays wired for the future supersession flow. + const badge = labelFor({ sourceStatus: "outdated", validationStatus: "unverified", sourceCheckedAt: null }); + expect(badge?.label).toBe("Source superseded"); + expect(badge?.tone).toBe("danger"); + }); + + it("falls back to an undated label when the status is known but the date is not", () => { + expect(labelFor({ sourceStatus: "review_due", validationStatus: "unverified" })?.label).toBe("Source check due"); + expect( + labelFor({ sourceStatus: "review_due", validationStatus: "unverified", sourceCheckedAt: "not-a-date" })?.label, + ).toBe("Source check due"); + }); + + it("adds no source badge when the caller supplies no governance at all", () => { + // Cross-mode link chips render badges without ever fetching governance; they must + // not sprout a warning for a status nobody asked about. + const badges = medicationIdentityBadges(baseRecord); + expect(badges.some((badge) => badge.id.startsWith("identity-source-"))).toBe(false); + }); + + it("badges every snapshot record that has a source deficiency, and only those", () => { + const records = loadMedicationSnapshot(); + const refDate = new Date("2026-09-02T00:00:00.000Z"); + const flagged: string[] = []; + + for (const record of records) { + const derived = deriveMedicationSourceGovernance(record.sections, refDate); + const badge = sourceBadgeOf(record, { + sourceStatus: derived.sourceStatus, + validationStatus: "unverified", + sourceCheckedAt: derived.sourceCheckedAt, + sourcesRecorded: derived.sourcesRecorded, + }); + if (derived.sourceStatus === "current") { + expect(badge, `${record.slug} is within the review interval and needs no chip`).toBeUndefined(); + } else { + expect(badge, `${record.slug} has status ${derived.sourceStatus} and must be badged`).toBeTruthy(); + expect(badge?.tone).toBe("warning"); + flagged.push(record.slug); + } + } + + // The three records carrying no `src` section at all. They must read as a + // recorded-sources deficiency, not as a date that would not parse. + expect(flagged.sort()).toEqual(["alimemazine", "edoxaban", "levomepromazine"]); + for (const slug of flagged) { + const record = getMedicationRecord(slug); + expect(record, `${slug} fixture missing`).toBeTruthy(); + const derived = deriveMedicationSourceGovernance(record!.sections, refDate); + expect(derived.sourcesRecorded).toBe(false); + expect( + sourceBadgeOf(record!, { + sourceStatus: derived.sourceStatus, + validationStatus: "unverified", + sourceCheckedAt: derived.sourceCheckedAt, + sourcesRecorded: derived.sourcesRecorded, + })?.label, + ).toBe("No sources recorded"); + } + }); + + it("never costs a healthy record one of its identity badges", () => { + // The regression this guards: an always-on freshness chip sorted ABOVE the + // Poisons Schedule (info) and the TGA/OFF indication tag (info), and every + // snapshot record already produces at least five badges against a hero cluster + // rendered at limit 5. Measured at this reference date, 201 of 330 records lost + // their schedule (122) or TGA tag (77) to a chip whose text was identical for + // 327 of them. `tests/medication-identity-badge-cluster.dom.test.tsx` proves the + // same thing through the real component; this pins it across the whole corpus. + const refDate = new Date("2026-09-02T00:00:00.000Z"); + const displaced: string[] = []; + + for (const record of loadMedicationSnapshot()) { + const derived = deriveMedicationSourceGovernance(record.sections, refDate); + if (derived.sourceStatus !== "current") continue; + const withGovernance = medicationIdentityBadges(record, { + sourceStatus: derived.sourceStatus, + validationStatus: "unverified", + sourceCheckedAt: derived.sourceCheckedAt, + sourcesRecorded: derived.sourcesRecorded, + }); + const withoutGovernance = medicationIdentityBadges(record); + if (JSON.stringify(withGovernance) !== JSON.stringify(withoutGovernance)) { + displaced.push(record.slug); + } + } + + expect(displaced).toEqual([]); + }); +}); diff --git a/tests/medication-identity-badge-cluster.dom.test.tsx b/tests/medication-identity-badge-cluster.dom.test.tsx new file mode 100644 index 0000000000..25546de48f --- /dev/null +++ b/tests/medication-identity-badge-cluster.dom.test.tsx @@ -0,0 +1,123 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge"; +import { medicationIdentityBadges, type MedicationBadge } from "@/lib/medication-badges"; +import { deriveMedicationSourceGovernance } from "@/lib/medication-records"; +import { getMedicationRecord, loadMedicationSnapshot } from "@/lib/medication-snapshot"; +import type { MedicationRecord } from "@/lib/medications"; + +// What survives the medication hero's badge cluster, proved through the real +// component rather than through the badge array. +// +// Nothing asserted this before, which is how an always-on source-freshness chip +// shipped without anyone noticing that the cluster it joined was already full. +// `BadgeCluster` re-sorts by tone priority and slices to `limit`, and the overflow +// `+N` chip is plain, non-interactive text with no tooltip — so a badge pushed past +// the limit is not "collapsed", it is gone. On this cluster the casualties were the +// Poisons Schedule and the TGA/OFF indication tag, both `info`, both outranked by a +// `neutral` freshness chip. A record showing neither TGA nor OFF reads as unknown +// regulatory status rather than as approved. + +const HERO_BADGE_LIMIT = 5; +const REFERENCE_DATE = new Date("2026-09-02T00:00:00.000Z"); + +const recordPageSource = readFileSync( + path.resolve(process.cwd(), "src/components/clinical-dashboard/medication-record-page.tsx"), + "utf8", +); + +function governanceFor(record: MedicationRecord) { + const derived = deriveMedicationSourceGovernance(record.sections, REFERENCE_DATE); + return { + sourceStatus: derived.sourceStatus, + validationStatus: "unverified" as const, + sourceCheckedAt: derived.sourceCheckedAt, + sourcesRecorded: derived.sourcesRecorded, + }; +} + +/** + * The labels a reader can actually see, read from each chip's `title` (which is the + * label verbatim) rather than from `textContent`, because warning/danger chips also + * carry an `sr-only` tone prefix. The trailing `+N` overflow chip is included on + * purpose: it is what the cluster shows INSTEAD of the badges it dropped, so a count + * that moves from `+3` to `+4` is itself proof that one more badge went unreachable + * even when the five visible chips happen to be unchanged. + */ +function visibleChips(badges: MedicationBadge[]): string[] { + const { container, unmount } = render(); + const chips = Array.from(container.firstElementChild?.children ?? []).map( + (element) => element.getAttribute("title") ?? "", + ); + unmount(); + return chips; +} + +describe("medication hero identity badge cluster", () => { + it("pins the hero cluster limit this file measures against", () => { + // If the hero's limit changes, these assertions stop describing the real surface. + expect(recordPageSource).toContain(` { + // Agomelatine produces exactly five identity badges, so it fits the hero cluster + // with nothing to spare — which is precisely the record an always-on freshness + // chip pushed the TGA tag out of. (Records with six or more badges lose an `info` + // chip to the limit regardless; this file's corpus test below is what covers the + // difference the source badge itself makes.) + const agomelatine = getMedicationRecord("agomelatine"); + expect(agomelatine, "agomelatine fixture missing").toBeTruthy(); + const chips = visibleChips(medicationIdentityBadges(agomelatine!, governanceFor(agomelatine!))); + + // S4 is the Poisons Schedule; TGA says the primary indication is approved. A record + // showing neither TGA nor OFF reads as unknown regulatory status, not as approved. + expect(chips).toContain("S4"); + expect(chips).toContain("TGA"); + // Nothing is dropped, so there is no overflow chip standing in for it either. + expect(chips.some((chip) => /^\+\d+$/.test(chip))).toBe(false); + // And no chip is spent restating that a healthy record is healthy. + expect(chips.filter((chip) => /source/i.test(chip))).toEqual([]); + }); + + it("displaces nothing on any snapshot record whose sources are within the review interval", () => { + const displaced: Array<{ slug: string; lost: string[] }> = []; + + for (const record of loadMedicationSnapshot()) { + const governance = governanceFor(record); + if (governance.sourceStatus !== "current") continue; + const withGovernance = new Set(visibleChips(medicationIdentityBadges(record, governance))); + const lost = visibleChips(medicationIdentityBadges(record)).filter((chip) => !withGovernance.has(chip)); + if (lost.length > 0) displaced.push({ slug: record.slug, lost }); + } + + expect(displaced).toEqual([]); + }); + + it("spends a slot only where there is a real deficiency to report", () => { + // These three carry no `src` section at all. Here the warning IS the most + // important thing about the record, so it correctly outranks an `info` chip. + for (const slug of ["alimemazine", "edoxaban", "levomepromazine"]) { + const record = getMedicationRecord(slug); + expect(record, `${slug} fixture missing`).toBeTruthy(); + expect(visibleChips(medicationIdentityBadges(record!, governanceFor(record!)))).toContain("No sources recorded"); + } + }); + + it("surfaces a review-due warning ahead of passive identity metadata", () => { + const agomelatine = getMedicationRecord("agomelatine"); + expect(agomelatine, "agomelatine fixture missing").toBeTruthy(); + const chips = visibleChips( + medicationIdentityBadges(agomelatine!, { + sourceStatus: "review_due", + validationStatus: "unverified", + sourceCheckedAt: "2026-05-14", + sourcesRecorded: true, + }), + ); + expect(chips).toContain("Source check due — sources last checked May 2026"); + }); +}); diff --git a/tests/medication-records.test.ts b/tests/medication-records.test.ts index 14ade5c88a..e4f0a2194f 100644 --- a/tests/medication-records.test.ts +++ b/tests/medication-records.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getMedicationRecord } from "@/lib/medication-snapshot"; -import { recordToRow, rowToMedicationRecord, type MedicationRecordRow } from "@/lib/medication-records"; +import { recordToRow, rowGovernance, rowToMedicationRecord, type MedicationRecordRow } from "@/lib/medication-records"; function baseRow(overrides: Partial = {}): MedicationRecordRow { return { @@ -164,3 +164,70 @@ describe("rowToMedicationRecord", () => { expect(record.quick).toEqual(snapshot.quick); }); }); + +describe("rowGovernance", () => { + const datedSections = [ + { + title: "Sources", + type: "src", + rows: [{ key: "Source Review", val: "TGA PI checked 2026-05-14" }], + }, + ]; + + it("ages the stored status from the row's own sections instead of trusting the column", () => { + // `source_status` is written once, by `recordToRow` at insert time, and never + // ages. Before this, a row inserted as `current` claimed `current` forever in + // production while demo mode — which re-derives per request — aged correctly. + const row = baseRow({ sections: datedSections, source_status: "current" }); + const governance = rowGovernance(row, new Date("2028-01-01T00:00:00.000Z")); + + expect(governance.sourceStatus).toBe("review_due"); + expect(governance.sourceCheckedAt).toBe("2026-05-14"); + }); + + it("still reports current while the stored sections are inside the review interval", () => { + const row = baseRow({ sections: datedSections, source_status: "unknown" }); + const governance = rowGovernance(row, new Date("2026-09-02T00:00:00.000Z")); + + expect(governance.sourceStatus).toBe("current"); + expect(governance.sourceCheckedAt).toBe("2026-05-14"); + }); + + it("reports unknown for a row with no source section even when the column says current", () => { + const row = baseRow({ sections: [], source_status: "current" }); + const governance = rowGovernance(row, new Date("2026-09-02T00:00:00.000Z")); + + expect(governance.sourceStatus).toBe("unknown"); + expect(governance.sourceCheckedAt).toBeNull(); + }); + + it("reports unknown for malformed sections JSONB rather than falling back to the column", () => { + const row = baseRow({ sections: { not: "an array" } as never, source_status: "current" }); + const governance = rowGovernance(row, new Date("2026-09-02T00:00:00.000Z")); + + expect(governance.sourceStatus).toBe("unknown"); + }); + + it("never downgrades a stored superseded status by re-deriving from age", () => { + // `outdated` asserts a recorded clinical judgement that guidance was superseded. + // Age can neither establish nor refute it, so re-derivation must not erase it. + const row = baseRow({ sections: datedSections, source_status: "outdated" }); + const governance = rowGovernance(row, new Date("2026-09-02T00:00:00.000Z")); + + expect(governance.sourceStatus).toBe("outdated"); + }); + + it("passes validation status and review timestamps through unchanged", () => { + const row = baseRow({ + sections: datedSections, + validation_status: "approved", + last_reviewed_at: "2026-05-14T00:00:00.000Z", + review_due_at: "2027-05-14T00:00:00.000Z", + }); + const governance = rowGovernance(row, new Date("2026-09-02T00:00:00.000Z")); + + expect(governance.validationStatus).toBe("approved"); + expect(governance.lastReviewedAt).toBe("2026-05-14T00:00:00.000Z"); + expect(governance.reviewDueAt).toBe("2027-05-14T00:00:00.000Z"); + }); +}); diff --git a/tests/medications-route.test.ts b/tests/medications-route.test.ts index 47fc011b87..e3fdc94172 100644 --- a/tests/medications-route.test.ts +++ b/tests/medications-route.test.ts @@ -221,6 +221,35 @@ describe("medications API", () => { expect(payload.governance?.acamprosate?.validationStatus).toBe("unverified"); }); + it("re-derives the public governance map instead of caching it for the process lifetime", async () => { + // The public/demo governance map is memoised so the route does not remap every + // record per request. Source freshness is a function of the reading clock, so a + // lifetime cache would re-freeze exactly what read-time derivation unfreezes: a + // long-lived process started before a record aged out would keep serving the + // pre-ageing status until it happened to restart. + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/medications/route"); + + type GovernancePayload = { + governance?: Record; + }; + + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); + const first = (await (await GET(request("/api/medications"))).json()) as GovernancePayload; + expect(first.governance?.acamprosate?.sourceStatus).toBe("current"); + + // Well past the 365-day review interval for the whole catalogue. + vi.setSystemTime(new Date("2028-09-02T00:00:00.000Z")); + const second = (await (await GET(request("/api/medications"))).json()) as GovernancePayload; + expect(second.governance?.acamprosate?.sourceStatus).toBe("review_due"); + } finally { + vi.useRealTimers(); + } + }); + it("serves an identity-only slim catalog for fields=index", async () => { const client = createSupabaseMock(); mockRuntime(client, { demoMode: true }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 555bcce16b..e0acfbb7bd 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -14,7 +14,7 @@ import { answerThreadStorageKey } from "../src/lib/answer-thread-storage"; import { documentSummaryQuestion } from "../src/lib/answer-contract"; import { demoAnswer, demoDocuments, demoSummary, getDemoDocument, getDemoDocumentPayload } from "../src/lib/demo-data"; import { formRecords } from "../src/lib/forms"; -import { deriveGovernanceFromSections } from "../src/lib/medication-records"; +import { publicMedicationGovernance } from "../src/lib/medication-records"; import { getMedicationRecord, loadMedicationSnapshot } from "../src/lib/medication-snapshot"; import { searchMedicationCatalog } from "../src/lib/medication-query"; import { medicationToSearchResult, type MedicationRecord } from "../src/lib/medications"; @@ -359,14 +359,10 @@ async function mockDemoApi(page: Page, options: MockDemoApiOptions = {}) { await route.fulfill({ status: 404, json: { error: `No medication found for "${slug}".` } }); return; } - const governance = deriveGovernanceFromSections(record); await route.fulfill({ json: { record, - governance: { - sourceStatus: governance.source_status, - validationStatus: governance.validation_status, - }, + governance: publicMedicationGovernance(record), demoMode: true, }, });