From 077667d152fd3592417b6161e5c3c6011e0e82e2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:23:19 +0800 Subject: [PATCH 01/18] feat: wire PR #3538 components, fix clinical math/Tall Man/RAG latency/auth bugs, and prepare governance sign-off tooling --- bundle-budget.json | 36 +- docs/clinical-hazard-controls.json | 10 +- docs/codebase-index.md | 1 + docs/design-system/ADOPTION.md | 8 +- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-contract.json | 1 + docs/design-system/adoption-manifest.json | 23 +- docs/organisation/systems/app-experience.json | 2 + .../systems/clinical-content.json | 2 + docs/organisation/systems/data-platform.json | 2 +- .../systems/personal-practice.json | 1 + docs/site-map.md | 2 + scripts/apply-specifier-worksheet.mjs | 294 ++ .../publish-site-content-first-catalogue.ts | 452 ++ .../assessments/respond/[token]/page.tsx | 266 + src/app/api/roster/shifts/manual/route.ts | 4 +- src/app/api/roster/shifts/route.ts | 5 +- .../assessments/respond/[token]/route.ts | 142 + .../calculators/clinical-math-panel.tsx | 522 ++ src/components/calculators/search-page.tsx | 3 + .../document-search-results.tsx | 27 +- src/components/clinical-notes/note-editor.tsx | 62 +- .../differential-stream-workspace.tsx | 81 +- src/components/on-call/call/call-page.tsx | 2 - .../on-call/handover/handover-page.tsx | 5 + .../roster/manage/roster-decision-sheet.tsx | 19 +- .../roster/manage/roster-safe-number.tsx | 62 +- src/components/roster/roster-add-sheet.tsx | 34 +- src/components/roster/roster-import-flow.tsx | 16 +- .../roster/team/calendar/needs-you-strip.tsx | 11 +- .../teaching/assessments/assessments-help.tsx | 40 + src/components/teaching/attendance-roster.tsx | 16 + src/components/teaching/organise-sheets.tsx | 38 +- .../teaching/qr-check-in-helper.tsx | 5 + .../teaching/teaching-code-sheet.tsx | 183 +- src/data/medication-source-links.json | 58 +- src/data/source-acquisitions.json | 4472 ++++++++++------- src/lib/answer-verification.ts | 2 +- src/lib/calculators/clinical-math/lithium.ts | 60 +- src/lib/calculators/clinical-math/renal.ts | 68 +- src/lib/calculators/clinical-math/types.ts | 12 +- src/lib/clinical-search.ts | 6 +- src/lib/env.ts | 1 + src/lib/medications/tall-man.ts | 51 +- src/lib/openai.ts | 31 +- src/lib/rag/rag-candidate-sources.ts | 137 +- src/lib/rag/rag-claim-support.ts | 6 +- src/lib/rag/rag-coverage-gate.ts | 12 +- src/lib/rag/rag-embedding-prefetch.ts | 2 + src/lib/rag/rag-governed-search.ts | 7 +- src/lib/rag/rag-hydration.ts | 114 +- src/lib/rag/rag.ts | 50 +- src/lib/roster/owner/teams.ts | 73 +- src/lib/roster/shifts/diff.ts | 51 +- src/lib/roster/team/errors.ts | 11 + src/lib/roster/team/model.ts | 8 +- src/lib/roster/team/repository.ts | 218 +- src/lib/source-text-sanitizer.ts | 4 +- src/lib/teaching/assessments/magic-link.ts | 556 ++ src/lib/teaching/repository.ts | 41 +- src/lib/ui/use-virtual-window.ts | 109 + tests/answer-verification.test.ts | 22 + tests/attendance-roster.dom.test.tsx | 39 + tests/clinical-math-bounds.test.ts | 41 +- tests/clinical-math-calculators.test.ts | 95 +- tests/epa-magic-link.test.ts | 173 + tests/medication-source-links.test.ts | 38 +- tests/note-editor-caret.dom.test.tsx | 71 +- tests/pdf-table-extraction.test.ts | 125 + tests/qr-check-in-helper.dom.test.tsx | 35 +- tests/rag-claim-support.test.ts | 31 + tests/rag-coverage-gate.test.ts | 61 + tests/rag-hydration-batching.test.ts | 300 ++ tests/roster-own-shift-conflicts.test.ts | 73 + tests/roster-owner-teams-batch.test.ts | 112 + tests/roster-swap-server-rules.test.ts | 160 + tests/safe-number-concurrency.test.ts | 72 + ...site-content-first-publication-cli.test.ts | 123 + tests/source-text-sanitizer.test.ts | 20 + tests/tall-man-lettering.test.ts | 37 +- tests/teaching-display-revoke-auth.test.ts | 112 + tests/virtual-window-results.dom.test.tsx | 140 + worker/python/extract_pdf_assets.py | 298 +- 83 files changed, 8429 insertions(+), 2288 deletions(-) create mode 100644 scripts/apply-specifier-worksheet.mjs create mode 100644 scripts/publish-site-content-first-catalogue.ts create mode 100644 src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx create mode 100644 src/app/api/teaching/assessments/respond/[token]/route.ts create mode 100644 src/components/calculators/clinical-math-panel.tsx create mode 100644 src/lib/teaching/assessments/magic-link.ts create mode 100644 src/lib/ui/use-virtual-window.ts create mode 100644 tests/epa-magic-link.test.ts create mode 100644 tests/pdf-table-extraction.test.ts create mode 100644 tests/rag-coverage-gate.test.ts create mode 100644 tests/rag-hydration-batching.test.ts create mode 100644 tests/roster-own-shift-conflicts.test.ts create mode 100644 tests/roster-owner-teams-batch.test.ts create mode 100644 tests/roster-swap-server-rules.test.ts create mode 100644 tests/site-content-first-publication-cli.test.ts create mode 100644 tests/teaching-display-revoke-auth.test.ts create mode 100644 tests/virtual-window-results.dom.test.tsx diff --git a/bundle-budget.json b/bundle-budget.json index 9b00c8be73..0a03a453ce 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -2,11 +2,11 @@ "$comment": "Client JS bundle-size budgets captured from a known-good production build. `production` guards aggregate user-facing chunks, `routes` guards the Lighthouse journeys and the main work-mode pages (added by the work-mode speed review, 7 Oct 2026) against route-local growth (route baselines lowered about 100 KB on 7 Oct 2026 (scaled from a local build by the ratio to the baselineSource build, so they stay comparable with it) when the sign-in library moved out of the first load, so that saving cannot quietly regrow), and `mockups` is a looser design-scratch hygiene ceiling. The `production` baseline alone was raised on 9 Oct 2026 to a local build of the hospital roles change merged with main (5472.8 KiB), after its 145 KiB duplicated-chunk regression was removed: main had already reached +9.8% from the combined work-mode growth. Route baselines and baselineSource were left as they were. Refresh intentionally with `npm run check:bundle-budget -- --update`. On 13 Oct 2026 the owner approved resetting two route baselines, /roster/shifts (390.9 KiB) and /teaching/assessments (459.3 KiB), to a local production build of main at f4bcb55a4 (which matched CI's own measurement of that commit), because main had already reached +9.7% and +8.9% there. All other baselines were left as they were.", "enforce": true, "production": { - "gzipBytes": 6062493, + "gzipBytes": 5604147, "tolerancePct": 10 }, "mockups": { - "gzipBytes": 670993, + "gzipBytes": 719616, "tolerancePct": 25 }, "serverPages": { @@ -22,60 +22,60 @@ }, "routes": { "/": { - "gzipBytes": 246078, + "gzipBytes": 229182, "tolerancePct": 10 }, "/documents/search": { - "gzipBytes": 248188, + "gzipBytes": 231601, "tolerancePct": 10 }, "/my-day": { - "gzipBytes": 477445, + "gzipBytes": 444857, "tolerancePct": 10 }, "/my-day/week": { - "gzipBytes": 524825, + "gzipBytes": 481356, "tolerancePct": 10 }, "/roster": { - "gzipBytes": 380787, + "gzipBytes": 361951, "tolerancePct": 10 }, "/roster/shifts": { - "gzipBytes": 408460, + "gzipBytes": 400282, "tolerancePct": 10 }, "/open-shifts": { - "gzipBytes": 352114, + "gzipBytes": 320160, "tolerancePct": 10 }, "/on-call": { - "gzipBytes": 411937, + "gzipBytes": 389216, "tolerancePct": 10 }, "/on-call/handover": { - "gzipBytes": 448042, + "gzipBytes": 422726, "tolerancePct": 10 }, "/teaching": { - "gzipBytes": 374236, + "gzipBytes": 345601, "tolerancePct": 10 }, "/teaching/assessments": { - "gzipBytes": 483070, + "gzipBytes": 470323, "tolerancePct": 10 }, "/cme": { - "gzipBytes": 417752, + "gzipBytes": 384066, "tolerancePct": 10 }, "/admin": { - "gzipBytes": 371114, + "gzipBytes": 350344, "tolerancePct": 10 } }, - "totalGzipBytes": 6733486, + "totalGzipBytes": 5777051, "tolerancePct": 10, - "updatedAt": "2026-10-14T04:23:00.194Z", - "baselineSource": "f8f664425d4c9c97cf6a4faf5796ea0a09bc4262" + "updatedAt": "2026-10-07T08:53:49.208Z", + "baselineSource": "8d270cd8e4fa65f954edfab15968a8a414802c30" } diff --git a/docs/clinical-hazard-controls.json b/docs/clinical-hazard-controls.json index 57b4e9a597..d194f498d5 100644 --- a/docs/clinical-hazard-controls.json +++ b/docs/clinical-hazard-controls.json @@ -165,20 +165,20 @@ "src/components/clinical-dashboard/patient-profile-panel.tsx": "c81ce6a9d80bdc749f9ebfac6bda7578b97f66acf7daaeb8827b4c7b3e6b0628", "src/data/dictionary-source-dispositions.json": "3d03bd0be2d699f5dc7eb50338b535112a7dea57daf0f210de9ae241c606c2b8", "src/lib/answer-render-policy.ts": "a5e2b36c4bdf6c2081d443d10e87c0ced403abfb129454d6989ad93af737f4a9", - "src/lib/answer-verification.ts": "0a340eeec7850282d8cbe50c90dd9a4369bc04bd92057d97005db323321f91ed", + "src/lib/answer-verification.ts": "73e2a66d3b50ae66b415f57366de7f688d098dd9315aeefa38c7e8fd426f8dfc", "src/lib/clinical-safety.ts": "8fa7b5fca92f2b12b6f241d32feadd9310ee075a38e55e10b5bbce4c26c1173b", "src/lib/rag/rag-answer-support.ts": "4250f30017789f6c5d75e84e7a291f163f08abb95d615fb2368e904956841dd5", - "src/lib/rag/rag-claim-support.ts": "866ae98e48a1f5bb9f6599399f362017d4a04d76e0ac5aa8fb8c612488376702", + "src/lib/rag/rag-claim-support.ts": "88e5b90be32af8f02b6443bb584f3156e70c881c64de9ca32bcb3be689cd0204", "src/lib/rag/rag-source-block.ts": "984b3f655b468aabbf2d1f489eb6df66273fe7c9e85ea88df63affa986654ad2", "src/lib/service-ranker.ts": "bbdbe3629520a22104194fc727530c05614dfba5528d9b9d8d5743acbfa9ea44", "src/lib/service-urgent-routing.ts": "663b0d131fa4457a4df9875fb200efe1520c9ebda75b552a8bbd68eb66264a7f", "src/lib/source-authority-registry.ts": "e82b2f1aeaf053a321f76576da71a2c54f7fb4db485fbf6c4caf686ac6f2267c", "src/lib/source-governance.ts": "e3e1c09b0a7e446b7055f894088c771f1a5cc160a0f20f026f3cc291af5804fa", - "src/lib/source-text-sanitizer.ts": "c8b2a8b12b365a9558115f6f75d28c22234d1595291f58f1554f46daeace0c61", + "src/lib/source-text-sanitizer.ts": "cb6c869b520a8be03d40bae1206a87b6d53318573baeaec664b35c353fa1bd80", "tests/answer-render-policy.test.ts": "5e48ea66ca1902ac17a271444db7f6350a37c0100fe2152afd042e4fdb89427d", - "tests/answer-verification.test.ts": "6c61453a04d7342b5c80ddb337df76b61f5dfe14e6de363c86348d97e71ccee3", + "tests/answer-verification.test.ts": "2beac1a1f96cb8afae395a51cd5d2a91e8f45ff45723bf3e7641ec85c2605050", "tests/clinical-safety.test.ts": "053b82e3523a5aa55b40059a558e8992fe5342ebedd8812da10193977cad43f6", - "tests/rag-claim-support.test.ts": "0fa88174f11aa1e10588ac92ab11bfcf3185a8e9bec44f047ad5dcd1bc999080", + "tests/rag-claim-support.test.ts": "79f19e142dff92ea92cb3635bd032f081885a1714d1ac745eb3d0f9151247586", "tests/rag-content-accuracy.test.ts": "6aaacb7fb9b43711696ba244a56cd31736fb3a884690774cde4f4db6ee206eb4", "tests/rag-injection.test.ts": "9a6fa43c799232d9e38d334b732c99dfaad5a5192ed64a7fcb8d6cb373300931", "tests/rag-retrieval-row-contract.test.ts": "4da5fe01a76dfa5b4e06e02303f136ff3fb0a1a1219cfe09ec158a1eae074fb6", diff --git a/docs/codebase-index.md b/docs/codebase-index.md index b27c6c99ad..5117d18aa5 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -339,6 +339,7 @@ domain-extracted directory; imported as `@/lib/rag/rag*`). Other modules below r | `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts`, `answer-request.ts`, `clinical-ask-request.ts`, `speech-transcription-request.ts`, `row-contracts.ts` | | `app-modes.ts`, `document-flow-routes.ts`, `local-project-identity.ts`, `local-server-utils.mjs` | Routing and project identity | | `tailwind-merge.ts` | The `extendTailwindMerge` config behind `cn()` — declares this repo's custom `@theme` scales so twMerge does not misclassify them (`docs/design-system/TOKENS.md`) | +| `src/lib/ui/` — `use-virtual-window.ts` | Progressive windowed virtualization hook (`IntersectionObserver` sentinel + `ensureIndexVisible`) for long result lists | | `caring-contacts/` — `clock.ts` | The Perth-time clock kept from the retired Caring Contacts prototype; the Mental Health Act timeline (`mha-timeline.ts`) imports it | ### On Call mode diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index 1a7ed8b88d..8c7c88c202 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -368,12 +368,12 @@ product exclusions; the only route-only dispositions are the documented legacy-r surfaces. Shared shell/component roots carry their own explicit `shared-shell` disposition. Registered public components: 55 -Declared product roots: 232 +Declared product roots: 233 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 231 +Roots inheriting `.ckb-v2` from the global ``: 232 Production surfaces observed under v2: 14/14 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 224/224 +Declared production page routes: 225/225 Source observation and contract declaration are independent. A literal `ckb-v2` on the global `` makes every production surface inherit v2, but it does not approve that adoption. The Proof column summarizes each surface's dark, forced-colours, 320px, print and browser declarations; exact statuses and evidence paths live in the manifest. @@ -382,7 +382,7 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | Surface | Disposition | Routes | Roots | Declared shell | Observed shell (mount) | Proof | Baseline | | ---------------------------------- | --------------- | -----: | ----: | -------------- | -------------------------- | -------------- | -------------- | | `root-shell-and-settings` | shared-shell | 3 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | -| `catalogues-forms-and-info` | owned | 181 | 181 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `catalogues-forms-and-info` | owned | 182 | 182 | v2 | v2 (inherited-global-root) | passed | not-committed | | `differentials` | owned | 7 | 7 | v2 | v2 (inherited-global-root) | passed | not-committed | | `formulation` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | | `specifiers` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index e4115d4809..6d8c089f5c 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1102,7 +1102,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `StageList` | feedback | yes | yes | no | yes | no | 0 | | `StatusMark` | source | yes | yes | inherited-global-root | yes | no | 6 | | `Tabs` | controls | yes | yes | inherited-global-root | yes | no | 3 | -| `TextField` | controls | yes | yes | inherited-global-root | yes | no | 48 | +| `TextField` | controls | yes | yes | inherited-global-root | yes | no | 49 | | `TextLink` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `ToastRegion` | feedback | yes | yes | inherited-global-root | yes | no | 1 | | `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 2 | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index bdda51cba3..787513a661 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -291,6 +291,7 @@ "src/app/(search-app)/teaching/assessments/export/page.tsx", "src/app/(search-app)/teaching/assessments/help/page.tsx", "src/app/(search-app)/teaching/assessments/record/page.tsx", + "src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/[id]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/page.tsx", "src/app/(search-app)/my-day/favourites/page.tsx", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 75690d9da0..b06dda7a93 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -2871,6 +2871,7 @@ "src/components/admin/renewals/record-dates-sheet.tsx", "src/components/admin/starter/starter-date-sheet.tsx", "src/components/alerts/remind-me-sheet.tsx", + "src/components/calculators/clinical-math-panel.tsx", "src/components/clinical-dashboard/email-auth-form.tsx", "src/components/clinical-dashboard/password-recovery-form.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", @@ -2922,6 +2923,7 @@ "src/components/admin/renewals/record-dates-sheet.tsx", "src/components/admin/starter/starter-date-sheet.tsx", "src/components/alerts/remind-me-sheet.tsx", + "src/components/calculators/clinical-math-panel.tsx", "src/components/clinical-dashboard/email-auth-form.tsx", "src/components/clinical-dashboard/password-recovery-form.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", @@ -3440,6 +3442,7 @@ "src/app/(search-app)/teaching/assessments/help/page.tsx", "src/app/(search-app)/teaching/assessments/page.tsx", "src/app/(search-app)/teaching/assessments/record/page.tsx", + "src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/[id]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/page.tsx", "src/app/(search-app)/teaching/c/[token]/page.tsx", @@ -5100,6 +5103,16 @@ "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": [] }, + { + "file": "src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, { "file": "src/app/(search-app)/teaching/assessments/trainee/[id]/page.tsx", "exists": true, @@ -6557,6 +6570,7 @@ "src/app/(search-app)/teaching/assessments/help/page.tsx", "src/app/(search-app)/teaching/assessments/page.tsx", "src/app/(search-app)/teaching/assessments/record/page.tsx", + "src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/[id]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/page.tsx", "src/app/(search-app)/teaching/c/[token]/page.tsx", @@ -6783,6 +6797,7 @@ "src/app/(search-app)/teaching/assessments/help/page.tsx", "src/app/(search-app)/teaching/assessments/page.tsx", "src/app/(search-app)/teaching/assessments/record/page.tsx", + "src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/[id]/page.tsx", "src/app/(search-app)/teaching/assessments/trainee/page.tsx", "src/app/(search-app)/teaching/c/[token]/page.tsx", @@ -6824,7 +6839,7 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 231, + "inheritedCkbV2RootCount": 232, "dynamicCkbV2RootCount": 0, "v2MountedSurfaceCount": 14, "declaredV2SurfaceCount": 14 @@ -6833,8 +6848,8 @@ "registeredComponentCount": 55, "previewCount": 55, "productImportedComponentCount": 48, - "rootCount": 232, - "productionRouteCount": 224, - "nextUiEntryCount": 333 + "rootCount": 233, + "productionRouteCount": 225, + "nextUiEntryCount": 334 } } diff --git a/docs/organisation/systems/app-experience.json b/docs/organisation/systems/app-experience.json index 5588d4a385..3fb0371d6d 100644 --- a/docs/organisation/systems/app-experience.json +++ b/docs/organisation/systems/app-experience.json @@ -227,6 +227,7 @@ "src/lib/tools-catalog.ts", "src/lib/tools-page-layout.ts", "src/lib/ui-copy.ts", + "src/lib/ui/use-virtual-window.ts", "src/lib/universal-search-domains.ts", "src/lib/universal-search-mode-context.ts", "src/lib/universal-search-stream.ts", @@ -398,6 +399,7 @@ "tests/use-*", "tests/ux-audit-dead-ends-fix.dom.test.tsx", "tests/viewer-zoom.test.ts", + "tests/virtual-window-results.dom.test.tsx", "tests/visual-evidence-tabs.dom.test.tsx", "tests/ward-output.test.ts", "tests/work-calendar-entries-enabled.dom.test.tsx", diff --git a/docs/organisation/systems/clinical-content.json b/docs/organisation/systems/clinical-content.json index 1c3b688b25..f3ecb2bba1 100644 --- a/docs/organisation/systems/clinical-content.json +++ b/docs/organisation/systems/clinical-content.json @@ -56,6 +56,7 @@ "public/forms-pdf/**", "public/therapy-compass-data/**", "scripts/apply-dictionary-rewrites.ts", + "scripts/apply-specifier-worksheet.mjs", "scripts/audit-public-document-approvals.ts", "scripts/audit-source-governance.ts", "scripts/build-clinical-review-queue.ts", @@ -95,6 +96,7 @@ "scripts/merge-source-acquisitions.ts", "scripts/patch-therapy-references.mjs", "scripts/promote-public-documents-batch.ts", + "scripts/publish-site-content-first-catalogue.ts", "scripts/reconcile-registry-governance.ts", "scripts/refresh-site-content-bootstrap.ts", "scripts/refresh-site-content-p03-baseline.ts", diff --git a/docs/organisation/systems/data-platform.json b/docs/organisation/systems/data-platform.json index f82c8a0b6f..18e48109ec 100644 --- a/docs/organisation/systems/data-platform.json +++ b/docs/organisation/systems/data-platform.json @@ -51,7 +51,6 @@ "scripts/check-retrieval-owner-migration.ts", "scripts/check-supabase-project.ts", "scripts/generate-drift-manifest.ts", - "scripts/test-sql-behaviour.ts", "scripts/lib/cross-tenant-*", "scripts/lib/find-owner-id-by-email.ts", "scripts/lib/operational-alerts.mjs", @@ -66,6 +65,7 @@ "scripts/sql/**", "scripts/supabase-recovery-status.ts", "scripts/test-cross-tenant-staging.ts", + "scripts/test-sql-behaviour.ts", "scripts/verify-recovery-readiness-evidence.ts", "src/app/api/account/**", "src/app/api/health/**", diff --git a/docs/organisation/systems/personal-practice.json b/docs/organisation/systems/personal-practice.json index 2a2daa7630..c7731be049 100644 --- a/docs/organisation/systems/personal-practice.json +++ b/docs/organisation/systems/personal-practice.json @@ -78,6 +78,7 @@ "tests/calendar-*", "tests/challenger-m4-2-empirical-stress.test.ts", "tests/cme-*", + "tests/epa-magic-link.test.ts", "tests/example-data-roster.test.ts", "tests/favourite-set-name-sheet.dom.test.tsx", "tests/favourite-set-name.test.ts", diff --git a/docs/site-map.md b/docs/site-map.md index b5707e3881..345640f9f3 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -68,6 +68,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/cme/customise` - Choose which modules show on the CPD dashboard below the hours, pace and next-action rows, and reorder them with up/down controls that work as well from a keyboard as from a pointer. Source: `src/app/(search-app)/cme/customise/page.tsx`. - `/my-day/setup` - Route discovered from app directory Source: `src/app/(search-app)/my-day/setup/page.tsx`. - `/therapy-compass/[slug]/brief` - Therapy brief-intervention view. Source: `src/app/(search-app)/therapy-compass/[slug]/brief/page.tsx`. +- `/teaching/assessments/respond/[token]` - Route discovered from app directory Source: `src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx`. - `/my-day/alerts/earlier` - Route discovered from app directory Source: `src/app/(search-app)/my-day/alerts/earlier/page.tsx`. - `/teaching/resources` - Route discovered from app directory Source: `src/app/(search-app)/teaching/resources/page.tsx`. - `/admin/help` - Crisis lines, support, guides, contacts and on-site detail with in-page search. Source: `src/app/(search-app)/admin/help/page.tsx`. @@ -1479,6 +1480,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir ## API routes - `/api/on-call/shifts` - Route discovered from app directory Source: `src/app/api/on-call/shifts/route.ts`. +- `/api/teaching/assessments/respond/[token]` - Route discovered from app directory Source: `src/app/api/teaching/assessments/respond/[token]/route.ts`. - `/api/alerts/test` - Route discovered from app directory Source: `src/app/api/alerts/test/route.ts`. - `/api/on-call/entries` - Route discovered from app directory Source: `src/app/api/on-call/entries/route.ts`. - `/api/health/ready` - Readiness health check. Source: `src/app/api/health/ready/route.ts`. diff --git a/scripts/apply-specifier-worksheet.mjs b/scripts/apply-specifier-worksheet.mjs new file mode 100644 index 0000000000..878b55ef8e --- /dev/null +++ b/scripts/apply-specifier-worksheet.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node +/** + * Parse a completed specifier entry worksheet (`docs/specifier-entry-worksheet-with-catatonia.md`) + * and propagate the shared criteria + disorder-specific overrides into `data/specifiers-content.json`, + * then rebuild `data/specifiers-search-index.json`. + * + * Refuses to write while any required worksheet prompt is still blank (`_(blank)_` or `> _`). + * + * Usage: + * node scripts/apply-specifier-worksheet.mjs --worksheet docs/specifier-entry-worksheet-with-catatonia.md --dry-run + * node scripts/apply-specifier-worksheet.mjs --worksheet docs/specifier-entry-worksheet-with-catatonia.md --write + */ + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const EXPECTED_CATATONIA_ROW_KEYS = Object.freeze([ + "specifier:ndv:autism-spectrum-disorder:co-occurring:with-catatonia", + "specifier:psy:brief-psychotic-disorder:features:with-catatonia", + "specifier:psy:schizophreniform-disorder:features:with-catatonia", + "specifier:psy:schizophrenia:features:with-catatonia", + "specifier:psy:schizoaffective-disorder:features:with-catatonia", + "specifier:psy:psychotic-disorder-due-to-another-medical-condition:features:with-catatonia", + "specifier:bip:bipolar-i-disorder:episode-specifiers:with-catatonia", + "specifier:bip:bipolar-ii-disorder:episode-specifiers:with-catatonia", + "specifier:dep:major-depressive-disorder:episode-specifiers:with-catatonia", +]); + +function isBlankPlaceholder(value) { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return true; + if (trimmed.includes("_(blank)_")) return true; + if (/^>_?/.test(trimmed) || /^_\(.*\)_$/.test(trimmed)) return true; + return false; +} + +function cleanField(value) { + return String(value ?? "") + .replace(/^>\s*/, "") + .trim(); +} + +export function parseWorksheetArgs(argv) { + let worksheet = "docs/specifier-entry-worksheet-with-catatonia.md"; + let mode = "dry-run"; + let sawMode = false; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--worksheet") { + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + throw new Error("--worksheet requires a path."); + } + worksheet = next; + index += 1; + continue; + } + if (token === "--dry-run" || token === "--write") { + if (sawMode) { + throw new Error("Specify at most one of --dry-run or --write."); + } + sawMode = true; + mode = token === "--write" ? "write" : "dry-run"; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + + return { worksheet, write: mode === "write", dryRun: mode === "dry-run" }; +} + +export function parseCatatoniaWorksheet(markdown) { + const problems = []; + const normalized = String(markdown).replace(/\r\n/g, "\n"); + const sectionParts = normalized.split(/^##\s+2[\s.—-]+/m); + const sharedSection = sectionParts[0] ?? ""; + const disordersSection = (sectionParts[1] ?? "").split(/^##\s+3[\s.—-]+/m)[0] ?? ""; + + const quoteMatches = [...sharedSection.matchAll(/^>\s*(.+)$/gm)].map((match) => cleanField(match[1])); + if (quoteMatches.length < 4) { + problems.push(`Section 1 must contain 4 blockquote answers (found ${quoteMatches.length}).`); + } + + const [threshold = "", exclusions = "", impact = "", sourceFamily = ""] = quoteMatches; + const sharedLabels = [ + ["Section 1.1 (threshold / what has to be present)", threshold], + ["Section 1.2 (exclusions / differential)", exclusions], + ["Section 1.3 (what applying it changes)", impact], + ["Section 1.4 (sourceFamily)", sourceFamily], + ]; + for (const [label, value] of sharedLabels) { + if (isBlankPlaceholder(value)) { + problems.push(`${label} is still blank.`); + } + } + + const entriesByRowKey = new Map(); + const subsectionBlocks = disordersSection.split(/^###\s+/m).slice(1); + for (const block of subsectionBlocks) { + const keyMatch = block.match(/`(specifier:[^`\n]+:with-catatonia)`/); + if (!keyMatch) continue; + const rowKey = keyMatch[1].trim(); + + const unchangedMatch = block.match(/-\s*Shared definition applies unchanged\?[^\n]*→\s*([^\n]+)/); + const diffMatch = block.match(/-\s*If no, what differs here[\s\S]*?:\s*([^\n]+)/); + const icd11Match = block.match(/-\s*ICD-11 route for this disorder[\s\S]*?:\s*([^\n]+)/); + + const rawUnchanged = cleanField(unchangedMatch?.[1] ?? "").toLowerCase(); + const rawDifference = cleanField(diffMatch?.[1] ?? ""); + const rawIcd11 = cleanField(icd11Match?.[1] ?? ""); + + if (isBlankPlaceholder(rawUnchanged) || (rawUnchanged !== "yes" && rawUnchanged !== "no")) { + problems.push(`${rowKey}: "Shared definition applies unchanged?" must be answered "yes" or "no".`); + } + const unchanged = rawUnchanged === "yes"; + const difference = isBlankPlaceholder(rawDifference) ? "" : rawDifference; + if (rawUnchanged === "no" && !difference) { + problems.push(`${rowKey}: marked "no" for unchanged definition, but difference note is blank.`); + } + if (isBlankPlaceholder(rawIcd11)) { + problems.push(`${rowKey}: ICD-11 route (icd11Context) is still blank.`); + } + + entriesByRowKey.set(rowKey, { + rowKey, + unchanged, + difference, + icd11Context: rawIcd11, + }); + } + + for (const expectedKey of EXPECTED_CATATONIA_ROW_KEYS) { + if (!entriesByRowKey.has(expectedKey)) { + problems.push(`Missing worksheet subsection for ${expectedKey}.`); + } + } + + if (problems.length > 0) { + throw new Error(`Worksheet is incomplete; refusing to apply:\n- ${problems.join("\n- ")}`); + } + + return { + shared: { + threshold, + exclusions, + impact, + sourceFamily, + }, + entries: EXPECTED_CATATONIA_ROW_KEYS.map((key) => entriesByRowKey.get(key)), + }; +} + +function shortContentHash(payload) { + return createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 8); +} + +export function applyCatatoniaWorksheet(document, parsed) { + const byRowKey = new Map(parsed.entries.map((entry) => [entry.rowKey, entry])); + const matched = new Set(); + + const categories = (document.categories ?? []).map((category) => ({ + ...category, + disorders: (category.disorders ?? []).map((disorder) => { + let nextIcd11Context = disorder.icd11Context; + const groups = (disorder.groups ?? []).map((group) => ({ + ...group, + items: (group.items ?? []).map((item) => { + const rowKey = item?.review?.rowKey; + const override = byRowKey.get(rowKey); + if (!override) return item; + matched.add(rowKey); + if (override.icd11Context) { + nextIcd11Context = override.icd11Context; + } + const meaning = override.unchanged + ? parsed.shared.threshold + : `${parsed.shared.threshold} ${override.difference}`.trim(); + const clinicalNote = `${parsed.shared.exclusions} ${parsed.shared.impact}`.trim(); + const definition = { + meaning, + clinicalNote, + sourceFamily: parsed.shared.sourceFamily, + status: "defined", + }; + const contentHash = shortContentHash({ + rowKey, + label: item.label, + definition, + definitionStatus: "defined", + icd11Context: nextIcd11Context, + }); + const nextReview = { + rowKey, + contentHash, + sourceFamily: parsed.shared.sourceFamily, + sourceVerificationStatus: "source-verified", + clinicianReviewStatus: "clinician-review-pending", + changedSinceReview: true, + }; + return { + ...item, + definition, + definitionStatus: "defined", + review: nextReview, + }; + }), + })); + return { + ...disorder, + icd11Context: nextIcd11Context, + groups, + }; + }), + })); + + for (const expectedKey of EXPECTED_CATATONIA_ROW_KEYS) { + if (!matched.has(expectedKey)) { + throw new Error(`Specifier rowKey ${expectedKey} was not found in data/specifiers-content.json.`); + } + } + + const allItems = categories.flatMap((category) => + category.disorders.flatMap((disorder) => disorder.groups.flatMap((group) => group.items)), + ); + const itemsWithDefinitions = allItems.filter((item) => item.definitionStatus === "defined").length; + + return { + ...document, + stats: { + ...document.stats, + itemsWithDefinitions, + }, + categories, + }; +} + +export async function main(argv = process.argv.slice(2), { root = process.cwd(), output = process.stdout } = {}) { + const args = parseWorksheetArgs(argv); + const worksheetPath = resolve(root, args.worksheet); + if (!existsSync(worksheetPath)) { + throw new Error(`Worksheet file not found: ${worksheetPath}`); + } + const markdown = readFileSync(worksheetPath, "utf8"); + const parsed = parseCatatoniaWorksheet(markdown); + + const specifiersPath = join(root, "data", "specifiers-content.json"); + const document = JSON.parse(readFileSync(specifiersPath, "utf8")); + const nextDocument = applyCatatoniaWorksheet(document, parsed); + + if (!args.write) { + output.write( + `Dry run passed: ${parsed.entries.length} "With catatonia" specifier rows ready to update from ${args.worksheet}.\n`, + ); + return 0; + } + + let formatted = `${JSON.stringify(nextDocument, null, 2)}\n`; + try { + const prettier = await import("prettier"); + const config = (await prettier.resolveConfig(specifiersPath)) ?? {}; + formatted = await prettier.format(formatted, { ...config, filepath: specifiersPath }); + } catch { + // Fall back to 2-space JSON formatting if prettier is unavailable. + } + + writeFileSync(specifiersPath, formatted, "utf8"); + execFileSync(process.execPath, [join(root, "scripts", "build-specifiers-search-index.mjs")], { + cwd: root, + stdio: "inherit", + }); + output.write( + `Updated ${parsed.entries.length} "With catatonia" specifier rows in data/specifiers-content.json and rebuilt data/specifiers-search-index.json.\n`, + ); + return 0; +} + +const invokedPath = process.argv[1] + ? pathToFileURL(existsSync(process.argv[1]) ? realpathSync(resolve(process.argv[1])) : resolve(process.argv[1])).href + : null; +if (invokedPath === import.meta.url) { + main().then( + (code) => { + process.exitCode = code; + }, + (error) => { + process.stderr.write(`apply-specifier-worksheet: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }, + ); +} diff --git a/scripts/publish-site-content-first-catalogue.ts b/scripts/publish-site-content-first-catalogue.ts new file mode 100644 index 0000000000..4c7759d551 --- /dev/null +++ b/scripts/publish-site-content-first-catalogue.ts @@ -0,0 +1,452 @@ +/** + * Build an offline, owner-reviewed first-publication catalogue plan for Services (222) and + * Forms (54) (#HTZPQ8 / #ZPHK2F). + * + * WHY THIS EXISTS + * --------------- + * Owner decision 2026-09-25 (#ZPHK2F): the 222 service rows and 54 form rows go public through + * the administrator first-publication flow in owner-approved batches, each batch clinically + * signed off by a named person (`isNamedPerson`). They must NOT be forced through the adoption + * reconciliation triage by relaxing `reconcileCanonicalPublicSiteContent`. + * + * SAFETY GATES + * ------------ + * 1. Fail-closed clinical sign-off check: every record included in the plan must carry + * `status === "reviewed"`, a named human reviewer (`isNamedPerson(reviewedBy)` — rejecting + * `"PsychSift"`, `"System"`, bare titles, etc.), and a valid UTC `reviewedAt` timestamp. + * 2. Offline by default: `--dry-run` and `--out ` never import or call a live Supabase + * client. Live execution requires BOTH `--execute` and `ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT=true`. + * + * USAGE + * node scripts/run-tsx.mjs scripts/publish-site-content-first-catalogue.ts --dry-run + * node scripts/run-tsx.mjs scripts/publish-site-content-first-catalogue.ts \ + * --kind all --batch-size 100 --out first-publication-plan.json + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { isNamedPerson } from "@/lib/admin/rule-sign-off"; +import { formRecords, type FormRecord } from "@/lib/forms"; +import { clinicalRegistryRowsToCorpusEntries } from "@/lib/registry-corpus"; +import { recordToRow, type RegistryRecordRow } from "@/lib/registry-records"; +import { defaultServiceRecords } from "@/lib/registry-fixtures"; +import type { ServiceRecord } from "@/lib/services"; +import { dynamicLogicalId } from "@/lib/site-content/adapters"; +import { + canonicalRegistrySiteContentProjection, + expectedCanonicalPublicRegistrySnapshot, + registryEntryToSiteContentRecord, + type CanonicalPublicRegistrySnapshot, + type CanonicalRegistrySiteContentProjection, +} from "@/lib/site-content/adapters/registry"; +import { compareCanonicalSiteContentIdentifiers, siteContentValueHash } from "@/lib/site-content/site-content-manifest"; +import { reconciliationHash } from "@/lib/site-content/site-content-reconciliation"; + +export type FirstPublicationKind = "service" | "form" | "all"; + +export type ClinicalSignOffEntry = { + id?: string; + code?: string; + slug?: string; + status: string; + reviewedBy: string | null; + reviewedAt: string | null; + reviewedContentSha256?: string | null; +}; + +export type FirstPublicationRejectedRecord = { + kind: "service" | "form"; + recordId: string; + slug: string; + reason: string; +}; + +export type FirstPublicationBatch = { + batchIndex: number; + logicalIds: string[]; + records: CanonicalRegistrySiteContentProjection[]; +}; + +export type FirstPublicationCataloguePlan = { + version: "site-content-first-publication-plan-v1"; + kind: FirstPublicationKind; + batchSize: number; + batchCount: number; + expectedRecordCount: number; + counts: { + services: number; + forms: number; + total: number; + }; + trustedSnapshotDigest: string; + planDigest: string; + trustedSnapshots: CanonicalPublicRegistrySnapshot[]; + batches: FirstPublicationBatch[]; +}; + +export type BuildFirstPublicationOptions = { + kind?: FirstPublicationKind; + batchSize?: number; + services?: readonly ServiceRecord[]; + forms?: readonly FormRecord[]; + serviceReviews?: readonly ClinicalSignOffEntry[]; + formReviews?: readonly ClinicalSignOffEntry[]; + onlySlugs?: readonly string[]; +}; + +export type FirstPublicationCliOptions = { + kind: FirstPublicationKind; + batchSize: number; + dryRun: boolean; + execute: boolean; + out?: string; + servicesReviewPath: string; + formsReviewPath: string; +}; + +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; + +function isValidReviewTimestamp(value: string | null | undefined): boolean { + if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value.trim())) return false; + return Number.isFinite(Date.parse(value.trim())); +} + +function validateSignOffEntry( + kind: "service" | "form", + recordId: string, + slug: string, + review: ClinicalSignOffEntry | undefined, +): FirstPublicationRejectedRecord | null { + if (!review) { + return { + kind, + recordId, + slug, + reason: "missing clinical review record", + }; + } + if (review.status !== "reviewed") { + return { + kind, + recordId, + slug, + reason: `status is "${review.status}" (requires "reviewed")`, + }; + } + if (!isNamedPerson(review.reviewedBy)) { + return { + kind, + recordId, + slug, + reason: `reviewedBy ${JSON.stringify(review.reviewedBy)} is not a named clinician (isNamedPerson check failed)`, + }; + } + if (!isValidReviewTimestamp(review.reviewedAt)) { + return { + kind, + recordId, + slug, + reason: `reviewedAt ${JSON.stringify(review.reviewedAt)} is not a valid UTC ISO timestamp`, + }; + } + return null; +} + +function toOwnerlessRegistryRow( + record: ServiceRecord | FormRecord, + kind: "service" | "form", + recordId: string, + reviewedAt: string, +): RegistryRecordRow { + const inserted = recordToRow(record, "00000000-0000-0000-0000-000000000000", kind); + return { + ...inserted, + id: recordId, + owner_id: null as unknown as string, + validation_status: "locally_reviewed", + created_at: reviewedAt, + updated_at: reviewedAt, + } as RegistryRecordRow; +} + +export function buildFirstPublicationCataloguePlan( + options: BuildFirstPublicationOptions = {}, +): FirstPublicationCataloguePlan { + const kind = options.kind ?? "all"; + const batchSize = options.batchSize ?? 100; + if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 500) { + throw new Error("--batch-size must be an integer between 1 and 500."); + } + + const slugFilter = options.onlySlugs ? new Set(options.onlySlugs.map((slug) => slug.trim().toLowerCase())) : null; + const services = + kind === "form" + ? [] + : (options.services ?? defaultServiceRecords()).filter((record) => + slugFilter ? slugFilter.has(record.slug.toLowerCase()) : true, + ); + const forms = + kind === "service" + ? [] + : (options.forms ?? formRecords).filter((record) => + slugFilter ? slugFilter.has(record.slug.toLowerCase()) : true, + ); + + const serviceReviews = options.serviceReviews ?? loadDefaultServiceReviews(); + const formReviews = options.formReviews ?? loadDefaultFormReviews(); + + const serviceReviewByKey = new Map(); + for (const entry of serviceReviews) { + for (const candidateKey of [entry.id, entry.slug, entry.code]) { + if (typeof candidateKey === "string" && candidateKey.trim()) { + serviceReviewByKey.set(candidateKey.trim().toLowerCase(), entry); + } + } + } + + const formReviewByKey = new Map(); + for (const entry of formReviews) { + for (const candidateKey of [entry.code, entry.id, entry.slug]) { + if (typeof candidateKey === "string" && candidateKey.trim()) { + formReviewByKey.set(candidateKey.trim().toLowerCase(), entry); + } + } + } + + const rejected: FirstPublicationRejectedRecord[] = []; + const ownerlessRows: RegistryRecordRow[] = []; + + for (const service of services) { + const serviceWithId = service as ServiceRecord & { id?: string }; + const stableId = String(service.catalogPayload?.stableId ?? serviceWithId.id ?? service.slug).trim(); + const review = + serviceReviewByKey.get(stableId.toLowerCase()) ?? + serviceReviewByKey.get(service.slug.trim().toLowerCase()) ?? + (serviceWithId.id ? serviceReviewByKey.get(serviceWithId.id.trim().toLowerCase()) : undefined); + const problem = validateSignOffEntry("service", stableId, service.slug, review); + if (problem) { + rejected.push(problem); + continue; + } + ownerlessRows.push(toOwnerlessRegistryRow(service, "service", stableId, review!.reviewedAt!.trim())); + } + + for (const form of forms) { + const formWithOptionalFields = form as FormRecord & { id?: string; formNumber?: string }; + const payload = (form.catalogPayload ?? {}) as { form?: string; id?: string }; + const rawFormCode = + payload.form ?? + formWithOptionalFields.formNumber ?? + form.catalogueLabel ?? + form.primaryContact?.value ?? + form.slug; + const formCode = String(rawFormCode) + .replace(/^Form\s+/i, "") + .trim(); + const formRecordId = String(payload.id ?? formWithOptionalFields.id ?? form.slug).trim(); + const review = + formReviewByKey.get(formCode.toLowerCase()) ?? + formReviewByKey.get(form.slug.trim().toLowerCase()) ?? + formReviewByKey.get(formRecordId.toLowerCase()); + const problem = validateSignOffEntry("form", formCode, form.slug, review); + if (problem) { + rejected.push(problem); + continue; + } + ownerlessRows.push(toOwnerlessRegistryRow(form, "form", formRecordId, review!.reviewedAt!.trim())); + } + + if (rejected.length > 0) { + const preview = rejected + .slice(0, 10) + .map((item) => ` - [${item.kind}] ${item.recordId} (${item.slug}): ${item.reason}`) + .join("\n"); + const remainder = rejected.length > 10 ? `\n ...and ${rejected.length - 10} more record(s).` : ""; + throw new Error( + `First-publication plan refused: ${rejected.length} record(s) lack clinical sign-off by a named person:\n${preview}${remainder}`, + ); + } + + if (ownerlessRows.length === 0) { + throw new Error("First-publication plan refused: no signed-off records matched the selection."); + } + + const corpusEntries = clinicalRegistryRowsToCorpusEntries(ownerlessRows); + const projections: CanonicalRegistrySiteContentProjection[] = []; + const trustedSnapshots: CanonicalPublicRegistrySnapshot[] = []; + + const sortedEntries = [...corpusEntries].sort((left, right) => + compareCanonicalSiteContentIdentifiers(dynamicLogicalId(left), dynamicLogicalId(right)), + ); + + for (const entry of sortedEntries) { + const logicalId = dynamicLogicalId(entry); + const sourceLineage = [ + { + sourceId: + entry.kind === "service" + ? `repository:data/services-snapshot.json#${entry.slug}` + : `repository:data/forms-catalog.json#${entry.slug}`, + sourceHash: siteContentValueHash(entry), + relationship: "derived_from" as const, + }, + ]; + const siteRecord = registryEntryToSiteContentRecord(entry, { logicalId, sourceLineage }); + const snapshot = expectedCanonicalPublicRegistrySnapshot(entry.recordId, siteRecord); + const projection = canonicalRegistrySiteContentProjection( + entry, + { + logicalId, + publicRecordId: entry.recordId, + rowOwnerId: null, + publicationState: "published", + renderedByPublicSite: true, + explicitlyReconciled: true, + sourceLineage, + }, + snapshot, + ); + trustedSnapshots.push(snapshot); + projections.push(projection); + } + + const batches: FirstPublicationBatch[] = []; + for (let offset = 0; offset < projections.length; offset += batchSize) { + const slice = projections.slice(offset, offset + batchSize); + batches.push({ + batchIndex: batches.length, + logicalIds: slice.map((item) => item.record.logicalId), + records: slice, + }); + } + + const trustedSnapshotDigest = reconciliationHash({ + version: "site-content-trusted-snapshot-v1", + records: trustedSnapshots, + }); + + const unsignedPlan = { + version: "site-content-first-publication-plan-v1" as const, + kind, + batchSize, + batchCount: batches.length, + expectedRecordCount: projections.length, + counts: { + services: services.length, + forms: forms.length, + total: projections.length, + }, + trustedSnapshotDigest, + trustedSnapshots, + batches, + }; + + return { + ...unsignedPlan, + planDigest: reconciliationHash(unsignedPlan), + }; +} + +function loadDefaultServiceReviews(path = "data/service-records-review.json"): ClinicalSignOffEntry[] { + const parsed = JSON.parse(readFileSync(resolve(path), "utf8")) as { entries?: ClinicalSignOffEntry[] }; + return Array.isArray(parsed.entries) ? parsed.entries : []; +} + +function loadDefaultFormReviews(path = "data/forms-content-review.json"): ClinicalSignOffEntry[] { + const parsed = JSON.parse(readFileSync(resolve(path), "utf8")) as { forms?: ClinicalSignOffEntry[] }; + return Array.isArray(parsed.forms) ? parsed.forms : []; +} + +export function parseFirstPublicationCliArgs(argv: readonly string[]): FirstPublicationCliOptions { + const options: FirstPublicationCliOptions = { + kind: "all", + batchSize: 100, + dryRun: false, + execute: false, + servicesReviewPath: "data/service-records-review.json", + formsReviewPath: "data/forms-content-review.json", + }; + + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (flag === "--dry-run") { + options.dryRun = true; + continue; + } + if (flag === "--execute") { + options.execute = true; + continue; + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Flag ${flag} requires a value.`); + } + index += 1; + if (flag === "--kind") { + if (value !== "service" && value !== "form" && value !== "all") { + throw new Error("--kind must be service, form, or all."); + } + options.kind = value; + } else if (flag === "--batch-size") { + options.batchSize = Number(value); + } else if (flag === "--out") { + options.out = value; + } else if (flag === "--services-review") { + options.servicesReviewPath = value; + } else if (flag === "--forms-review") { + options.formsReviewPath = value; + } else { + throw new Error(`Unknown argument: ${flag}`); + } + } + + if (options.execute && options.dryRun) { + throw new Error("Specify at most one of --dry-run or --execute."); + } + if (!Number.isInteger(options.batchSize) || options.batchSize < 1 || options.batchSize > 500) { + throw new Error("--batch-size must be an integer between 1 and 500."); + } + return options; +} + +export async function runFirstPublicationCli( + argv: readonly string[] = process.argv.slice(2), + env: NodeJS.ProcessEnv = process.env, +): Promise { + const options = parseFirstPublicationCliArgs(argv); + if (options.execute && env.ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT !== "true") { + throw new Error( + "Refusing live first-publication execution: --execute requires ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT=true.", + ); + } + + const plan = buildFirstPublicationCataloguePlan({ + kind: options.kind, + batchSize: options.batchSize, + serviceReviews: loadDefaultServiceReviews(options.servicesReviewPath), + formReviews: loadDefaultFormReviews(options.formsReviewPath), + }); + + if (options.out) { + writeFileSync(resolve(options.out), `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + } + + return plan; +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) { + runFirstPublicationCli().then( + (plan) => { + console.log( + `First-publication plan ready: ${plan.counts.total} record(s) across ${plan.batchCount} batch(es) (digest ${plan.planDigest}).`, + ); + }, + (error) => { + console.error(`publish-site-content-first-catalogue: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }, + ); +} diff --git a/src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx b/src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx new file mode 100644 index 0000000000..383f995310 --- /dev/null +++ b/src/app/(search-app)/teaching/assessments/respond/[token]/page.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { CheckCircle2, ShieldAlert, Send } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { WorkButton, WorkCard, WorkChip, WorkChips, WorkSectionLabel, WorkTag } from "@/components/mode-kit/work"; +import { AssessHeader, AssessNote, AssessTextField, OptionCard } from "@/components/teaching/assessments/assess-kit"; +import { + CASE_COMPLEXITIES, + SUPERVISION_LEVELS, + type CaseComplexity, + type SupervisionLevel, +} from "@/lib/teaching/assessments/content"; +import { + getEpaMagicLinkByToken, + submitEpaMagicLinkResponse, + type EpaMagicLinkRecord, +} from "@/lib/teaching/assessments/magic-link"; +import { EPA_FEEDBACK_MAX } from "@/lib/teaching/assessments/model"; +import { checkPatientDetail } from "@/lib/work-text/patient-detail-check"; + +const LEVEL_OPTIONS = SUPERVISION_LEVELS.map((item) => ({ + id: item.id, + title: item.formLabel, + detail: item.detail, +})); + +export default function EpaMagicLinkRespondPage({ + tokenProp, +}: { + readonly tokenProp?: string; +} = {}) { + const params = useParams<{ token?: string }>(); + const token = tokenProp ?? (typeof params?.token === "string" ? params.token : ""); + + const [record, setRecord] = useState(() => { + if (!token) return null; + const local = getEpaMagicLinkByToken(token); + return local.ok ? local.request : (local.request ?? null); + }); + const [statusError, setStatusError] = useState(() => { + if (!token) return "That assessment link was not found."; + const local = getEpaMagicLinkByToken(token); + return local.ok ? null : local.message; + }); + const [loading, setLoading] = useState(() => { + if (!token) return false; + const local = getEpaMagicLinkByToken(token); + return !local.ok && local.status === "not_found"; + }); + + const [level, setLevel] = useState(null); + const [complexity, setComplexity] = useState(null); + const [strengths, setStrengths] = useState(""); + const [improvements, setImprovements] = useState(""); + const [formError, setFormError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [completed, setCompleted] = useState(() => { + if (!token) return null; + const local = getEpaMagicLinkByToken(token); + return !local.ok && local.status === "completed" && local.request ? local.request : null; + }); + + useEffect(() => { + if (!token) return; + const local = getEpaMagicLinkByToken(token); + if (local.ok || local.status !== "not_found") { + return; + } + + let cancelled = false; + void fetch(`/api/teaching/assessments/respond/${encodeURIComponent(token)}`, { + headers: { Accept: "application/json" }, + }) + .then(async (response) => { + const body = (await response.json().catch(() => null)) as { + request?: EpaMagicLinkRecord; + message?: string; + } | null; + if (cancelled) return; + if (response.ok && body?.request) { + setRecord(body.request); + setStatusError(null); + } else { + setStatusError(body?.message ?? "That assessment link was not found."); + } + }) + .catch(() => { + if (!cancelled) { + setStatusError("Could not load this assessment link. Check your connection and try again."); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [token]); + + const patientProblem = checkPatientDetail(strengths) ?? checkPatientDetail(improvements); + + async function handleSubmit() { + setFormError(null); + if (!level) { + setFormError("Choose a supervision level first."); + return; + } + if (patientProblem) { + setFormError(patientProblem.body); + return; + } + if (strengths.trim().length > EPA_FEEDBACK_MAX || improvements.trim().length > EPA_FEEDBACK_MAX) { + setFormError(`Keep feedback under ${EPA_FEEDBACK_MAX} characters.`); + return; + } + + const localResult = submitEpaMagicLinkResponse(token, { + level, + complexity, + strengths, + improvements, + }); + if (localResult.ok) { + setCompleted(localResult.request); + setRecord(localResult.request); + return; + } + if (localResult.status !== "not_found") { + setFormError(localResult.message); + return; + } + + setSubmitting(true); + try { + const response = await fetch(`/api/teaching/assessments/respond/${encodeURIComponent(token)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + level, + complexity, + strengths, + improvements, + }), + }); + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + request?: EpaMagicLinkRecord; + message?: string; + } | null; + if (response.ok && payload?.request) { + setCompleted(payload.request); + setRecord(payload.request); + } else { + setFormError(payload?.message ?? "Could not submit this EPA response."); + } + } catch { + setFormError("Could not submit this EPA response. Check your connection and try again."); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + {loading ? ( + Loading assessment request… + ) : completed ? ( + +
+
+
+

+ {completed.epaCode} ({completed.epaTitle}) has been submitted. No CLA account is needed; the hospital + Medical Education Unit reviews external assessor entries. +

+
+
+ ) : statusError || !record ? ( + {statusError ?? "That assessment link was not found."} + ) : ( +
+ +
+
+ {record.traineeName} + {record.supervisorRole} +
+

+ {record.epaCode}: {record.epaDetail} +

+
+
+ + + + Case complexity (optional) + + {CASE_COMPLEXITIES.map((item) => ( + setComplexity(complexity === item.id ? null : item.id)} + > + {item.title} + + ))} + + + + + + + {patientProblem ? ( + {patientProblem.body} + ) : formError ? ( + {formError} + ) : null} + + void handleSubmit()} + > + {submitting ? "Submitting…" : "Submit EPA assessment"} + +
+ )} +
+ ); +} diff --git a/src/app/api/roster/shifts/manual/route.ts b/src/app/api/roster/shifts/manual/route.ts index 17d191daa6..7cb296bc64 100644 --- a/src/app/api/roster/shifts/manual/route.ts +++ b/src/app/api/roster/shifts/manual/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/api-rate-limit"; import { isDemoMode } from "@/lib/env"; import { jsonError, publicErrorResponse } from "@/lib/http"; +import { detectOwnShiftConflicts } from "@/lib/roster/shifts/diff"; import { onCallManualShiftRequestSchema } from "@/lib/roster/shifts/model"; import { addManualShifts } from "@/lib/roster/shifts/repository"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -37,7 +38,8 @@ export async function POST(request: Request) { if (rateLimit.limited) return rateLimitJsonResponse("Too many requests. Try again shortly.", rateLimit); const body = await parseJsonBody(request, onCallManualShiftRequestSchema, "That shift could not be saved."); const shifts = await addManualShifts(supabase, user.id, [body.shift], body.repeatWeeks); - return NextResponse.json({ shifts }, { headers: noStore }); + const conflicts = detectOwnShiftConflicts(shifts); + return NextResponse.json({ shifts, conflicts }, { headers: noStore }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); diff --git a/src/app/api/roster/shifts/route.ts b/src/app/api/roster/shifts/route.ts index 52c969658d..c0516139af 100644 --- a/src/app/api/roster/shifts/route.ts +++ b/src/app/api/roster/shifts/route.ts @@ -13,7 +13,7 @@ import { clearRosterSettings } from "@/lib/roster/settings"; import { removeAllOwnerSubscriptions } from "@/lib/roster/alerts/subscriptions"; import { removeAllOwnerLeave, withdrawRosterRequests } from "@/lib/roster/team/delete-my-data"; import { demoOnCallShifts } from "@/lib/roster/shifts/demo-shifts"; -import { shiftIsInWindow } from "@/lib/roster/shifts/diff"; +import { detectOwnShiftConflicts, shiftIsInWindow } from "@/lib/roster/shifts/diff"; import { onCallShiftImportRequestSchema } from "@/lib/roster/shifts/model"; import { deleteOwnerShifts, @@ -157,7 +157,8 @@ export async function POST(request: Request) { fetchOwnerShifts(supabase, user.id, listFrom()), fetchLatestShiftImport(supabase, user.id), ]); - return NextResponse.json({ shifts, latestImport }, { headers: noStore }); + const conflicts = detectOwnShiftConflicts(shifts); + return NextResponse.json({ shifts, latestImport, conflicts }, { headers: noStore }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); diff --git a/src/app/api/teaching/assessments/respond/[token]/route.ts b/src/app/api/teaching/assessments/respond/[token]/route.ts new file mode 100644 index 0000000000..337fa2561e --- /dev/null +++ b/src/app/api/teaching/assessments/respond/[token]/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { jsonError, publicErrorResponse } from "@/lib/http"; +import { + consumePublicApiRateLimit, + EpaMagicLinkError, + getEpaMagicLinkByToken, + registerEpaMagicLinkRecord, + submitEpaMagicLinkResponse, + type CreateEpaMagicLinkInput, + type EpaMagicLinkSubmission, +} from "@/lib/teaching/assessments/magic-link"; +import { parseJsonBody } from "@/lib/validation/body"; + +export const runtime = "nodejs"; + +const noStore = { "Cache-Control": "private, no-store, max-age=0" }; + +const submissionSchema = z + .object({ + level: z.enum(["direct", "proximal", "minimal"]).nullable().optional(), + entrustmentLevel: z.enum(["direct", "proximal", "minimal"]).nullable().optional(), + complexity: z.enum(["low", "medium", "high"]).nullable().optional(), + observed: z.enum(["direct", "team"]).nullable().optional(), + strengths: z.string().max(1000).nullable().optional(), + note: z.string().max(1000).nullable().optional(), + improvements: z.string().max(1000).nullable().optional(), + better: z.string().max(1000).nullable().optional(), + assessorName: z.string().max(200).nullable().optional(), + }) + .strict(); + +const registrationSchema = z + .object({ + epaCode: z.union([z.number().int().min(1).max(4), z.string().min(1).max(20)]), + traineeName: z.string().min(1).max(200), + supervisorName: z.string().min(1).max(200), + supervisorRole: z.string().max(100).optional(), + clinicalSetting: z.string().max(200).nullable().optional(), + }) + .strict(); + +export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) { + try { + const rate = consumePublicApiRateLimit(request, "teaching-epa-magic-link-read"); + if (rate.limited) { + return publicErrorResponse("Too many requests. Wait a minute and try again.", 429, { + code: "rate_limited", + headers: { ...noStore, "Retry-After": String(rate.retryAfterSeconds) }, + }); + } + const { token } = await params; + const lookup = getEpaMagicLinkByToken(token); + if (!lookup.ok) { + const status = lookup.status === "not_found" ? 404 : 410; + return publicErrorResponse(lookup.message, status, { + code: lookup.code, + headers: noStore, + }); + } + return NextResponse.json({ request: lookup.request }, { headers: noStore }); + } catch (error) { + if (error instanceof EpaMagicLinkError) { + return publicErrorResponse(error.message, error.status, { + code: error.code, + headers: noStore, + }); + } + return jsonError(error); + } +} + +export async function PUT(request: Request, { params }: { params: Promise<{ token: string }> }) { + try { + const rate = consumePublicApiRateLimit(request, "teaching-epa-magic-link-write"); + if (rate.limited) { + return publicErrorResponse("Too many requests. Wait a minute and try again.", 429, { + code: "rate_limited", + headers: { ...noStore, "Retry-After": String(rate.retryAfterSeconds) }, + }); + } + const { token } = await params; + const body = (await parseJsonBody( + request, + registrationSchema, + "Check the EPA magic-link details and try again.", + )) as CreateEpaMagicLinkInput; + const record = registerEpaMagicLinkRecord(token, body); + return NextResponse.json({ ok: true, request: record }, { headers: noStore }); + } catch (error) { + if (error instanceof EpaMagicLinkError) { + return publicErrorResponse(error.message, error.status, { + code: error.code, + headers: noStore, + }); + } + return jsonError(error); + } +} + +export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) { + try { + const rate = consumePublicApiRateLimit(request, "teaching-epa-magic-link-write"); + if (rate.limited) { + return publicErrorResponse("Too many requests. Wait a minute and try again.", 429, { + code: "rate_limited", + headers: { ...noStore, "Retry-After": String(rate.retryAfterSeconds) }, + }); + } + const { token } = await params; + const body = (await parseJsonBody( + request, + submissionSchema, + "Check the EPA response and try again.", + )) as EpaMagicLinkSubmission; + const result = submitEpaMagicLinkResponse(token, body); + if (!result.ok) { + const status = + result.status === "not_found" + ? 404 + : result.status === "expired" + ? 410 + : result.status === "completed" + ? 409 + : 400; + return publicErrorResponse(result.message, status, { + code: result.code, + headers: noStore, + }); + } + return NextResponse.json({ ok: true, request: result.request }, { headers: noStore }); + } catch (error) { + if (error instanceof EpaMagicLinkError) { + return publicErrorResponse(error.message, error.status, { + code: error.code, + headers: noStore, + }); + } + return jsonError(error); + } +} diff --git a/src/components/calculators/clinical-math-panel.tsx b/src/components/calculators/clinical-math-panel.tsx new file mode 100644 index 0000000000..35d2862a84 --- /dev/null +++ b/src/components/calculators/clinical-math-panel.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { Activity, ChevronDown, ChevronUp, Calculator, FlaskConical, HeartPulse } from "lucide-react"; +import React, { useId, useState } from "react"; + +import { focusRing } from "@/components/card-recipes"; +import { TextField } from "@/components/ui/text-field"; +import { cn, eyebrowText, toneDanger, toneSuccess, toneWarning } from "@/components/ui-primitives"; +import { + calculateCkdEpi2021, + calculateCockcroftGault, + calculateProportionalLithiumDose, + calculateQtc, + evaluateLithiumLevel, + type BiologicalSex, + type CreatinineUnit, + type LithiumIndication, +} from "@/lib/calculators/clinical-math"; + +type ActiveTab = "renal" | "qtc" | "lithium"; + +export interface ClinicalMathPanelProps { + defaultOpen?: boolean; + defaultTab?: ActiveTab; +} + +export function ClinicalMathPanel({ defaultOpen = false, defaultTab = "renal" }: ClinicalMathPanelProps) { + const panelId = useId(); + const [open, setOpen] = useState(defaultOpen); + const [activeTab, setActiveTab] = useState(defaultTab); + + // Renal state + const [age, setAge] = useState("45"); + const [sex, setSex] = useState("male"); + const [weightKg, setWeightKg] = useState("75"); + const [heightCm, setHeightCm] = useState(""); + const [creatinine, setCreatinine] = useState("90"); + const [creatinineUnit, setCreatinineUnit] = useState("umol/L"); + + // QTc state + const [qtMs, setQtMs] = useState("400"); + const [hrBpm, setHrBpm] = useState("68"); + + // Lithium state + const [lithiumLevel, setLithiumLevel] = useState("0.7"); + const [lithiumIndication, setLithiumIndication] = useState("maintenance"); + const [currentDoseMg, setCurrentDoseMg] = useState("750"); + const [targetLevel, setTargetLevel] = useState("0.8"); + const [tabletIncrementMg, setTabletIncrementMg] = useState<125 | 225 | 250 | 450>(125); + + const parsedAge = Number.parseFloat(age); + const parsedWeight = Number.parseFloat(weightKg); + const parsedHeight = heightCm.trim() === "" ? undefined : Number.parseFloat(heightCm); + const parsedCreatinine = Number.parseFloat(creatinine); + + const cgResult = calculateCockcroftGault({ + age: parsedAge, + weightKg: parsedWeight, + heightCm: parsedHeight, + sex, + serumCreatinine: parsedCreatinine, + creatinineUnit, + }); + + const ckdResult = calculateCkdEpi2021({ + age: parsedAge, + sex, + serumCreatinine: parsedCreatinine, + creatinineUnit, + }); + + const qtcResult = calculateQtc({ + qtMs: Number.parseFloat(qtMs), + heartRateBpm: Number.parseFloat(hrBpm), + sex, + }); + + const parsedLithiumLevel = Number.parseFloat(lithiumLevel); + const lithiumEval = evaluateLithiumLevel({ + serumLevelMmolL: parsedLithiumLevel, + indication: lithiumIndication, + age: parsedAge, + }); + + const lithiumDose = calculateProportionalLithiumDose({ + currentDoseMg: Number.parseFloat(currentDoseMg), + currentLevelMmolL: parsedLithiumLevel, + targetLevelMmolL: Number.parseFloat(targetLevel), + tabletIncrementMg, + }); + + return ( +
+
+

+

+ +
+ + {open ? ( +
+
+ + + +
+ + {activeTab === "renal" ? ( +
+
+ setAge(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-age" + /> + setWeightKg(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-weight" + /> + setHeightCm(e.target.value)} + placeholder="e.g. 170" + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-height" + /> + setCreatinine(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-creatinine" + /> +
+ +
+
+ Sex: + {(["male", "female"] as const).map((option) => ( + + ))} +
+
+ Unit: + {(["umol/L", "mg/dL"] as const).map((unit) => ( + + ))} +
+
+ + {cgResult.isValid && ckdResult.isValid ? ( +
+
+ + Cockcroft-Gault CrCl: {cgResult.crClMlMin} mL/min + + + CKD-EPI 2021 eGFR: {ckdResult.egfr} mL/min/1.73m² + +
+ {cgResult.ibwKg ? ( +

+ Ideal Body Weight (Devine): {cgResult.ibwKg} kg · Weight used: {cgResult.effectiveWeightKg} kg ( + {cgResult.weightBasis}) +

+ ) : null} + {cgResult.warnings.map((w, idx) => ( +

+ {w} +

+ ))} +
+ ) : ( +
+ {[...cgResult.errors, ...ckdResult.errors].join(" · ")} +
+ )} +
+ ) : null} + + {activeTab === "qtc" ? ( +
+
+ setQtMs(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-qt" + /> + setHrBpm(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-hr" + /> +
+ + {qtcResult.isValid ? ( +
+
+ + Fridericia QTc: {qtcResult.fridericiaMs} ms · Bazett QTc: {qtcResult.bazettMs} ms + + + Risk: {qtcResult.riskCategory?.replace(/_/g, " ")} + +
+ {qtcResult.alerts.map((alert, idx) => ( +

+ {alert} +

+ ))} +
+ ) : ( +
+ {qtcResult.errors.join(" · ")} +
+ )} +
+ ) : null} + + {activeTab === "lithium" ? ( +
+
+ setLithiumLevel(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-lithium-level" + /> + setCurrentDoseMg(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-lithium-dose" + /> + setTargetLevel(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-lithium-target" + /> + setAge(e.target.value)} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" + data-testid="clinical-math-lithium-age" + /> +
+ +
+
+ Indication: + {( + [ + ["maintenance", "Maintenance"], + ["acute_mania", "Acute mania"], + ] as const + ).map(([val, label]) => ( + + ))} +
+
+ Tablet step: + {([125, 225, 250, 450] as const).map((stepMg) => ( + + ))} +
+
+ +
+
+ + Level status: {lithiumEval.status} (target {lithiumEval.targetRange.min}–{lithiumEval.targetRange.max}{" "} + mmol/L) + + {lithiumDose.isValid ? ( + + Suggested dose: {lithiumDose.suggestedDoseMg} mg/day + + ) : null} +
+ {lithiumEval.clinicalGuidance.map((g, idx) => ( +

+ {g} +

+ ))} + {lithiumDose.warnings?.map((w, idx) => ( +

+ {w} +

+ ))} + {!lithiumDose.isValid ? ( +

+ {lithiumDose.errors.join(" · ")} +

+ ) : null} +
+
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 2ad61b01d3..1eb0e01e3a 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -45,6 +45,7 @@ import { type CalculatorFixture, } from "./calculator-fixtures"; import { CalculatorSheet } from "./calculator-sheet"; +import { ClinicalMathPanel } from "./clinical-math-panel"; import { calculatorRecordById } from "./calculator-routes"; import { MetaPill, @@ -587,12 +588,14 @@ export function CalculatorsSearchPage({ sidebar={ <> + } sidebarMobile={
+
} diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index c85a393d4b..732bc00b3f 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -105,6 +105,7 @@ import { import type { ClinicalDocument, SearchScopeSummary } from "@/lib/types"; import type { ClientDocumentMatch } from "@/lib/answer-client-payload"; import type { RegistryRequestStatus } from "@/lib/use-registry-records"; +import { useVirtualWindow } from "@/lib/ui/use-virtual-window"; import { sortResultItems } from "@/lib/result-sort"; import { documentRelevanceLabel } from "./relevance-score"; @@ -953,21 +954,23 @@ function DocumentSearchResultsPanelImpl({ activeFacetKeys.join(","), sortedMatches.map((document) => document.document_id).join(","), ].join("\0"); - const [visibleCountState, setVisibleCountState] = useState({ - signature: resultsSignature, - count: DOCUMENT_RESULTS_INITIAL_WINDOW, + const { + visibleCount, + hasMore: hasMoreMatches, + showMore: showMoreMatches, + sentinelRef: documentResultsSentinelRef, + } = useVirtualWindow({ + totalCount: sortedMatches.length, + initialCount: DOCUMENT_RESULTS_INITIAL_WINDOW, + stepCount: DOCUMENT_RESULTS_PAGE_SIZE, + resetKey: resultsSignature, }); - if (visibleCountState.signature !== resultsSignature) { - setVisibleCountState({ signature: resultsSignature, count: DOCUMENT_RESULTS_INITIAL_WINDOW }); - } - const visibleCount = Math.min(visibleCountState.count, sortedMatches.length); const renderedMatches = sortedMatches.slice(0, visibleCount); const coverImageIds = useMemo( () => renderedMatches.map((doc) => doc.coverImageId).filter((id): id is string => Boolean(id)), [renderedMatches], ); useBatchSignedImageUrls(coverImageIds); - const hasMoreMatches = visibleCount < sortedMatches.length; const recordMatchCount = recordMatches.length; const shouldShowHome = showHome || !trimmedQuery; @@ -1791,17 +1794,13 @@ function DocumentSearchResultsPanelImpl({ {hasMoreMatches ? (