Skip to content
Open
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 number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "0ecf9856-7f74-40d4-94e4-fee50c7b4a38",
"createdOn": "2026-09-07",
"action": "add",
"payload": {
"pri": "P3",
"type": "task",
"summary": "Fifteen specifier one-of group labels are authored, not clinician-reviewed",
"detail": "singleSelectGroupLabels in src/lib/specifier-builder-diagnoses.ts decides which specifier groups the builder offers as radios. The dataset carries no exclusivity field, so the mapping is authored in this repo against the item lists. PR #2726 corrected four labels that were blocking valid combinations (Classes, Clusters, Aetiology, Attraction) and added a per-disorder exception for Specific Phobia under Type, but the remaining fifteen labels (Course, Course and Status, Current Episode, Insight, Pattern, Presentation, Prognosis, Remission, Severity, Severity (Major NCD), Severity/Course, Subtype, Subtypes, Type, Types and Severity) have only had that same authored read, not a qualified one. Consequence is now bounded rather than silent: every single-select group carries a control that reopens it as a checkbox list, so a wrong entry costs a default the clinician can override, not an unrecordable combination. Wanted: a psychiatrist confirms each of the fifteen against DSM-5-TR, and any that fail move out of the set or gain a disorder-level exception. Pinned by tests/specifier-builder-diagnoses.test.ts.",
"source": "PR #2726 (BigSimmo/Database), specifier builder one-of mapping correction",
"issueUlid": "01M1XS1ZNVFYSQGK03ER892KEZ"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "bcd23f66-16fc-47b7-8b6e-d3f2ed1569c4",
"createdOn": "2026-09-07",
"action": "add",
"payload": {
"pri": "P2",
"type": "task",
"summary": "DSM-5-TR specifier catalogue has no clinician sign-off: 585 items, 71 source-verified, 0 reviewed",
"detail": "The Specifiers mode (search, detail routes, Map, and now the Build catalogue path) serves 585 specifier items across 131 disorders from data/specifiers-search-index.json. 71 carry a verified source; every one of the 585 is still clinician-review-pending, and the dataset lastUpdated is 2026-05-09. Generated definitions are correctly withheld and PR #2726 now renders ReviewStatusBadge on each option at the point of selection, so the state is honest on screen, but the underlying review has never been done. This is a qualified-clinician task, not a code task: a psychiatrist has to read each entry against current DSM-5-TR / ICD-11 materials and record the outcome in the review fields (sourceVerificationStatus, clinicianReviewStatus) that data/specifiers-content.json already carries. Scope it in tranches by category rather than as one pass. Until it is done, the builder must keep its aide-memoire framing and must not be described as decision support.",
"source": "PR #2726 (BigSimmo/Database), specifier builder review-status work",
"issueUlid": "01M1XS1N1YZ3GZ5PDNEACPWDY8"
}
}
67 changes: 55 additions & 12 deletions src/components/specifiers/specifier-builder-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { InformationPageHeader } from "@/components/information-page-shell";
import {
CategoryTag,
ReviewStatusBadge,
SpecifierPageShell,
SpecifierSafetyNote,
specifierCard,
Expand All @@ -29,6 +30,7 @@ import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
import {
applyBuilderGroupRules,
builderCatalogGroups,
relaxBuilderGroups,
builderDiagnosisGroups,
catalogWordingSegment,
findBuilderDiagnosis,
Expand Down Expand Up @@ -127,14 +129,14 @@ function catalogStep(group: BuilderCatalogGroup, index: number): BuilderStep {
title: group.selection === "single" ? `Choose the ${label}` : `Add ${label}`,
body:
group.selection === "single"
? "Select one option, and only when it is established."
? "Select one option, and only when it is established. One-of is this builder's default for the group, not a verified manual rule, so reopen it if more than one applies."
: "Select only what the current presentation supports.",
};
}

function builderStepsFor(diagnosis: BuilderDiagnosis): BuilderStep[] {
function builderStepsFor(diagnosis: BuilderDiagnosis, catalogGroups: BuilderCatalogGroup[]): BuilderStep[] {
if (diagnosis.kind === "guided") return [baseStep, ...guidedSteps];
return [baseStep, ...builderCatalogGroups(diagnosis.id).map((group, index) => catalogStep(group, index + 1))];
return [baseStep, ...catalogGroups.map((group, index) => catalogStep(group, index + 1))];
}

function continueLabelFor(steps: BuilderStep[], index: number) {
Expand Down Expand Up @@ -256,15 +258,18 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
const [selected, setSelected] = useState<string[]>(initialState.selected);
const [diagnosisFilter, setDiagnosisFilter] = useState("");
const [copyState, setCopyState] = useState<CopyState>("idle");
// Single-select groups the clinician has reopened because more than one option
// applies. Keyed by group id, which already carries the diagnosis.
const [relaxedGroupIds, setRelaxedGroupIds] = useState<ReadonlySet<string>>(() => new Set());
const copyTimer = useRef<number | null>(null);
const focusStageHeading = useRef(false);

const diagnosis = findBuilderDiagnosis(diagnosisId) ?? guidedBuilderDiagnoses[0];
const steps = useMemo(() => builderStepsFor(diagnosis), [diagnosis]);
const catalogGroups = useMemo(
() => (diagnosis.kind === "catalog" ? builderCatalogGroups(diagnosis.id) : []),
[diagnosis],
() => (diagnosis.kind === "catalog" ? relaxBuilderGroups(builderCatalogGroups(diagnosis.id), relaxedGroupIds) : []),
[diagnosis, relaxedGroupIds],
);
const steps = useMemo(() => builderStepsFor(diagnosis, catalogGroups), [catalogGroups, diagnosis]);

const firstStepId = useMemo(() => {
const seeded = initialState.selected[0];
Expand Down Expand Up @@ -350,6 +355,8 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
function changeDiagnosis(nextId: string) {
const next = findBuilderDiagnosis(nextId);
if (!next) return;
const nextGroups =
next.kind === "catalog" ? relaxBuilderGroups(builderCatalogGroups(next.id), relaxedGroupIds) : [];
setDiagnosisId(nextId);
setSelected((current) => {
// Curated selections survive a move between mood presets when they remain
Expand All @@ -362,17 +369,16 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
});
}
if (next.kind === "catalog" && diagnosis.kind === "catalog") {
const groups = builderCatalogGroups(next.id);
const known = new Set(groups.flatMap((group) => group.items.map((item) => item.slug)));
const known = new Set(nextGroups.flatMap((group) => group.items.map((item) => item.slug)));
return applyBuilderGroupRules(
groups,
nextGroups,
current.filter((slug) => known.has(slug)),
);
}
return [];
});

const nextSteps = builderStepsFor(next);
const nextSteps = builderStepsFor(next, nextGroups);
setActiveView((current) => (nextSteps.some((step) => step.id === current) ? current : "base"));
setVisited((current) => current.filter((id) => nextSteps.some((step) => step.id === id)));
setCopyState("idle");
Expand All @@ -387,6 +393,16 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
setCopyState("idle");
}

function relaxGroup(groupId: string) {
setRelaxedGroupIds((current) => {
if (current.has(groupId)) return current;
const next = new Set(current);
next.add(groupId);
return next;
});
setCopyState("idle");
}

function chooseCatalogSingle(group: BuilderCatalogGroup, slug: string | null) {
setSelected((current) => {
const cleared = current.filter((item) => !group.items.some((candidate) => candidate.slug === item));
Expand Down Expand Up @@ -704,6 +720,7 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
type={single ? "radio" : "checkbox"}
name={single ? activeStep.group.id : undefined}
aria-label={item.label}
aria-describedby={`${item.slug}-review-status`}
checked={checked}
onChange={() =>
single ? chooseCatalogSingle(activeStep.group, item.slug) : toggle(item.slug)
Expand Down Expand Up @@ -731,8 +748,18 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
)}
</span>
<span className="min-w-0">
<span className="block text-sm font-extrabold break-words text-[color:var(--text-heading)]">
{item.label}
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-extrabold break-words text-[color:var(--text-heading)]">
{item.label}
</span>
{/* The detail and reference pages already carry this badge. Carrying it here too
means the clinician sees an item's source-review state at the point of choosing
it, not only if they open its record afterwards. The input sets aria-label, which
overrides the label's descendant text, so the status reaches assistive tech only
through the aria-describedby wired to this id. */}
<span id={`${item.slug}-review-status`}>
<ReviewStatusBadge status={item.src} />
</span>
</span>
<span className="mt-1 block text-xs font-medium leading-5 break-words text-[color:var(--text-muted)]">
Recorded for {item.disorder}. Confirm the wording against the current manual before
Expand All @@ -744,6 +771,22 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
})}
</div>
</fieldset>
{activeStep.group.selection === "single" ? (
<div className="border-t border-[color:var(--border)] px-4 py-3 sm:px-5">
<button
type="button"
onClick={() => relaxGroup(activeStep.group.id)}
data-testid="specifier-builder-relax-group"
className="min-h-12 rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-3 text-left text-xs font-bold text-[color:var(--text-heading)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
More than one of these applies
</button>
<p className="mt-2 text-xs font-medium leading-5 text-[color:var(--text-muted)]">
Reopens this group so every applicable option can be recorded. Use it when the manual allows the
combination; the one-of default is this builder&rsquo;s grouping, not a rule.
</p>
</div>
) : null}
</section>
) : null}

Expand Down
51 changes: 45 additions & 6 deletions src/lib/specifier-builder-diagnoses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,26 @@ const guidedCatalogDisorders = new Set([
* options contradict each other, and blocking a valid combination (for example
* "in sustained remission, on maintenance therapy, in a controlled environment")
* is the worse failure for a documentation aid.
*
* The dataset carries no exclusivity field — a group is only a label and a list — so
* this mapping is authored here and every entry has to earn its place against the
* items it actually governs. Four labels were removed on review because the manual
* permits the combination the radio was blocking:
* * "Classes" — the substance-class list. Concurrent alcohol, cannabis and tobacco
* use disorders are ordinary, and each is coded in its own right.
* * "Clusters" — personality-disorder clusters. Meeting criteria across clusters
* (borderline with avoidant, say) is common and is coded as both.
* * "Aetiology" — the neurocognitive list pairs a certainty term ("probable or
* possible") with the aetiology itself, so "probable Alzheimer's disease" needs
* two picks, and the delirium list has to allow more than one contributor.
* * "Attraction" — "limited to incest" is a separate axis in the manual, applied
* alongside the attraction type rather than instead of it.
*
* Even a correct entry is a default rather than a rule: the builder can relax any
* single-select group on request (see relaxBuilderGroups), so a mapping error costs a
* default, never an unreachable combination.
*/
export const singleSelectGroupLabels: ReadonlySet<string> = new Set([
"Aetiology",
"Attraction",
"Classes",
"Clusters",
"Course",
"Course & Status",
"Current Episode",
Expand All @@ -104,10 +118,35 @@ export const singleSelectGroupLabels: ReadonlySet<string> = new Set([
"Types & Severity",
]);

export function builderGroupSelection(groupLabel: string): BuilderGroupSelection {
/**
* `disorder::group` pairs where the label-wide rule above is wrong for one disorder.
* Specific phobia is the case in the current dataset: the manual asks for every
* applicable phobia type to be coded, while every other "Type" group is one-of.
*/
export const multiSelectGroupExceptions: ReadonlySet<string> = new Set(["Specific Phobia::Type"]);

export function builderGroupSelection(groupLabel: string, disorder?: string): BuilderGroupSelection {
if (disorder && multiSelectGroupExceptions.has(`${disorder}::${groupLabel}`)) return "multiple";
return singleSelectGroupLabels.has(groupLabel) ? "single" : "multiple";
}

/**
* Reopen named single-select groups as checkbox lists. This is what keeps the mapping
* above a default: when a clinician says more than one option applies, the group stops
* enforcing one-of instead of leaving the combination unreachable.
*/
export function relaxBuilderGroups(
groups: BuilderCatalogGroup[],
relaxedGroupIds: ReadonlySet<string>,
): BuilderCatalogGroup[] {
if (!relaxedGroupIds.size) return groups;
return groups.map((group) =>
group.selection === "single" && relaxedGroupIds.has(group.id)
? { ...group, selection: "multiple" as const }
: group,
);
}

function diagnosisSlug(value: string) {
return value
.toLowerCase()
Expand Down Expand Up @@ -150,7 +189,7 @@ function buildCatalog() {
group = {
id: `${id}::${diagnosisSlug(item.group)}`,
label: item.group,
selection: builderGroupSelection(item.group),
selection: builderGroupSelection(item.group, item.disorder),
items: [],
};
entry.groups.push(group);
Expand Down
58 changes: 58 additions & 0 deletions tests/specifier-builder-diagnoses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
catalogWordingSegment,
findBuilderDiagnosis,
guidedBuilderDiagnoses,
relaxBuilderGroups,
resolveInitialBuilderState,
stripSpecifierOptionList,
toggleBuilderCatalogSlug,
Expand Down Expand Up @@ -91,6 +92,51 @@ describe("specifier builder base diagnoses", () => {
expect(builderGroupSelection("Co-occurring")).toBe("multiple");
});

it("leaves a group multi-select where the manual allows the combination", () => {
// Each of these was a single-select label until review. A radio there makes an
// ordinary presentation unrecordable rather than merely awkward.
expect(builderGroupSelection("Classes")).toBe("multiple");
expect(builderGroupSelection("Clusters")).toBe("multiple");
expect(builderGroupSelection("Aetiology")).toBe("multiple");
expect(builderGroupSelection("Attraction")).toBe("multiple");
});

it("lets one disorder opt out of a label-wide one-of rule", () => {
expect(builderGroupSelection("Type", "Specific Phobia")).toBe("multiple");
expect(builderGroupSelection("Type", "Delusional Disorder")).toBe("single");
expect(builderGroupSelection("Type")).toBe("single");

const phobia = builderCatalogGroups(catalogDiagnosisId("anx", "Specific Phobia"));
const type = phobia.find((group) => group.label === "Type")!;
expect(type.selection).toBe("multiple");
const both = applyBuilderGroupRules(phobia, [type.items[0].slug, type.items[1].slug]);
expect(both).toHaveLength(2);
});

it("records concurrent substance use disorders instead of replacing the last one", () => {
const classes = builderCatalogGroups(catalogDiagnosisId("sub", "Substance Classes with Use Disorders"));
const group = classes.find((entry) => entry.label === "Classes")!;
let selected = toggleBuilderCatalogSlug(classes, [], group.items[0].slug);
selected = toggleBuilderCatalogSlug(classes, selected, group.items[1].slug);
expect(selected).toEqual([group.items[0].slug, group.items[1].slug]);
});

it("reopens a single-select group on request so no combination is unreachable", () => {
const asd = catalogDiagnosisId("ndv", "Autism Spectrum Disorder");
const groups = builderCatalogGroups(asd);
const severity = groups.find((group) => group.label === "Severity")!;

const relaxed = relaxBuilderGroups(groups, new Set([severity.id]));
expect(relaxed.find((group) => group.label === "Severity")!.selection).toBe("multiple");
// Every other group keeps its own rule, and the original list is not mutated.
expect(relaxed.find((group) => group.label === "Co-occurring")!.selection).toBe("multiple");
expect(severity.selection).toBe("single");

let selected = toggleBuilderCatalogSlug(relaxed, [], severity.items[0].slug);
selected = toggleBuilderCatalogSlug(relaxed, selected, severity.items[1].slug);
expect(selected).toHaveLength(2);
});

it("keeps one pick inside a single-select group and many inside the rest", () => {
const asd = catalogDiagnosisId("ndv", "Autism Spectrum Disorder");
const groups = builderCatalogGroups(asd);
Expand Down Expand Up @@ -127,6 +173,18 @@ describe("specifier builder base diagnoses", () => {
});
});

it("carries a source-review status on every catalogue option the builder can offer", () => {
// The builder shows these rows at the moment of choosing, so each must have a status
// the ReviewStatusBadge can render. Most of the catalogue is still awaiting formal
// source review, and that has to stay visible rather than being implied as verified.
const statuses = new Set(specifierIndexItems.map((item) => item.src));
for (const status of statuses) {
expect(["source-verified", "source-needs-formal-review", "source-not-applicable"]).toContain(status);
}
expect(specifierIndexItems.every((item) => Boolean(item.src))).toBe(true);
expect(statuses.has("source-needs-formal-review")).toBe(true);
});

it("lowers an ordinary leading capital for the wording line but leaves structured labels alone", () => {
expect(catalogWordingSegment("With catatonia")).toBe("with catatonia");
expect(catalogWordingSegment("Mild (BMI 17 or above)")).toBe("mild (BMI 17 or above)");
Expand Down
Loading
Loading