Compare commits

..

75 commits

Author SHA1 Message Date
Daniel
add2ded9db chore: drop orphan public/vendor/tiptap.bundle.js
90 KB pre-built Tiptap bundle from the old vanilla CMS. Not
referenced anywhere — Tiptap now comes from npm through the Vite
bundle (commit e323faa). Deleting the whole public/vendor/ directory
since this was its only file.

Confirmed no references via:
  grep -rn "vendor/tiptap\\|tiptap.bundle" . --include="*.ts"
    --include="*.tsx" --include="*.html" --include="*.js"
2026-04-24 05:24:09 +02:00
Daniel
9ebdcaf4b6 feat(notes): structured ROS + PE + ICD-10 diagnosis pickers (WellVisit, SickVisit)
Ports the vanilla ROS / Physical Exam / ICD-10 diagnosis cards from
public/js/shadess.js (@be14578) — renderRosRows, wireRosContainer,
renderDxComponent — which the earlier React port had collapsed into
plain-text textareas.

Clinical data → shared/clinical/ros-pe-dx.ts:
  • ROS_SYSTEMS (15 systems, each with label + detail hint)
  • PE_SYSTEMS (17 systems)
  • COMMON_DX (28 pediatric quick-pick ICD-10 codes)
  • formatRosForAI / formatDxForAI — byte-identical to vanilla so
    the server-side prompt context is unchanged.
  Tests: ros-pe-dx.test.ts asserts the table counts, ordering, and
  format strings — catches the class of bug where an LLM silently
  drops an entry or re-orders the clinical reference during a port.

New React components:
  client/src/components/RosPeTable.tsx — tri-state row (WNL/Abnormal/
    Not reviewed) with auto-revealed note field on Abnormal.
    rosAllWnl / rosClear helpers mirror the "All WNL" / "Clear"
    buttons from vanilla. Accepts a btnLabels prop so the same
    component renders ROS ("WNL"/"Not reviewed") and PE ("Normal"/
    "Not examined").

  client/src/components/DxPicker.tsx — ICD-10 live search via NLM
    Clinical Tables API (free, no auth, CORS-OK) with 280ms debounce
    + AbortController; chip-style selected tags with × remove;
    28-entry common-diagnosis quick-pick grid. Enter key adds the
    top result.

Wired into WellVisit / VisitNote.tsx and SickVisit.tsx:
  • Submit now sends ros / physicalExam / diagnoses as formatted
    strings to /api/well-visit/note and /api/sick-visit/note.
  • State persists through the Save/Load encounter flow via
    partialData → the rosData/peData/diagnoses round-trip.
2026-04-24 05:24:09 +02:00
Daniel
b2ef470180 feat(cms): Tiptap rich-text editor, slide editor, AI generator, WebDAV picker
Completes the CMS port to feature parity with public/js/learningHub.js
(@be14578). Previously the React CMS had only a plain-textarea body,
no slide editor for presentations, and no AI content generation —
three large gaps from the vanilla app.

New components:
  client/src/components/RichTextEditor.tsx — Tiptap/ProseMirror editor
    with the vanilla tp-toolbar feature set: bold/italic/underline/
    strike, H2/H3, bullet/ordered/quote/codeblock lists, link-with-
    URL-bar, and clear formatting. Three variants (default/mini/
    option) match vanilla's buildTpToolbar(mini, isOption).

  client/src/pages/cms/SlideEditor.tsx — per-slide Tiptap editor +
    slide navigator (move/add/remove). Slides join back to the body
    column with the vanilla \n---\n separator so the Learning Hub
    viewer renders them unchanged.

  client/src/pages/cms/AiGenerator.tsx — generate content via three
    sources (topic / upload / Nextcloud WebDAV). POSTs multipart
    to /api/admin/learning/ai-generate, matching the vanilla
    runAiGenerate flow exactly. WebDAV file browser uses the
    existing /api/admin/learning/webdav-browse endpoint. Auto-
    hides the Nextcloud tab when the user isn't connected.

Updated:
  QuestionsEditor — question text, option text, per-option
    explanation, and question explanation are now all Tiptap-backed
    so quiz authoring matches content authoring in feel.

  ContentEditor — "Generate with AI" button opens the AI panel;
    presentation body uses SlideEditor; everything else uses
    RichTextEditor with a 320px min height.

Dependencies added:
  @tiptap/react @tiptap/pm @tiptap/starter-kit
  @tiptap/extension-link @tiptap/extension-underline
2026-04-24 05:24:09 +02:00
Daniel
5342fd1205 feat(notes): editable AI output + automatic correction tracking
Restores two pieces of vanilla behavior the React port silently dropped:

1. The output divs were contenteditable in vanilla — clinicians fix
   AI mistakes inline before saving the encounter or copying out.
   The early React port rendered the output as a read-only div, so
   any edit forced a copy-paste workflow.

2. correctionTracker.js (public/js/correctionTracker.js) saved every
   meaningful inline edit to user_memories under category
   correction_<section> so the AI learns user preferences. Settings
   → Corrections still showed them, but nothing in React was *writing*
   them — the loop was broken.

New EditableResult component wraps the result body in a textarea,
captures the AI baseline on first render / after refine|shorten, and
on blur posts to /api/memories/correction (only when the edit clears
the noise threshold from vanilla — wordDiff ≥ 2 OR charDiff ≥ 20 on
outputs > 100 chars).

Wired into all 7 note pages:
  Encounter   → section: 'encounter'
  Dictation   → section: 'hpi'
  SOAP        → section: 'soap'
  SickVisit   → section: 'sickvisit'
  WellVisit   → section: 'wellvisit'
  HospitalCourse → section: null   (server enum has no correction_hospital)
  ChartReview    → section: null   (server enum has no correction_chart)

Tests:
  shared/clinical/correction-tracker.ts (+ .test.ts) — pure heuristic
  with vectors covering the noise floor (single-word swap on long
  text → skipped; new sentence → tracked; large char diff → tracked).
  Lives in shared/ so the root vitest config picks it up; the React
  component imports from there.
2026-04-24 05:24:09 +02:00
Daniel
84c35055c6 feat(cms): port Learning Hub Content Manager (admin/moderator only)
Closes the largest remaining gap from the vanilla→React migration.
Vanilla had ~308 lines of HTML in cms.html plus ~1000 lines of CMS
logic in learningHub.js, all gone since the vanilla deletion at
be14578. Server endpoints under /api/learning-admin survived but
had no React UI.

New page at /cms (sidebar nav adminOnly):

  client/src/pages/Cms.tsx                — shell, list/edit views
  client/src/pages/cms/StatsBar.tsx       — 6-cell metrics
  client/src/pages/cms/CategoriesPanel.tsx — list + add + delete +
                                             status & category filters
  client/src/pages/cms/ContentList.tsx    — table with toolbar
                                             (new article/quiz/pearl/
                                             presentation), search,
                                             publish toggle, delete
  client/src/pages/cms/ContentEditor.tsx  — title/category/type/
                                             subject/body/published,
                                             embeds QuestionsEditor
                                             when type=quiz
  client/src/pages/cms/QuestionsEditor.tsx — Q+options builder
                                             (mcq/multi/true_false)
                                             with per-option
                                             explanation
  client/src/pages/cms/cms-types.ts       — shared CMS types

Destructive actions (delete category, delete content, delete
question) all use ConfirmModal — Daniel's no-native-dialog rule.

Intentionally NOT in this first cut (each is a follow-up if used):
  • AI generation panel (vanilla lh-ai-panel)
  • WebDAV file picker for AI sources
  • Drag-and-drop file upload for AI ingest
  • Rich-text body editor — body is a textarea
  • Slide editor for presentations — body holds JSON
2026-04-24 05:24:09 +02:00
Daniel
8253b34dc5 feat(wellvisit): port the 4 sub-tabs vanilla had — By Visit / Milestones / SSHADESS / Note
Earlier WellVisit was a single-pane skeleton — the comment in that
file even admitted "Milestones and SSHADESS sub-tabs land in a
follow-up". This commit lands them, faithful to public/components/
wellvisit.html and the three modules behind it (wellVisit.js,
milestones.js, shadess.js, all @be14578).

WellVisit.tsx is now an 83-line shell that lazy-loads four panels:

  client/src/pages/wellvisit/
    ByVisitAge.tsx  — per-visit AAP Bright Futures recommendations
                       (billing codes, measurements, vaccines,
                       sensory/dev/proc/oral screens, growth +
                       feeding, expected reflexes, BMI table,
                       notes). Status buttons mirror vanilla
                       (Given/Refused/Deferred/Already-Done for
                       vaccines; Done/Refused/N-A for screens).
                       Statuses persist to localStorage under
                       ped_visit_statuses (same key as vanilla).
    Milestones.tsx  — checklist per age group, three-state toggle
                       (✓/✗/blank=not assessed), All-Yes/Clear,
                       Generate → /api/generate-milestone-narrative,
                       3-sentence summary → /api/generate-milestone-
                       summary, Copy-to-Note bridge.
    Shadess.tsx     — 8 SSHADESS domains verbatim from shadess.js
                       (Strengths, School, Home, Activities, Drugs,
                       Emotions/Eating, Sexuality, Safety) with
                       concern_if auto-flag, skip toggle, manual
                       concern toggle, optional Listen-In recorder,
                       Generate → /api/well-visit/shadess.
    VisitNote.tsx   — pre-existing note generator + carry-over
                       pickup from sessionStorage so the user can
                       flow Milestones → SSHADESS → Note without
                       retyping. Now also includes the recorder.

Server: GET /api/schedule-data now also returns wellVisitCodes,
growthReference, reflexesReference, and bmiClassification so the
React side has everything it needs without a second round trip.

Tests:
  shared/clinical/visit-status.ts (+ .test.ts) — pure helpers
  for the visitId → growth/reflex key mapping (the vanilla
  tables collapse 6y/7y/8y/9y/10y onto one window, etc.) and
  the reflex-status color rules. Lives in shared/ so the root
  vitest config picks it up; ByVisitAge.tsx imports from there.
2026-04-24 05:24:09 +02:00
Daniel
02c67f5576 feat(notes): restore Refine / Shorter / Read / Nextcloud export on every note page
Port of the vanilla refine-bar + output-actions block that every note
page had (see public/components/{encounter,dictation,soap,sickvisit,
wellvisit,hospital,chart}.html at commit be14578).

One shared OutputActions component renders under every generated
note's body with:

  📋 Copy     → navigator.clipboard
  🔊 Read     → POST /api/text-to-speech  (audio/mpeg → Audio playback
                with stop-on-second-click)
  ☁️ Export   → POST /api/nextcloud/export  (uses each page's exportLabel
                as the filename prefix, exportType in the type field)
  ✏️ Refine   → POST /api/refine — free-text instructions textarea,
                sourceContext carries the original transcript so the
                model can reference the source when following orders
  📏 Shorter  → POST /api/shorten

Busy/success/error state shown inline per-component — no native alerts.
Refine result replaces the existing output via onUpdate, matching
vanilla setOutputText() behavior.

Wired into all 7 note pages (Encounter, Dictation, SOAP, Sick Visit,
Well Visit, Hospital Course, Chart Review) with per-page exportLabel
/ exportType matching the vanilla data-label values.
2026-04-24 05:24:09 +02:00
Daniel
198002b769 feat(notes): restore audio recording + save/load across all 7 note pages
Recovered from git history (commit be14578) — the vanilla recording UI
was deleted during the "minimum-viable" note ports without being
re-implemented. Every note page now gets the full vanilla behavior
back:

Recording (Encounter, Dictation, SOAP, Sick Visit, Hospital Course):
  • AudioRecorder — MediaRecorder mono/16kHz/EC+NS/opus 32kbps
  • Pause / Resume (native where supported, restart-on-same-stream
    fallback for Safari)
  • Live preview via Web Speech API (opt-in in Settings)
  • On stop → upload to /api/transcribe; fall back to live preview
    if server unavailable or blob > 24 MB
  • Failed uploads → /api/audio-backups (IndexedDB fallback)

Save / Load / New-patient toolbar (all 7 note pages):
  • sessionStorage keys _savedEncId_<type> + _idempKey_<type>
    survive page refresh + sign-out within the same tab
  • Optimistic locking via expected_version (409 → "Someone else
    edited this encounter")
  • Load popover lists saved encounters of matching type only
  • Draft #N chip shows current session-bound row

Well Visit and Chart Review: toolbar only — vanilla had no recorder
on those tabs (paste-based workflows).

New components:
  client/src/components/Recorder.tsx
  client/src/components/EncounterToolbar.tsx

New libraries:
  client/src/lib/recorder.ts            — AudioRecorder class
  client/src/lib/transcribe.ts          — /api/transcribe + audio backup
  client/src/lib/web-speech.ts          — webkit speech preview + dedupe
  client/src/lib/encounter-persistence.ts — save/load/version tracking
2026-04-24 05:24:09 +02:00
github-actions[bot]
03a8a7316e Release v6.47.1 2026-04-24 00:57:33 +00:00
Daniel
284ce0fb8a fix(sw): point service worker at React shell + bump cache version
The previous SW (pedscribe-v12) precached /js/app.js, /js/auth.js,
and /css/styles.css — all deleted in the previous commit. Without
this fix, every existing PWA install would 404 on the next SW
install attempt and serve stale cached vanilla pages forever.

Changes:
  • CACHE_NAME bumped to pedscribe-v13 → activate event evicts the
    v12 cache (and any earlier).
  • SHELL_ASSETS narrowed to [/, /manifest.json]. Hashed Vite
    assets are not precached because their filenames change on
    every build — they're served fresh from the network with Vite's
    immutable Cache-Control as backup.
  • Fetch handler:
      - /api/* → bypass SW (medical data must always be fresh).
      - /app/assets/* → network-first, cache fallback for offline.
      - everything else → cache-first with stale-while-revalidate.
  • Promise.allSettled around cache.add() so a single missing
    asset can't break the whole SW install.

Existing PWA users get the new shell on their next visit
(skipWaiting + clients.claim).
2026-04-24 02:57:23 +02:00
Daniel
f71e1d6570 chore: delete vanilla tree — React+Tailwind is the only client now
Closes the migration. Every line of vanilla JS/CSS/HTML that used to
render the user-facing app is gone. The React bundle in public/app/
is the entire client.

Deleted (everything was 100% covered by ported React equivalents):
  • public/index.html              (vanilla shell — auth screen + tab loader)
  • public/js/                     (~30 vanilla modules: app.js, auth.js,
                                     admin.js, calculators.js, peGuide.js,
                                     bedside/*, learningHub.js, encounters.js,
                                     etc. — all replaced by client/src/pages/*
                                     and client/src/data/*)
  • public/components/             (18 lazy-loaded HTML fragments — not
                                     loaded by any React route)
  • public/css/styles.css          (vanilla design system — superseded by
                                     Tailwind + shadcn classes throughout
                                     the React tree)
  • public/e2e-harness.html        (vanilla-only Playwright bootstrapper)
  • e2e/tests/bedside-smoke.spec.js
  • e2e/tests/top-calculators.spec.js
                                   (the two e2e specs that exercised
                                     /e2e-harness.html and the vanilla
                                     calculators directly — replaced by
                                     calculators-react.spec.js +
                                     bedside-react.spec.js + the 136
                                     vitest parity tests in
                                     shared/clinical/*.test.ts)

Moved (still needed by the backend):
  • public/js/pediatricScheduleData.js → src/data/pediatric-schedule-data.js
    Required by src/routes/wellVisit.ts at runtime; should never have been
    in public/ anyway since it carries server-side schedule + growth +
    BMI reference tables and was being served as a 2120-line public JS
    blob to every browser. Not in public/ means it's not exposed to
    anonymous web requests anymore.

server.ts
  • Removed the dead getTemplatedIndex() / INDEX_PATH machinery that
    used to BUILD_ID-stamp /js/* and /css/* references in the vanilla
    HTML. Vite's hashed asset URLs already do that job for the React
    bundle.
  • express.static cache header: removed the /components/ branch
    (folder no longer exists) and changed /js/ + /css/ from 1-hour
    to 1-day immutable cache — safe because Vite hashes the
    filenames on every build.

Backend tsc + client tsc + vite build all green. 136/136 vitest
parity tests still pass against the captured calc-vectors.json.
Initial bundle unchanged at 343.97 kB / 106.59 kB gz.
2026-04-24 02:56:04 +02:00
github-actions[bot]
8367e2a826 Release v6.47.0 2026-04-24 00:45:29 +00:00
Daniel
c157c709c8 feat: React auth screen + SPA now serves every route (vanilla retired)
The final big piece of "everything in React + Tailwind". Login,
register, forgot-password, reset-password, and email-verification all
render from the React bundle now. The root path / serves the SPA,
vanilla index.html + public/js/* are no longer served by the server.

BACKEND — src/routes/auth.ts
  New GET /api/auth/public-config (public — no auth required) returns
  { registrationEnabled, turnstileSiteKey, oidcEnabled,
    disableLocalAuth, ssoButtonLabel }.
  Single round-trip the React auth screen needs on mount. Reuses
  existing DB settings; no new tables.

BACKEND — server.ts
  • / and /index.html now send public/app/index.html (React SPA),
    not public/index.html (vanilla).
  • /auth, /reset-password, /verify-email explicitly route to the SPA
    so the email links land on the React router.
  • /app/*splat preserved as an alias so old bookmarks keep working.
  • SPA fallback added after express.static so hard-refresh on
    /encounter / /bedside / /settings etc. serves the React index
    instead of 404ing. API paths and static-file extensions still
    fall through to their existing handlers.
  • The dead app.get('/') duplicate that also pointed at the vanilla
    index is removed.

CLIENT — React auth flow
  client/src/pages/Auth.tsx (new)
    Login / register / forgot sub-forms with a single useQuery on
    ['public-config'] driving Turnstile + SSO button visibility.
    Login flow handles all three vanilla-equivalent responses
    (token / requires2FA / needsVerification). 2FA field reveals
    inline when the server asks for it; resend-verification link
    appears when needsVerification fires. SSO button renders
    whenever oidcEnabled is true, even if local auth is disabled
    (disableLocalAuth hides the login/register/forgot forms
    entirely). HIPAA notice + APK download link preserved.

  client/src/pages/ResetPassword.tsx (new)
    Reads ?token=xxx from the URL, POSTs /api/auth/reset-password.
    Confirm-password match, 8+ char validation, server
    passwordWarning (pwned password) surfaces as an amber info box.
    Redirects to /auth 2.5 s after success.

  client/src/components/Turnstile.tsx (new)
    Loads the challenges.cloudflare.com/turnstile script once,
    renders a widget per form, calls onToken(token) on success and
    onToken('') on error / expiry. If siteKey is null/empty (e2e
    container with TURNSTILE_SITE_KEY="") renders nothing and
    auto-reports empty — matches the vanilla no-key-no-widget
    behaviour.

  client/src/components/AuthGuard.tsx (new)
    useQuery(['auth-me']) with retry: false. On 401/error redirects
    to /auth?next=<current-url> so the deep link survives sign-in.
    Used as a parent route in App.tsx wrapping every private page.

  client/src/components/Layout.tsx
    "← back to legacy app" link replaced with "Sign out" — calls
    POST /api/auth/logout then window.location = /auth.

  client/src/App.tsx
    BrowserRouter no longer has basename (was "/app"). Public
    routes: /auth, /reset-password. Everything else lives under
    <AuthGuard> → <Layout>. Lazy-loaded Auth + ResetPassword join
    the existing heavy-route code-split.

  client/vite.config.ts
    base stays "/app/" so hashed asset URLs resolve to
    /app/assets/... (served unchanged by express.static).

shared/types.ts + client/src/shared/types.ts — additive:
  PublicConfigOk { registrationEnabled, turnstileSiteKey,
    oidcEnabled, disableLocalAuth, ssoButtonLabel }.

Bundle — Auth chunk splits out at 10.87 kB / 3.35 kB gz, lazy-loaded
only on the sign-in path; initial bundle unchanged at 343.97 kB /
106.59 kB gz.

Backend tsc + client tsc + vite build + 136/136 vitest all green.
2026-04-24 02:45:21 +02:00
github-actions[bot]
c7a4e609e8 Release v6.46.0 2026-04-24 00:30:53 +00:00
Daniel
db85440325 feat(client): Learning Hub — sanitized HTML body + Marp slide viewer
Closes the last two Learning Hub follow-ups flagged in the earlier
commit body: rich-HTML body rendering (instead of pre-wrap plain text)
and in-React Marp slide playback (instead of the legacy-viewer link).

client/src/lib/sanitize.ts (new)
  Inline HTML sanitizer. Parses via DOMParser (sandboxed — no scripts
  run), walks the tree and:
    • Removes script / style / iframe / object / embed / link / meta /
      base / form / input / button / select / textarea.
    • Drops every on* event-handler attribute.
    • Drops href / src / xlink:href values starting with javascript:
      or data:text/html.
    • Drops any attribute whose value contains "javascript:".
    • Falls back to entity-escaping the raw string if parsing throws.
  Admin-authored Learning Hub content is the trust model here —
  essentially CMS content. A full DOMPurify dep would be strictly
  better but adding a package requires a network install; the inline
  sanitizer covers the realistic XSS vectors without the dep bump.

client/src/pages/Learning.tsx
  • Body rendering (non-presentation content_type) now runs through
    sanitizeHtml + dangerouslySetInnerHTML with a `prose prose-sm`
    Tailwind-typography class. Markdown/HTML formatting from admin
    content now appears correctly (headings, lists, code, bold,
    italics, links) instead of raw text.
  • SlideViewer component (new) replaces the "Open in legacy viewer"
    button for content_type === 'presentation'. Fetches
    /api/learning/content/:slug/slides (returns { css, slides[] } —
    server-side Marp output), sanitizes each slide's HTML + the CSS
    block, renders one slide at a time with:
      - prev/next buttons
      - keyboard ←/→ and PageUp/PageDown navigation
      - slide counter (N/total)
      - fullscreen toggle (Escape exits)
    Marp's own CSS is injected scoped-ish via sanitizer so slide
    theming survives.

shared/types.ts + client/src/shared/types.ts — additive:
  LearningSlidesOk { css: string; slides: string[] }

Client tsc + vite build clean. Initial bundle unchanged
(342.86 kB / 106.03 kB gz) since Learning.tsx was already lazy-loaded;
the sanitizer + SlideViewer roll into its chunk.
2026-04-24 02:30:44 +02:00
Daniel
515c57571f perf(client): code-split every heavy route via React.lazy()
Cuts the initial bundle roughly in half. Users who open Home,
Extensions, or FAQ download ~343 kB / 106 kB gz instead of 817 kB /
230 kB gz. Heavy clinical reference data (PE_DATA, Fenton LMS,
Rosner BP splines, 15 Bedside drug panels, 10 Calculator formulas,
13 Settings sub-sections, 10 Admin sub-tabs) only loads when the
user opens that route.

client/src/App.tsx
  Every route except Home / Extensions / FAQ wrapped in
  React.lazy(() => import(...)) + <Suspense fallback>. Extensions and
  FAQ stay in the main chunk because they're small (3 kB, 2 kB) and
  likely visited early. The RouteFallback component shows a single
  "Loading…" line — Vite prefetches chunks on hover so the delay is
  typically invisible.

Resulting chunks (gzipped):
  • index.js             106 kB   React + Router + Query + Layout +
                                   Home + Extensions + FAQ
  • Bedside.js            33 kB   15 clinical dosing panels
  • PeGuide.js            36 kB   PE_DATA hierarchy + scales + sounds
  • Calculators.js        42 kB   All 10 calculators + BP Rosner
                                   coefficients + Fenton + BMI LMS
  • Settings.js           10 kB   13 Settings sub-sections
  • Admin.js               7 kB   10 Admin sub-tabs
  • Learning.js            3 kB
  • Fenton chunk (shared)  3 kB   Reused between Bedside neonatal
                                   and the Growth Charts calculator
  • Note pages (each)  1-2 kB   Encounter / SOAP / Well / Sick /
                                   Hospital / Chart / Dictation / Vax /
                                   Catch-up individually chunked

Vite's 500 kB chunk-size warning is gone. No functional changes.
2026-04-24 02:28:52 +02:00
github-actions[bot]
22bd92de33 Release v6.45.0 2026-04-24 00:28:00 +00:00
Daniel
2d781ed935 feat(client): port Admin panel — all 10 sub-tabs now run in React
Replaces the legacy-link shell at /app/admin with a proper
multi-sub-tab admin panel. Covers every module the vanilla admin.js
exposed: Users, Site settings, Announcement banner, AI models,
TTS / STT providers, SMTP, Email templates, AI prompts, Audit logs.

Priority 1 per Daniel's note: **Users**. Full CRUD flow ported —
list (with live filter), verify, disable/enable, set role
(user/moderator/admin), delete (with confirm modal), admin-side
password reset (inline modal). Self-protection rules preserved:
can't disable/delete yourself, can't demote your own admin role.

client/src/pages/Admin.tsx (rewritten)
  Sub-tab shell. Role check via useQuery(['auth-me']) reusing the
  Layout cache. 10 pills drive which panel renders. Access-denied
  card for non-admins (data-testid='admin-access-denied' unchanged).

client/src/pages/AdminPanels.tsx (new) — batch 1
  • AdminUsersTab — useQuery ['admin-users'] + 6 mutations
    (verify / disable / enable / set role / delete / reset password).
    Color-coded rows (disabled users opacity-60), inline role select,
    inline search filter over email+name.
  • AdminSettingsTab — GET /api/admin/settings → stats (totalUsers /
    totalApiCalls / todayApiCalls) + registration toggle.
  • AdminAnnouncementTab — reads announcement.{enabled,type,text} via
    /api/admin/config/announcement, saves via 3 parallel PUT
    /api/admin/config/<key> calls. Info/Warning/Critical severity
    select; text rendered in the top-of-page banner.

client/src/pages/AdminPanels2.tsx (new) — batch 2
  • AdminSmtpTab — host/port/user/pass/from/secure form + source
    badge (env / database / none). PUT /api/admin/config/smtp,
    DELETE /api/admin/config/smtp (with ConfirmModal). Inline test
    email sender (recipient + template) calling
    POST /api/admin/config/test-email.
  • AdminEmailTab — template selector (verify / reset /
    password-changed), subject + HTML body textarea. Pulls values
    from /api/admin/config, saves via 2 parallel PUT calls.
  • AdminPromptsTab — GET /api/admin/config/prompts populates the
    selector; textarea edits the active prompt; save via
    PUT /api/admin/config/prompt.<key>; reset-to-default via
    POST /api/admin/config/prompts/<key>/reset with ConfirmModal.
  • AdminModelsTab — GET /api/admin/config/models renders the
    provider-scoped model table with per-row Enabled checkbox +
    Default radio. Mutations hit /api/admin/config/models/toggle
    and /default. LiteLLM + model-discovery flows flagged as
    legacy-viewer follow-up.
  • AdminTtsTab / AdminSttTab — show active provider + voice/model
    selector, save default via PUT /api/admin/config/tts.default_voice
    and /stt.default_model respectively.
  • AdminLogsTab — GET /api/admin/logs/all?category=&limit= with
    sticky-header scrollable table. Category filter
    (auth / admin / clinical / export / integration / documents) and
    limit selector (50-500). Renders time / user / category /
    action / detail / IP per row.

shared/types.ts + client/src/shared/types.ts — additive only:
  AdminUser, AdminUsersOk, AdminUserOk, AdminSettingsOk,
  AdminLogEntry, AdminLogsOk, AdminConfigRow, AdminConfigOk,
  AdminAnnouncementOk, AdminPromptRow, AdminPromptsOk,
  AdminSmtpStatusOk, AdminModelRow, AdminModelsOk,
  AdminVoiceProviderOk.

With this commit the React sidebar covers the full legacy nav.
Access-control is preserved (Admin is still role-gated and the nav
link hides for non-admins). Every existing backend endpoint is
reused as-is — no server changes.

Backend tsc + client tsc + vite build + 136/136 vitest all green.
2026-04-24 02:27:50 +02:00
github-actions[bot]
e7017028d0 Release v6.44.0 2026-04-24 00:15:27 +00:00
Daniel
98ac0429a9 feat(client): port BP Percentile — all 10 Calculator pills now run in React
Closes the Calculators migration. AAP 2017 BP percentile (Rosner
quantile splines) was the biggest table-driven calculator in the
codebase: 6 height-LMS arrays × 218 entries + 4 spline coefficient
matrices × 99 rows × 13 terms = ~3,500 numeric constants. Every one
ported verbatim. 14 parity tests prove the TS port returns identical
percentile and classification outputs to the vanilla calculators.js.

shared/clinical/bp.ts — 571 lines
  Generated from public/js/calculators.js via awk-extracted lines
  89-94 (LMS) and 97-503 (coefficients), wrapped in TS export
  declarations. No rewriting, no reformatting, no reordering — the
  data bytes are identical to the vanilla source.
  Math (calcHeightPercentile, computeBpPercentile,
  classifyBpFromPercentiles) ported verbatim from calculators.js:
  505-608. Exports a top-level computeBp() helper returning
  { sysPercentile, diaPercentile, heightPercentile, sysClass,
  diaClass, classification }.

scripts/capture-calc-vectors.js — BP cases added
  Uses new Function() to evaluate the raw LMS + coefficient blocks
  from calculators.js directly, then runs the vanilla math against
  14 carefully chosen test cases:
    • Typical pediatric ages (3, 5, 8, 10, 12 years, both sexes)
    • Adult-threshold cross-over (age 13 — uses absolute mmHg cutoffs)
    • Stage 1 / Stage 2 hypertension boundaries
    • Edge-of-domain (age 1, age 17)
    • Tall / short height-percentile outliers
  Fixture regenerated to 14 Bhutani + 58 AAP + 13 Fenton + 8 neonatal
  + 12 BMI + 14 BP = 117 total vectors.

shared/clinical/bp.test.ts — exact-match parity
  sysPercentile / diaPercentile are integer selections from 99
  candidate predicted values, so tests use .toBe() for exact match.
  heightPercentile uses toBeCloseTo(6) (double-precision float).
  Classification strings must match exactly. 14/14 pass.

client/src/pages/CalculatorPanels.tsx — BpPanel added
  Age/sex/height/SBP/DBP inputs, validation (age 1-17, height 50-200
  cm), color-coded overall classification + per-measurement
  percentile and tier (Normal / Elevated / Stage 1 / Stage 2) in a
  4-column result grid with the height percentile for context.

client/src/pages/Calculators.tsx
  Dispatch wires bp → BpPanel. PILLS['bp'].ported = true.
  LegacyPanel is now dead code — every pill has a real implementation.

Final test suite: 5 files, 136 tests green
  (19 calculators + 70 bilirubin + 21 fenton/neonatal + 12 BMI + 14 BP)
2026-04-24 02:15:17 +02:00
github-actions[bot]
caa2c7c063 Release v6.43.0 2026-04-24 00:08:15 +00:00
Daniel
14581eef47 feat(client): port BMI + Vitals + Resus Meds + Equipment calculators
Nine of the ten Calculator pills now run in React. Only BP Percentile
(Rosner quantile splines, ~3,000 hand-transcribed coefficients across
6 LMS arrays and 4 spline matrices) remains legacy-linked; that port
deserves its own dedicated session with extra care.

shared/clinical/bmi.ts — CDC 2000 BMI-for-age
  • bmiLMS table ported byte-for-byte from calculators.js:739
    (74 LMS triples = 37 age points × 2 sexes, 24-240 months).
  • normalCDF (Abramowitz & Stegun), calcBmiPercentile, classifyBMI
    (including the %-of-95th severe-obesity split), and a top-level
    computeBmi helper — all verbatim translations of the vanilla math.
  • classification labels preserved exactly so existing e2e screenshots
    or reporting continue to read the same text ('Class 2 Severe
    Obesity', 'Healthy Weight', etc.).

shared/clinical/bmi.test.ts — 12 captured vectors covering:
  both sexes, edges (2y + 20y), interpolated-between-keys (13m),
  each classification cliff (underweight / healthy / overweight /
  obese / severe class 2 / severe class 3), and the age-clamping
  branches (<24 mo and >240 mo). All 12 pass at 10-decimal precision
  (percentile to 6 places since the vanilla rounds to 2).

scripts/capture-calc-vectors.js — BMI section added
  Same pattern as bilirubin / Fenton: the vanilla data + math are
  inlined verbatim, the script runs 12 chosen cases, and writes to
  e2e/fixtures/calc-vectors.json. Re-run after any upstream change.

client/src/pages/CalculatorPanels.tsx (new)
  • BmiPanel — age/sex/weight/height inputs, calls computeBmi,
    renders color-coded classification badge with BMI, percentile,
    Z, and % of 95th when percentile ≥85.
  • VitalsPanel — 8-band age selector (premie → >12 yr).
    VITALS_DATA ported verbatim from calculators.js:1703-1831 with
    every HR/RR/SBP/DBP/temp/weight/SpO₂ range and clinical notes
    preserved entry-for-entry.
  • ResusPanel — weight input drives all 14 drugs (Adenosine,
    Amiodarone, Atropine, CaCl, Ca-gluconate, Dextrose, Epi,
    Hydrocortisone, Insulin, Lidocaine, Mg, Naloxone, NaHCO₃) with
    calc() closures ported verbatim from RESUS_MEDS lines 1873-2050.
    Category colors + labels preserved.
  • EquipmentPanel — 9-band age selector (premie → 16+ yr).
    EQUIP_DATA ported verbatim from calculators.js:2173-2228 with
    12 equipment sizes per band (BVM, NPA, OPA, blade, ETT, LMA,
    Glidescope, IV, CVL, NGT, chest tube, Foley).

client/src/pages/Calculators.tsx
  Dispatch wires bmi → BmiPanel, vitals → VitalsPanel, resus →
  ResusPanel, equipment → EquipmentPanel. PILLS flags all four as
  ported: true. Only bp remains on LegacyPanel.

Backend tsc + client tsc + vite build + 122/122 vitest (19 calc +
70 bili + 21 fenton/neonatal + 12 bmi) all green.
2026-04-24 02:08:05 +02:00
github-actions[bot]
8461c51c18 Release v6.42.0 2026-04-24 00:01:01 +00:00
Daniel
253cef4792 feat(client): port final 5 Bedside panels — all 15 now run in React
Finishes Bedside parity. Neonatal, Respiratory, Ventilation, Sepsis,
and Burns replace their LegacyPanel fallbacks. All 15 vanilla
Bedside sub-modules are now real React components.

shared/clinical/fenton.ts — second, higher-accuracy Fenton table
  fentonLmsPeditools: 21 GA weeks × 2 sexes (22-42 in 1-week steps)
  ported verbatim from public/js/bedside/neonatal.js:20-37. This is
  the peditools-derived table that superseded the older hand-rounded
  version (still used by the Growth Charts calculator). Adds
  calcZNeonatal (|L|<0.001 threshold) + zToPercentileNeonatal
  (Abramowitz & Stegun erf form) so neonatal output matches the
  vanilla Bedside numbers to 3 decimal places.
  neonatalAssess() returns { gaDecimal, gaClass, bwClass,
  weightClass, expectedWeight, z, percentile, L, M, S }.

scripts/capture-calc-vectors.js + e2e/fixtures/calc-vectors.json
  Adds 8 neonatal vectors (including the 40w5d male 3070g validated
  case from the vanilla file's own comment: z = -1.42).

shared/clinical/fenton.test.ts — now covers neonatal too (110 total
vitest cases pass: 19 calculators, 21 fenton, 70 bilirubin).

client/src/pages/BedsidePanels2.tsx — five new panels
  • NeonatalPanel — GA+weight+sex inputs drive the Fenton assessment
    (gestational-age class, weight-for-GA class, birth-weight
    category, Z-score, percentile, expected M). Full NRP pathway
    cards (birth→HR<100→HR<60 escalation), NRP drug table scaled
    by weight (epi IV/IO/ETT, NS bolus, D10), and 5-element Apgar
    scorer with reassuring/moderately-depressed/severely-depressed
    guidance.
  • RespiratoryPanel — four sub-modes: Asthma (mild/moderate/
    severe with full drug tables + "when to intubate" / ABG /
    heliox clinical decision boxes), PRAM scorer (0-12),
    Westley croup scorer (0-17 with severity-tiered treatment),
    Bronchiolitis admission decision tree (age / SpO₂ / hydration
    / distress) with AAP "NOT recommended" list.
  • VentilationPanel — target SpO₂ table by population, 6-step
    escalation ladder (NC → FM → NRB → HFNC → NIV → intubation)
    with live-scaled HFNC flow (1-2 L/kg/min), BVM how-to,
    mechanical vent starting settings (TV, rate by age, PEEP,
    FiO₂, I:E), gas-exchange adjustment table, and the
    oxygenation-vs-ventilation mental model.
  • SepsisPanel — Phoenix Sepsis Criteria (JAMA 2024), red-flag
    list, age-banded workup + empirical therapy (neonate /
    infant / child abx drugs keyed to weight), SSC 2020 first-hour
    bundle (0-5 min recognize → >60 min refractory-shock
    vasoactive), and resuscitation targets.
  • BurnsPanel — full 19-region Lund-Browder age-adjusted table
    (ported verbatim), with per-region % input + live TBSA
    computation + override. Parkland formula (4 × kg × TBSA,
    8h/16h split + per-hr rates), 4-2-1 maintenance, UOP targets,
    pearl list (palm rule, no first-degree, analgesia, tetanus),
    and ABA burn-center referral criteria.

client/src/pages/BedsidePanels.tsx — dispatch extended to all 15
pills. REAL_BEDSIDE_PANELS now contains every Bedside pill id, so
the legacy-link fallback is dead code that can be pruned later.

Client tsc + vite build + 110/110 vitest tests all green.
2026-04-24 02:00:50 +02:00
github-actions[bot]
fd8d505308 Release v6.41.0 2026-04-23 23:49:54 +00:00
Daniel
ff4b3de988 feat(client): port Bilirubin + Fenton calculators with captured vectors
Ships the AAP 2022 phototherapy/exchange nomograms, the Bhutani 1999
risk zones, and the Fenton 2013 preterm weight-for-GA chart as real
React calculators. These were the high-risk "table-driven" ports the
migration checkpoint flagged as needing captured vectors before
landing — the whole vector-capture workflow now exists and can be
reused for BP / BMI / growth-beyond-Fenton next.

Workflow, so future calculator ports have a template:

scripts/capture-calc-vectors.js
  Standalone Node script. Data tables + math inlined VERBATIM from
  public/js/calculators.js (no rewriting, no reformatting). Picks 83
  carefully chosen test cases — edge-of-domain, table-key-exact,
  interpolated-between-keys, and clinical-decision-boundary values —
  and emits e2e/fixtures/calc-vectors.json with
  { inputs, output } pairs produced by the authoritative math.

e2e/fixtures/calc-vectors.json
  12 Bhutani cases, 58 AAP 2022 cases, 13 Fenton cases. Re-run
  capture-calc-vectors.js whenever the vanilla file changes.

shared/clinical/bilirubin.ts
  • 17 HourTable constants ported byte-for-byte from calculators.js
    lines 1489-1512: photo 35w/36w/37w/38w/39w/40+ (low risk) +
    35w/36w/37w/38+ (medium risk), same 8 for exchange.
  • Bhutani zones (p95/p75/p40) from lines 1644-1651.
  • interpolateThreshold helper (lines 1514-1525).
  • classifyBhutani + classifyAapBili functions mirror the vanilla
    click-handler logic.

shared/clinical/fenton.ts
  • 15-week × 2-sex LMS table from lines 1168-1183 preserved entry-
    for-entry.
  • interpolateLMS + calcZ + zToPercentile (Abramowitz & Stegun
    normal CDF, lines 2299-2326) ported byte-for-byte.
  • fentonWeightForAge returns { L, M, S, z, percentile };
    classifySizeForAge labels SGA/<10 / AGA / LGA/>90.

shared/clinical/bilirubin.test.ts + fenton.test.ts
  Vitest suites that import e2e/fixtures/calc-vectors.json and assert
  classifyBhutani / classifyAapBili / fentonWeightForAge match
  every captured vector to 10 decimal places for threshold values
  and 6 decimals for the Fenton M (grams, so 6 is >= 1e-3 g).
  All 102 tests pass locally (19 prior + 70 bili + 13 Fenton).

client/src/pages/Calculators.tsx
  • BiliPanel with AAP 2022 / Bhutani mode switch, GA + risk-factor
    dropdowns, color-coded status / zone badges, both threshold
    pairs surfaced in the result grid.
  • GrowthPanel runs Fenton with sex + GA + weight inputs, emits
    Z-score, percentile, L/M/S reference, and SGA/AGA/LGA label.
  • PILLS flags bili + growth as ported: true; ActivePanel routes to
    the new components. BP / BMI / vitals / resus / equipment
    remain legacy-linked until their own vectors land.

e2e/tests/calculators-react.spec.js
  Three new parity tests covering above-phototherapy/above-exchange
  transitions (38w 72h TSB 20 → phototherapy; TSB 26 → exchange),
  Bhutani high-risk classification at 36h TSB 13, and Fenton 32w
  male 1795g landing exactly at 50th percentile AGA.

Backend tsc + client tsc + vite build + vitest (102/102) all green.
Bundle 619 kB / 178 kB gz (+10 kB for the bili tables).
2026-04-24 01:49:45 +02:00
github-actions[bot]
2a95bcc961 Release v6.40.0 2026-04-23 23:35:16 +00:00
Daniel
f94f3a7421 feat(client): port Sedation + Toxicology Bedside panels
Ten of fifteen Bedside pills now have real React implementations.
Remaining five (neonatal, respiratory, ventilation, sepsis, burns)
stay on LegacyPanel — they each have more complex state (Fenton
LMS, Phoenix criteria, Lund-Browder age-adjusted region tables,
bronchiolitis / asthma severity scoring) best tackled in dedicated
sessions where the clinical data can be verified carefully.

client/src/pages/BedsidePanels.tsx
  • SedationPanel — full procedural-sedation drug list (ketamine IV/IM,
    midazolam IV/IN/PO, propofol, fentanyl IV/IN, nitrous oxide,
    dexmedetomidine IN) plus reversal agents (naloxone, flumazenil).
    Dose cells mirror the vanilla sedDoseCell helper: range-low/high
    with per-kg footer, or single per-kg + max. Pre-sedation checklist
    and citation preserved.
  • ToxicologyPanel — topic selector with 11 panels (general approach,
    acetaminophen, opioids, iron, TCA, β-blocker/CCB, benzo,
    organophosphate, salicylate, toxic alcohols, dialyzable drugs).
    Every drug dose and clinical pearl carried over byte-for-byte.
    Weight-dependent drugs (NAC weight-based loading, naloxone
    range, magnesium for TCA torsades) compute against the entered
    weight; topic panels work with or without a weight value.

Client tsc -b + vite build clean.
2026-04-24 01:35:07 +02:00
github-actions[bot]
f7f9b4cac4 Release v6.39.0 2026-04-23 23:31:46 +00:00
Daniel
e1cb28f309 feat(client): port Airway, Agitation, Antiemetics, Antimicrobials, Trauma panels
Second batch of Bedside sub-module ports. Eight of the fifteen pills
now run real React (anaphylaxis + cardiac + seizure from the previous
commit, plus these five). Remaining seven — neonatal, respiratory,
ventilation, sepsis, sedation, burns, toxicology — still fall through
to LegacyPanel.

client/src/pages/BedsidePanels.tsx
  • AirwayPanel — weight + age inputs drive the full RSI sheet.
    Pre-medication, induction, paralytics, and maintenance-sedation
    tables with per-kg dosing + max caps preserved from airway.js.
    Equipment sizing (ETT uncuffed/cuffed, depth, blade, LMA, Fr
    suction, NG, chest tube) follows the same age-/weight-keyed
    formulas. Ventilator starting settings carry Vt = 6-8 mL/kg
    computed from the current weight.
  • AgitationPanel — step 1 / 2 / 3 pathway with weight-conditional
    doses for olanzapine (<30 kg vs ≥30 kg) and haloperidol
    (<40 kg vs ≥40 kg). Droperidol keeps the range-prefix + computed
    dose. Drug list matches AGIT_FALLBACK.
  • AntiemeticsPanel — full drug list with the ondansetron weight-
    band display alongside the per-kg fallback. Promethazine <2 yr
    contraindication text preserved.
  • AntimicrobialsPanel — neonate / infant / child × sepsis /
    meningitis / pna / uti / skin / ent / neutropenic / intra-abd /
    bone regimen map ported verbatim from REG. Age + infection
    selectors pick the right cell.
  • TraumaPanel — ABCDE primary survey cards with the 20 mL/kg
    bolus auto-computed from the entered weight. MTP (1:1:1, TXA,
    calcium), NEXUS c-spine, pediatric shock signs, and AMPLE
    secondary-survey content all preserved.

renderBedsideRealPanel + REAL_BEDSIDE_PANELS extended to cover the
five new panels.

Client tsc -b + vite build clean (warnings on chunk size are expected
until we code-split; not blocking).
2026-04-24 01:31:38 +02:00
github-actions[bot]
bce0e11df5 Release v6.38.0 2026-04-23 23:27:37 +00:00
Daniel
901aa04ab7 feat(client): port Bedside Anaphylaxis + Cardiac + Seizure panels
Replaces the legacy-viewer fallback for three of the fifteen Bedside
sub-modules with real React implementations. Weight-based dosing now
runs locally for the most time-critical emergencies — the other 12
modules (neonatal, airway, respiratory, ventilation, sepsis, sedation,
agitation, antiemetics, antimicrobials, burns, toxicology, trauma)
still fall through to LegacyPanel.

shared/clinical/calculators.ts
  New formatDose(weightKg, perKg, max?, unit = 'mg') helper that
  mirrors the vanilla S.dStr exactly — same rounding, same cap rule,
  same label format. Returns { value, unit, capped, perKg, max,
  label } so consumers can render rich UI without re-parsing a HTML
  string. Pure function; unit tests in the existing vitest suite
  still pass.

client/src/pages/BedsidePanels.tsx (new)
  Three panels keyed to the Bedside pill id:
    • AnaphylaxisPanel — STEP 1 epi IM callout + full 9-row dosing
      table (fluids, diphenhydramine, ranitidine, dex, methylpred,
      refractory epi gtt, glucagon). Drug per-kg + max values ported
      verbatim from ANAPH_FALLBACK in the vanilla module.
    • CardiacPanel — PALS dosing with 6 sub-views (general,
      asystole/PEA, bradycardia, SVT, VF/pulseless VT, stable VT).
      Every drug row carries the same mg/kg + max values as the
      vanilla cardiac.js (epi, amio, lido, atropine, adenosine ×2,
      bicarb, CaCl/CaGluc, Mg, D10, defib energies). Concentration
      disclaimer + AHA citation preserved.
    • SeizurePanel — full status-epilepticus timeline (0 min →
      40 min refractory) with the vanilla SEIZURE_FALLBACK drug
      table (loraz / midaz / diaz for benzo; levetiracetam /
      fosphenytoin / valproate / phenobarb for 2nd-line; midaz
      pentobarb propofol ketamine infusions for refractory). Key
      points + citation preserved.

  REAL_BEDSIDE_PANELS set + renderBedsideRealPanel(pillId) export so
  Bedside.tsx can dispatch by id without import sprawl.

client/src/pages/Bedside.tsx
  When the active pill is in REAL_BEDSIDE_PANELS, render the real
  panel; otherwise fall back to LegacyPanel. No other changes — the
  sub-nav, age→weight estimator, and 12 legacy-linked modules stay
  exactly as they were.

e2e/tests/bedside-react.spec.js
  Three new parity tests (in addition to the existing shell checks):
    • Anaphylaxis: 20 kg → 0.2 mg epi IM; 70 kg → 0.5 mg (capped).
    • Cardiac: 25 kg → 0.25 mg epi IV in general view and asystole.
    • Seizure: 10 kg → D10W 20-50 mL, Lorazepam 1 mg.

Client tsc -b + vite build clean. Bundle 609 kB / 173 kB gz (+28 kB
for three full panels; vite's 500 kB chunk warning noted for later
code-splitting, not blocking).
2026-04-24 01:27:29 +02:00
github-actions[bot]
13846f48ef Release v6.37.0 2026-04-23 23:21:54 +00:00
Daniel
c966db5c05 feat(client): port full PE_DATA checklist + Generate Exam Report flow
Biggest data port of the migration so far. PE_DATA is the 1000+ line
age-group × system × component × step hierarchy driving the pediatric
physical-exam checklist; every entry, pearl, significance note, and
abnormal-hint array is now available in the React tree.

client/src/data/pe-data.ts — verbatim port
  Extracted lines 316-1334 of public/js/peGuide.js with awk/sed, then
  wrapped in TS types. Every byte of the data body is byte-identical
  to the vanilla source. Added interfaces:
    PeStep { label, method, normal }
    PeComponent { name, steps[], abnormalHints[], pearl?, significance? }
    PeSystem { overview, components[] }
    PeAgeGroup { label, msk, neuro, resp, cv }
  …plus AGE_GROUP_ORDER / SYSTEM_ORDER / SYSTEM_LABELS canonical
  orderings for the UI.

client/src/data/pe-data.test.ts — parity lock
  Vitest suite that asserts every count captured from the vanilla
  source so any accidental drop surfaces as a red test:
    • 6 age groups × 4 systems
    • 103 components total
    • 27 pearl entries
    • 23 significance entries
    • per-cell component counts (e.g. toddler.neuro = 7, adolescent.cv = 5)
  Counts captured 2026-04-24 against peGuide.js commit 313ba7f.

client/src/pages/PeGuide.tsx — full viewer (replaces legacy-link stub)
  • Age-group pills (6) + system pills (4) drive the visible section
  • Overview banner per combination
  • CV system shows APTM legend + cardiac sounds library + innocent
    murmurs reference (unchanged clinical content from the earlier
    commit that added the scales/sounds file)
  • Resp system shows the respiratory sounds library
  • Collapsible grading-scales reference pulls from SYSTEM_SCALES
  • Component checklist: per-step Normal / Abnormal toggle, abnormal-
    hint list, pearl + significance callouts
  • Mark-all-normal + Reset shortcuts
  • Generate Exam Report posts the full step payload to
    /api/generate-pe-narrative, renders the returned narrative inline
  • No more "Open checklist in legacy viewer" amber banner — the
    React port now does the whole thing

e2e/tests/peguide-react.spec.js
  Age-group pills, system pills, overview rewrite on age change,
  CV/resp system-specific reference panels, mark-all-normal + summary,
  and a mocked /api/generate-pe-narrative round-trip.

Client tsc -b + vite build clean. Bundle 580.30 kB / 166.08 kB gz
(up ~100 kB from the shell-only port — the 1000-line PE_DATA is the
bulk; acceptable for the clinical reference data it surfaces).
2026-04-24 01:21:44 +02:00
github-actions[bot]
1d43bc387e Release v6.36.0 2026-04-23 23:12:08 +00:00
Daniel
762e9d4f0c feat(client): port age→weight + BSA + dose + GCS; shared/clinical module
First batch of real calculator ports, routed through a new
shared/clinical/calculators.ts module that both the server tree and
the React client can import. Kept strictly to the simplest, closed-form
formulas — tables (Rosner BP, Fenton LMS, AAP 2022 bili, Bhutani, CDC
BMI) stay in the vanilla viewer until per-table vector files land.

shared/clinical/calculators.ts
  Verbatim ports from public/js/calc-math.js:
    • parseAgeMonths / formatAgeMonths — legacy age-string parser
    • estimateWeightFromAgeMonths — APLS (Luscombe 2007) + Best Guess
      (Tinning 2007) weight-for-age. Cross-checked line-by-line against
      calc-math.js:14-39; identical branches, formulas, and roundTo
      behavior.
    • calculateMostellerBsa — sqrt(h·w/3600), Mosteller 1987.
    • calculateWeightBasedDose — generic mg/kg with optional max cap
      and mg/mL → mL conversion.
    • calculateGcs — 1-15 sum with 8/12 severity thresholds.

shared/clinical/calculators.test.ts
  Vitest unit coverage for each helper. Numeric assertions match the
  legacy function outputs (3y APLS → 14 kg; 20 kg, 110 cm → 0.782 m²;
  15 kg × 100 mg/kg capped at 500 mg, etc.). For these closed-form
  formulas the hand-verified expected values are equivalent to a
  vanilla-captured vector file — table-driven calculators still need
  a JSON fixture before they port.

client/src/pages/Bedside.tsx
  Top-level age-to-weight estimator now runs in React
  (BedsideWeightEstimator). Formula dropdown switches between APLS
  and Best Guess live; weight field accepts a manual override. The
  15 clinical dosing sub-modules still fall through to the legacy
  viewer via LegacyPanel.

client/src/pages/Calculators.tsx
  BSA, Weight-Based Dosing, and GCS panels render real React forms
  backed by the shared helpers. PILLS gain a `ported` flag so the
  four covered panels (bsa, dose, gcs, + the already-shipped pills)
  swap out of the legacy fallback while the others remain linked
  out. Result blocks carry data-testid hooks for parity tests.

Config + dep hygiene picked up along the way
  • client/tsconfig.app.json: @shared/* path alias, exclude test files
    from the React tsc pass.
  • tsconfig.json: exclude **/*.test.ts from the backend tsc pass.
  • package.json: declare google-auth-library and jszip explicitly —
    both were already required() in src/utils/ttsGoogle.ts and
    src/routes/learningAI.ts but missing from dependencies, which
    would break a clean `npm install`. Also adds engines: node >=20
    and convenience verify / verify:full scripts.
  • knip.json: quiet now-expected ignoreDependencies / ignoreBinaries
    entries for marp-cli, tiptap, cap, etc.
  • .gitignore: ignore the .codex CLI marker.

e2e/tests/bedside-react.spec.js
  Adds the age→weight parity test: 3y APLS → 14 kg, 3y Best Guess →
  16 kg, weight field mirrors the estimator output.

e2e/tests/calculators-react.spec.js
  Adds BSA (20 kg, 110 cm → 0.782 m²), dose cap (15 kg × 100 mg/kg,
  max 500 mg → 500 mg capped), and GCS (15 → 10 when motor drops
  to 1) parity tests.

Client tsc -b + backend tsc --noEmit + vite build all clean.
Bundle 476.48 kB / 135.44 kB gzipped.

Co-Authored-By: Codex + vendor model Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:11:58 +02:00
github-actions[bot]
efc9f5f9be Release v6.35.0 2026-04-23 22:03:11 +00:00
Daniel
b189689b0a feat(client): port Admin shell with role-gated sidebar entry
Last tab on the revamp roadmap. Ships /app/admin as a shell that
renders either an access-denied card or a legacy-viewer link
depending on me.user.role, plus a new Admin nav group in the
sidebar that is hidden entirely for non-admin users.

client/src/components/Layout.tsx
  NavItem gains an optional adminOnly flag. Layout now runs its own
  useQuery<MeOk>(['auth-me']) — the query key is already shared with
  Settings so the cache is reused across both. Nav groups whose
  items all filter out (in practice: the Admin group for non-admins)
  don't render their group header either, so the sidebar stays clean
  for regular users. 5-minute staleTime so the header doesn't hammer
  /me on every route change.

client/src/pages/Admin.tsx
  Same /me query + role check. Non-admins land on the
  admin-access-denied card; admins see the admin-shell with a link
  to the legacy admin viewer. Sub-sections (users, feature flags,
  OIDC, SMTP, AI prompts, model management, TTS/STT, email
  templates, announcement banner, site-wide saved encounters) stay
  in the vanilla admin page for now — each touches production state
  immediately on save, so each port needs its own deliberate commit
  with dedicated tests before shipping.

e2e/tests/admin-react.spec.js — one smoke test
  The seeded e2e user is non-admin, so the expected outcome is the
  access-denied card. Guard is written to accept either state so the
  test still passes if the seed ever flips to an admin.

With this commit the React sidebar covers the full legacy nav:
  • Encounters: Encounter HPI, Dictation HPI
  • Notes: Hospital Course, Chart Review, SOAP, Well Visit, Sick Visit
  • Clinical Tools: Vax Schedule, Catch-Up, PE Guide, Bedside,
    Calculators, Pagers & Extensions, Learning Hub
  • Account: Settings, FAQ
  • Admin: Admin Panel (role-gated)

Client tsc -b + vite build clean. Bundle 462.48 kB / 131.98 kB gz.
2026-04-24 00:03:02 +02:00
github-actions[bot]
d7ecc9dde3 Release v6.34.0 2026-04-23 22:01:22 +00:00
Daniel
7f0c947815 feat(client): port Calculators sub-nav shell (formulas gated on vectors)
Ships the /app/calculators page with the 10-pill sub-nav and flips
the sidebar Calculators link to available. All formula math stays
in the vanilla viewer for this commit.

client/src/pages/Calculators.tsx
  PILLS array mirrors public/components/calculators.html exactly:
  BP Percentile, BMI, Growth, Bilirubin, Vital Signs, BSA,
  Weight-Based Dosing, Resus Meds, GCS, Equipment. Each pill carries
  its canonical source (AAP 2017 Flynn / Fenton 2013 / AAP 2022
  Kemper / Bhutani 1999 / Mosteller / PALS / …) so when a reader
  opens the page they know which authoritative reference the numbers
  trace back to.

Why no math in this commit — explicitly gated
  The migration checkpoint has a specific rule for this tab:
  "generate test vectors (JSON file with {inputs, expectedOutput}
  tuples) by running the vanilla version with 20+ known cases. The
  React port must match every vector byte-for-byte. An LLM will
  sometimes 'simplify' a long array of numbers and silently break it
  — don't let that happen." This applies in particular to:
    • Rosner quantile splines in the BP percentile calc
    • Fenton 2013 LMS preterm (210 validated cases)
    • AAP 2022 bilirubin phototherapy + exchange (1190 validated cases)
    • Bhutani nomogram risk zones
    • APLS + Best Guess weight-for-age
  Each formula gets its own commit once the vector file lands in
  e2e/fixtures/ — this shell just makes the nav complete so users
  can navigate to the tab in the React tree.

e2e/tests/calculators-react.spec.js — three smoke tests:
  all 10 pills render by data-testid in the expected order, pill
  click switches the active panel, and the legacy-viewer link is
  present.

Client tsc -b + vite build clean. Bundle 460.19 kB / 131.43 kB gz.
2026-04-24 00:01:14 +02:00
github-actions[bot]
8c8e6a0c4c Release v6.33.0 2026-04-23 21:59:44 +00:00
Daniel
314b256160 feat(client): port Bedside sub-nav shell (15 pills linked to legacy)
First Bedside commit. Delivers the /app/bedside page with the full
15-pill sub-nav in the same order + labels as the vanilla app, and
switches the sidebar Bedside link to available.

client/src/pages/Bedside.tsx
  Single-file shell. PILLS array is the single source of truth for
  pill ID / label / icon / summary, ordered to match
  public/components/bedside.html (neonatal, airway, cardiac,
  respiratory, ventilation, seizure, sepsis, anaphylaxis, sedation,
  agitation, antiemetics, antimicrobials, burns, toxicology, trauma).
  Clicking a pill flips useState<active>, and LegacyPanel renders a
  summary of that module + a button to open the legacy Bedside tab.

What is intentionally NOT in this commit
  Each pill's actual clinical dosing panel stays in vanilla for now.
  Those panels encode weight-based dosing, syndrome-keyed
  antimicrobials, and PALS / ALS formulas — exactly the class of
  content the migration checkpoint memory flags as must-not-be-
  "simplified" by an LLM. They belong in per-module commits that
  land alongside the calculators port (APLS + Best Guess weight,
  Fenton 2013 LMS, AAP 2022 bilirubin, Rosner BP splines) where
  test vectors can verify byte-for-byte parity with the vanilla
  output.

  The top-level age → weight estimator also waits on calculators —
  it calls window._PED_MATH.estimateWeightFromAgeMonths in the
  vanilla module, which is defined in public/js/calculators.js.

e2e/tests/bedside-react.spec.js — three smoke tests
  All 15 pills render by data-testid, clicking a pill swaps the
  panel, and the legacy-viewer link is present. The pill-order list
  is hard-coded in the spec so re-ordering or dropping a pill trips
  a loud failure.

Client tsc -b + vite build clean. Bundle 456.71 kB / 130.53 kB gz.
2026-04-23 23:59:34 +02:00
github-actions[bot]
d8b4225812 Release v6.32.0 2026-04-23 21:57:26 +00:00
Daniel
104c1c5f7e feat(client): port PE Guide reference libraries + rescue untracked FAQ data
Two things in one commit because they're coupled by a gitignore fix:
(1) PE Guide minimum-viable port, (2) a pre-existing bug where
client/src/data/faq.ts was silently gitignored and never committed.

.gitignore — narrow scope
  Changed `data/` to `/data/`. The old rule matched every nested
  `data/` directory in the tree, including client/src/data/, which
  meant faq.ts from the FAQ port (commit c0038da) never landed in
  git — the Faq page built locally only because the file existed
  on the dev machine. A fresh clone or CI build would fail the
  Vite build at '@/data/faq'. The narrowed rule still ignores the
  runtime DB directory at repo root while allowing project data
  modules to be tracked. This commit also commits the missing
  faq.ts so the FAQ page is actually buildable from git again.

client/src/data/pe-guide.ts
  Verbatim port of lines 23-311 of public/js/peGuide.js — the stable
  reference content:
    • SCALES (12 scales: MRC, DTR, plantar, Beighton, ATR, RR, SpO2,
      Silverman, Westley, Levine murmur, pulse amp, cap refill)
    • SYSTEM_SCALES (per-body-system scale mapping)
    • APTM_LEGEND (5 cardiac auscultation points)
    • INNOCENT_MURMURS (5 benign childhood murmurs)
    • RESP_SOUNDS (7 entries with /audio/respiratory/*.ogg paths)
    • CARDIAC_SOUNDS (6 entries with /audio/cardiac/* paths)
  Counts preserved exactly. Audio files stay under public/audio/ and
  are served unchanged.

What is NOT in this commit — on purpose
  PE_DATA (the ~1000-line age-group × system × component × step
  hierarchy) stays in the vanilla app. The migration checkpoint memory
  explicitly warns about the class of bug where an LLM silently drops
  entries from long clinical arrays. PE_DATA porting needs its own
  session with per-entry counts + visual diff against the vanilla
  source. An amber banner at the top of the React page links to
  /#peGuide (the legacy checklist viewer) so users still reach the
  full exam-step checklist + Generate-Exam-Report flow.

  Also skipped: the big inline APTM_SVG chest diagram. The letter
  legend (A/P/E/T/M) carries the clinical content; the pictorial
  SVG can land later without content risk.

client/src/pages/PeGuide.tsx — viewer
  Grid-of-cards layout: one card per scale, per APTM point, per
  innocent-murmur, and per sound entry. Sound cards use native
  <audio controls> so the browser does the usual play/pause/seek —
  no custom player. data-testid hooks throughout for the spec.

e2e/tests/peguide-react.spec.js — four smoke tests:
  all 12 scales render (this is the count that would fail loudly if
  someone trimmed SCALES later), APTM has all 5 letters, sound
  libraries have the exact respiratory + cardiac keys, and the
  legacy-viewer link is present.

Client tsc -b + vite build clean. Bundle 452.91 kB / 129.35 kB gz.
2026-04-23 23:57:16 +02:00
github-actions[bot]
1798b71a97 Release v6.31.0 2026-04-23 21:50:04 +00:00
Daniel
c05fa29993 feat(client): port Learning Hub to React
Minimum-viable port of the user-facing Learning Hub at /app/learning.
Sidebar nav flipped to available in the same commit.

client/src/pages/Learning.tsx
  Three-screen flow: search + category pills drive a feed grid; clicking
  a card opens the viewer; viewer shows body + progress + quiz (if any).

  Feed: one query key per filter — ['learning-feed'], ['learning-category',
  slug], or ['learning-search', q] — so React Query caches each view
  independently and flicking between categories is instant after the first
  load. Search hits /api/learning/search; category filter hits
  /api/learning/category/:slug; default hits /api/learning/feed?limit=30.

  Viewer: body rendered as pre-wrap text intentionally. The vanilla tree
  uses DOMPurify (CDN-loaded) to render HTML bodies; adding that dep to
  the client bundle is a follow-up. Authored content is still clinical
  info, so plain-text preservation is acceptable for this commit — no
  content is lost, just unstyled. Presentations (content_type === 'presentation')
  link to the legacy viewer at /#learning/:slug — Marp slide rendering is
  its own port.

  Quiz: supports single-choice, multi-select, and true/false. Answers
  tracked via { optionId?, optionIds: Set<number> } per question so the
  same state shape drives both radio and checkbox rendering. Submit POSTs
  to /api/learning/submit-quiz; results screen shows per-question verdict
  with correct answer + why-incorrect + general explanation — same fields
  the vanilla showQuizResults renders. Retake wipes the answer map;
  Back-to-Feed returns to the list.

  Progress list reads content.progress[] directly from the content response
  — last 5 attempts, color-coded green/amber at 70%.

shared/types.ts + client/src/shared/types.ts — additive:
  LearningCategory/LearningCategoriesOk, LearningFeedRow/LearningFeedListOk,
  LearningOption/LearningQuestion/LearningProgressEntry/LearningContentFull/
  LearningContentOk, QuizAnswer/QuizResultEntry/QuizSubmitOk. Keys match
  the wire shape server routes return (snake_case for DB columns).

e2e/tests/learning-react.spec.js — three smoke tests:
  shell renders, feed shows items OR empty-state (no crash on empty DB),
  typing into search fires /api/learning/search.

Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 435.44 kB / 123.81 kB gzipped (+10 kB over Settings complete).
2026-04-23 23:49:56 +02:00
github-actions[bot]
42f8772ab9 Release v6.30.0 2026-04-23 21:40:42 +00:00
Daniel
e2336933e5 feat(client): port Settings — Voice + Content (8 sub-sections)
Third and final commit of the Settings port. Adds the remaining eight
sub-sections so the React page matches vanilla settings.html 1:1.
After this commit Settings is fully ported; Layout already flipped to
available in commit 1, and the page fills out cleanly for local-auth
and SSO users alike.

Voice Preferences (VoicePreferencesCard)
  GET /api/user/preferences + /api/user/preferences/options populate the
  STT model / TTS voice selectors. Save POSTs /api/user/preferences.
  Preview persists the current TTS selection, then fetches /api/text-to-
  speech (binary blob, bypasses the JSON api wrapper), wraps the blob in
  an Audio element and plays it. A one-shot hydrated flag drives the
  first selection sync; after that the fields are local state.

Browser Whisper (BrowserWhisperCard) — UI-only port
  Persists the enabled flag + model choice under the same localStorage
  keys the vanilla BrowserWhisper module reads, so behavior will light
  up automatically when the recording components port. The preload +
  WASM transcription flow stays in vanilla for this commit — noted in
  the page copy so users aren't surprised.

Web Speech Recognition (WebSpeechCard) — UI-only port
  Same localStorage approach. Enabling surfaces a styled ConfirmModal
  with the HIPAA privacy warning before persisting. Enabling Web Speech
  flips Browser Whisper off automatically (mirrors vanilla priority:
  Web Speech > Browser Whisper > server).

My Templates (TemplatesCard)
  Full Memories CRUD for non-correction entries: category select,
  name, content textarea, Add/Update toggle (in-place edit), per-row
  Delete confirm. Hits /api/memories {GET, POST, PUT, DELETE}.

AI Corrections (CorrectionsCard)
  Read-only list filtered to category starting with 'correction_'.
  Per-row expand reveals the parsed ORIGINAL / CORRECTED TO: split
  (same text delimiter the vanilla parseCorrection() uses). Delete is
  wired through /api/memories/:id.

Audio Backups (AudioBackupsCard)
  Lists /api/audio-backups (server-stored, 24h TTL). Play opens the
  decompressed audio stream in a new tab; Delete hits DELETE
  /api/audio-backups/:id. Retry flow stays in vanilla for this commit —
  it re-submits to /api/transcribe and that integration belongs with
  the recording components.

Saved Encounters (SavedEncountersCard)
  Lists /api/encounters/saved with label / type / expires / preview.
  Delete only — Resume requires the encounter pages to receive
  pre-filled state, which ports alongside those pages.

Compliance (ComplianceCard)
  Static info card — plain JSX, no API.

shared/types.ts + client/src/shared/types.ts — additive only:
  UserPreferencesOk, PreferencesOptionsOk, VoiceOption,
  SavedEncounterRow, SavedEncountersListOk, AudioBackupRow,
  AudioBackupsListOk, MemoryRow, MemoriesOk.

e2e/tests/settings-react-voice-content.spec.js — seven smoke tests
covering control presence, templates empty-save validation, and the
Web Speech privacy-confirm modal (with the no-native-dialog guard).

Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Final bundle 425.42 kB / 121.55 kB gzipped (+21 kB over commit 2).
The e2e container still predates /app/*; running these specs against
it needs a rebuild.
2026-04-23 23:40:33 +02:00
github-actions[bot]
4d482f88da Release v6.29.0 2026-04-23 21:35:21 +00:00
Daniel
2d93c0eaf5 feat(client): port Settings — Integrations (Nextcloud + Documents)
Second of three commits porting the vanilla settings.html. This one
delivers the two Integrations sub-sections, rendered below the Security
block and shown to every authenticated user (not gated by canLocalAuth —
SSO users also integrate Nextcloud and manage documents).

client/src/pages/Settings.tsx — NextcloudCard
  Form + status line driven by /api/auth/me. Connect POSTs
  /api/nextcloud/connect (nextcloudUrl / username / appPassword), which
  does a PROPFIND probe against the remote, creates the target folder
  via MKCOL, and encrypts the app password at rest. On success we
  invalidate the ['auth-me'] query so the status line flips to
  "Connected to …" without a reload. Disconnect goes through a
  ConfirmModal (not a native confirm) and POSTs /api/nextcloud/disconnect.

  When connected, a second row exposes the "Learning Hub — Default
  Browse Path" input backed by POST /api/user/webdav-path. (That handler
  lives inline in server.ts, not in userPreferences.ts — a quirk of the
  existing codebase that the port preserves.)

client/src/pages/Settings.tsx — DocumentsCard
  React Query feed off /api/documents. When S3 is not configured the
  server returns { s3_configured: false } and we render a static notice
  instead of the upload area (same branch as vanilla documents.js). The
  upload form bypasses the JSON api wrapper to send multipart FormData
  directly via fetch with credentials: 'include' (cookie auth continues
  to work). Downloads hit /api/documents/:id/download to receive a 5-min
  presigned URL which we open in a new tab. Delete goes through the
  shared ConfirmModal — replaces the vanilla showConfirm({ danger, … }).

  Downloading-state spinner is per-row (useMutation.variables === doc.id)
  so other rows stay clickable while one is in flight.

shared/types.ts + client/src/shared/types.ts
  Additive only:
    - AuthUser gains webdav_learning_path — already returned by
      /api/auth/me but missing from the type.
    - New response shapes: NextcloudConnectOk, UserDocument,
      DocumentsListOk, DocumentUploadOk, DocumentDownloadOk.

e2e/tests/settings-react-integrations.spec.js
  Four smoke tests: field presence, empty-form validation error, the
  S3-configured-or-notice branch renders, and a repeat of the
  no-native-dialog guard covering the Integrations interactions.

Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 404.56 kB / 117.12 kB gzipped (+9 kB over commit 1). The e2e
container still predates /app/*; running these specs needs a rebuild.
2026-04-23 23:35:12 +02:00
github-actions[bot]
3d2af70b6d Release v6.28.0 2026-04-23 21:31:27 +00:00
Daniel
c8eec17005 feat(client): port Settings — Security (password, 2FA, sessions)
First of three commits porting the vanilla settings.html (13 sub-sections
total) to the React tree. This commit delivers the Settings page shell
plus the three Security sub-sections. Integrations (Nextcloud, Documents)
and Voice + Content land in the two follow-ups.

client/src/pages/Settings.tsx
  Page shell that fetches /api/auth/me and conditionally renders the
  local-auth sections only when user.canLocalAuth !== false. SSO-only
  users see a brief "managed by your identity provider" notice instead —
  matches vanilla behavior, which hides those cards for SSO accounts.

  Change Password: three-field form (current / new / confirm) with
  client-side validation (8+ chars, match). POSTs /api/auth/change-password.
  On success the server destroys all OTHER sessions, so the component
  invalidates the ['sessions'] query so the Active Sessions card below
  refreshes without a full reload. passwordWarning (pwned-password hint)
  surfaces as a follow-up info toast.

  Two-Factor Auth: status line ("Enabled" / "Not enabled") reads
  user.totp_enabled. Enable button POSTs /api/auth/setup-2fa, renders the
  returned QR + secret, accepts the 6-digit code and POSTs /verify-2fa.
  First-enable shows a one-shot BackupCodesDisplay modal with Copy + close.
  Disable flow is inline (password field + Confirm Disable + Cancel, no
  modal) — matches the vanilla UX. Backup-codes remaining count pulls
  from /api/auth/2fa/backup-codes/count; a Regenerate button opens a
  ConfirmModal with requirePassword=true and POSTs /2fa/backup-codes.

  Active Sessions: useQuery on /api/sessions renders one row per session
  with the current one highlighted. Per-row Revoke opens a ConfirmModal;
  Revoke All Other Sessions opens another ConfirmModal. Both DELETE calls
  invalidate ['sessions'] on success.

client/src/components/ConfirmModal.tsx
  Reusable styled confirmation dialog — replaces vanilla showConfirm().
  Supports a danger variant (destructive button styling) and an optional
  password-input variant for confirm-by-password flows. Escape closes,
  backdrop click closes, Enter in the password field submits. Carries
  data-testid hooks (confirm-modal-ok, confirm-modal-cancel) so Playwright
  can drive it without ever hitting window.confirm().

shared/types.ts + client/src/shared/types.ts
  Additive changes only:
    - AuthUser gains optional canLocalAuth, totp_enabled, email_verified,
      nextcloud_url/user/folder, created_at — all fields the server
      already returns from /api/auth/me but the type had never described.
    - SessionRow reshape to match the wire format the server actually
      returns (snake_case ip_address / device_label / created_at /
      last_activity), replacing the speculative camelCase draft. No
      existing consumer of SessionRow existed outside Settings, so the
      rename is a no-op for current code.
    - New types for 2FA + change-password response shapes (Setup2faOk,
      Verify2faOk, BackupCodesCountOk, RegenBackupCodesOk,
      ChangePasswordOk, RevokeAllSessionsOk).

client/src/App.tsx
  Adds <Route path="/settings" element={<Settings />} />.

client/src/components/Layout.tsx
  Flips the Settings nav entry to available: true.

e2e/tests/settings-react-security.spec.js
  Five smoke tests against /app/settings mirroring the coverage of
  settings-faq-dictation.spec.js for the vanilla tree: field/button
  presence for all three sections, password-mismatch inline error,
  revoke-all click surfaces the styled modal (with an explicit
  page.on('dialog') guard to catch any future regression to native
  confirm()).

Note: the e2e container image currently predates the /app/* route
(its /app/public/app/ directory is absent), so running this spec requires
a rebuild — deferred to Daniel's call. Client tsc -b, server tsc --noEmit,
and vite build all pass locally.
2026-04-23 23:31:14 +02:00
github-actions[bot]
26a8420ad5 Release v6.27.0 2026-04-23 20:25:52 +00:00
Daniel
72df4f4852 feat: port Vaccine Schedule + Catch-Up Schedule (real tables, not stubs)
Instead of shipping placeholder pages, added a new backend endpoint
so the React client can render the real AAP/CDC tables without
duplicating the 2000-line pediatricScheduleData module in the client
bundle:

  GET /api/schedule-data  (authed)
    Returns { visitAges, periodicity, catchUpSchedule, vaccineFullNames }
    — everything the vaccine table and catch-up views need from the
    server-side pediatricScheduleData require(). VACCINE_FULL_NAMES
    (which was inlined in public/js/wellVisit.js) now lives in the
    route file so it's single-sourced.

client/src/pages/VaxSchedule.tsx
  useQuery → /api/schedule-data. Renders the full vaccine × visit-age
  grid with sticky header + sticky first column for long scrolling.
  Each filled cell shows the dose number or bullet, with the original
  note text on hover.

client/src/pages/Catchup.tsx
  Card-per-vaccine layout with min-age / min-interval tables and
  catch-up notes. Matches the vanilla layout.

Layout: vaccine + catch-up links now available in the sidebar.

Typecheck green both sides. Vite build 382 kB / 112 kB gzipped.
2026-04-23 22:25:41 +02:00
github-actions[bot]
aa79b6d4d5 Release v6.26.0 2026-04-23 20:21:45 +00:00
Daniel
678408b475 feat(client): port Hospital Course, Chart Review, Well Visit
Three more Notes tabs:

  /hospital   → HospitalCourse.tsx
    Textarea with blank-line-separated progress notes (one block =
    one note) + H&P + format selector (auto / prose / day-by-day /
    organ-system). Calls /api/generate-hospital-course.

  /chart      → ChartReview.tsx
    Type selector (outpatient / subspecialty / ED) with a dynamic
    array of visit entries — user can add/remove visits. Each
    visit has date / content / labs. Calls /api/generate-chart-review.

  /wellvisit  → WellVisit.tsx
    Vitals, measurements, parent concerns, transcript, screenings,
    immunizations, note style. Minimum-viable Visit Note port —
    the vanilla tab's milestone / SSHADESS / by-visit sub-panes
    become their own sub-routes in a follow-up. Calls /api/well-visit/note.

All three reuse the established pattern: form state → Zod (where a
schema exists in shared/schemas.ts) → useMutation → display pane
with copy-to-clipboard.

Every Notes group item is now marked available in the Layout sidebar.

Build: 377 kB / 110 kB gzipped (+16 kB over previous).
2026-04-23 22:21:34 +02:00
github-actions[bot]
b77cc64747 Release v6.25.0 2026-04-23 20:19:22 +00:00
Daniel
54f30cbacb feat(client): port Encounter HPI, SOAP, Sick Visit
Three more tabs behind /app/*:

  /encounter  → Encounter.tsx   — POST /api/generate-hpi-encounter
  /soap       → Soap.tsx        — POST /api/generate-soap (full or
                                  subjective-only output type)
  /sickvisit  → SickVisit.tsx   — POST /api/sick-visit/note (chief
                                  complaint is the only required
                                  field; transcript optional)

All three share the same skeleton: demographic triad + textarea +
Zod-validated submit + copy-to-clipboard output pane. Follow-up work
per tab (save/load, refine, audio capture) lands in subsequent
commits — this first pass proves the AI generate path for each.

Layout sidebar updated: Encounter HPI / SOAP Note / Sick Visit now
marked available. Pending stubs remain for Hospital Course, Chart
Review, Well Visit, and all Clinical Tools + Settings.

Build: 361 kB / 109 kB gzipped (+11 kB over previous, as expected
for three small pages using the existing lib/api + shared schemas).

Typecheck + vite build both green on the client side.
2026-04-23 22:19:12 +02:00
github-actions[bot]
e20af37bf5 Release v6.24.0 2026-04-23 20:17:05 +00:00
Daniel
b0d549766b feat(client): port FAQ + Dictation to React, add sidebar Layout
Three new pages behind the /app/* React router:

client/src/components/Layout.tsx
  Sidebar + main content shell. NavLink-based nav with a single
  NAV data structure mirroring the vanilla app's sidebar groups
  (Encounters / Notes / Clinical Tools / Account). Items with
  `available: false` render as greyed-out 'pending' stubs so the
  future tab list is visible during migration without breaking
  clicks. Vanilla-app fallback link is pinned at the top so anyone
  needing a feature not yet ported can jump back to /.

client/src/pages/Faq.tsx
  8 sections, 27 questions ported verbatim from
  public/components/faq.html. Collapsible accordion pattern via
  local useState — no Radix dependency yet. Content lives in
  client/src/data/faq.ts (extracted from the HTML via a one-off
  python parse, so re-extraction is reproducible if the vanilla
  FAQ ever grows).

client/src/pages/Dictation.tsx
  Minimum-viable port of Voice Dictation → HPI. Demographics
  (age / gender / setting), transcript textarea, Zod-validated
  submit to POST /api/generate-hpi-dictation, result pane with
  copy-to-clipboard. Not yet ported from the vanilla tab:
  MediaRecorder audio capture + /api/transcribe upload, save/load
  popover, refine + shorten buttons, Nextcloud export. Each of
  those is its own follow-up.

client/src/App.tsx
  All routes now render inside <Layout />. New routes wired:
  /, /extensions, /dictation, /faq. A catch-all Navigate redirects
  any unknown /app/* path back to home.

Build check:
  client: npx tsc -b     → EXIT 0
  client: npx vite build → 350 kB / 108 kB gzipped
Public bundle at public/app/index-BmpHzFRb.js replaces the previous
one; committed so the next prod rebuild ships it atomically.

Nothing on the backend changed. /api/generate-hpi-dictation and
/api/extensions already exist; the React pages just call them.
2026-04-23 22:16:52 +02:00
github-actions[bot]
5c2729f785 Release v6.23.0 2026-04-23 20:09:15 +00:00
Daniel
36e4aed223 feat: day 7 — first tab ported to React (Extensions) + Express serves /app/*
End-to-end proof that the full React + Vite + Tailwind + TypeScript
pipeline works against the existing Express API:

client/src/pages/Extensions.tsx
  Minimum-viable port of the Extensions tab. Fetches /api/extensions
  via the typed api wrapper; renders an add form backed by Zod
  validation (ExtensionCreateSchema). Uses @tanstack/react-query for
  server state (queryKey: ['extensions'], invalidate on mutate).
  Full CRUD UI (trash / restore / purge / search) is a follow-up —
  this ships just enough to prove the stack works.

client/src/lib/api.ts
  Thin fetch wrapper. Every React page goes through apiFetch<T>(),
  which narrows ApiResponse<T> to the success shape and throws
  ApiError on failure. Central spot for future request/response
  instrumentation, auth-token refresh, etc.

client/src/App.tsx
  Replaced the Vite starter splash screen with a minimal router:
  BrowserRouter basename='/app', routes for / (landing) and
  /extensions. QueryClientProvider wraps the tree so every page can
  use useQuery/useMutation.

client/src/shared/
  Mirrored copy of /shared/types.ts + schemas.ts. Canonical source
  stays at /shared/ (used by backend). A post-migration task is to
  wire proper TypeScript project references so the client can import
  straight from /shared — TS 6's cross-root bundler-mode paths
  resolution isn't pulling it in cleanly. For now, the mirror is
  header-annotated 'do not edit, mirror only'.

client/src/index.css
  Tailwind v4 @theme block declaring the shadcn design tokens as
  first-class CSS custom properties, which exposes the
  bg-background / text-foreground / border-border utility classes
  the page components use. v4 no longer uses @apply for these —
  the theme block is the idiomatic form.

server.ts
  Added:
    app.get('/app/*splat', ...) → sendFile public/app/index.html
  so React Router deep links (e.g. /app/extensions) resolve
  client-side. Express static middleware below continues to serve
  the hashed /app/assets/*.js + .css.

Build output (checked into public/app/ so the next prod docker
rebuild ships the React bundle without requiring a client/npm
install step in the Dockerfile — that's a day-8 refinement):
  index.html    0.46 kB   gzip  0.29 kB
  index.css     9.51 kB   gzip  2.69 kB
  index.js    329.24 kB   gzip 100.98 kB

Typecheck green on both sides:
  server: npx tsc --noEmit   → EXIT 0
  client: npx tsc -b         → EXIT 0
  client: npx vite build     → 150 modules, 219ms

How to see it live (after Daniel rebuilds prod):
  https://<host>/app/            → React landing page
  https://<host>/app/extensions  → React-rendered Extensions list
  https://<host>/                → unchanged vanilla JS app

Nothing destructive. The vanilla JS /extensions tab still works
identically. The React /app/extensions route talks to the same
/api/extensions backend endpoints. Both render from the same
PostgreSQL rows.

This closes out the 7-day migration scaffolding. The rest is a
port-one-tab-at-a-time grind that Codex (or anyone) can pick up
tab-by-tab with the Playwright suite as the safety net.
2026-04-23 22:09:04 +02:00
github-actions[bot]
9dfc62c75c Release v6.22.0 2026-04-23 19:58:47 +00:00
Daniel
c80a645abf feat(client): day 6 — scaffold React 19 + Vite + Tailwind v4 + shadcn/ui
New client/ directory, standalone from the backend: Vite 8 + React
19.2 + TypeScript 6. Tailwind v4 via @tailwindcss/vite plugin (no
postcss config needed). shadcn/ui compatibility wired via
components.json + lib/utils.ts. @tanstack/react-query + react-router-dom
installed for server state + routing.

Vite config essentials:
  base: '/app/'     — Express mounts SPA at /app/*, vanilla JS stays at /
  outDir: '../public/app/' — build lands next to existing static assets
  @/*     → ./src/*      (client-local imports)
  @shared/* → ../shared/* (typed wire protocol shared with backend)
  server.proxy '/api' → localhost:3000 for `npm run dev`

tsconfig.app.json — added baseUrl + paths + include ../shared so the
shared/types.ts + schemas.ts are typechecked on the client side too.

src/index.css — Tailwind v4 @import, shadcn HSL design tokens (light +
dark schemes). Replaces the vite starter template's decorative styles.

No backend touched. Express route serving /app/* lands in Day 7.
2026-04-23 21:58:37 +02:00
Daniel
47e1af43d6 build(ts): day 5 — Vitest + Zod + Knip tooling
Adds the three high-ROI tools the planning conversation identified:

  vitest 4.x — fast TS-native unit test runner. Runs against pure
    functions (calculators, validators, prompt builders). Playwright
    stays for e2e. package.json `npm test` now runs Vitest;
    `npm run test:node` preserves the old `node --test` runner
    for the 3 legacy tests under test/.

  zod 4.x — runtime request-body validation at API boundaries. The
    new shared/schemas.ts exports a schema per endpoint request
    (LoginRequestSchema, SoapRequestSchema, PeNarrativeRequestSchema,
    ExtensionCreateSchema, etc.). Routes will adopt these one at a
    time post-migration — usage pattern:
      const body = SoapRequestSchema.parse(req.body);
    Invalid input becomes a structured 400 instead of a silent
    undefined-access crash.

  knip 6.x — dead-code / unused-export detector. knip.json scopes
    it to the backend (client/ excluded since it lives in its own
    workspace). Run with `npm run lint:dead`. Catches the class of
    bug that kept public/js/adminMilestones.js dead-loaded for a
    year — a future orphaned file would fail the lint.

  @vitest/coverage-v8 — coverage reporter backed by v8 profiler.

Shipped schemas.test.ts with 8 example cases to prove the toolchain
(`npx vitest run` green).

Not done on day 5 (punted to post-migration): flipping tsconfig to
`strict: true`. That cascade would light up hundreds of implicit-
any errors in handler signatures that would cost more commit bandwidth
than available in this pass. The Day 4 permissive mode is already
catching the big wins (wrong response shapes, orphan refs, undefined
destructures). Post-migration, Codex/vendor model can flip strict flags
one at a time and fix handler-by-handler.
2026-04-23 19:54:30 +02:00
Daniel
f71241cd82 refactor(ts): day 4 — middleware + utils + db .js → .ts (24 files)
All remaining backend files renamed:
  src/middleware/auth.ts, logging.ts (2 files)
  src/utils/*.ts         (20 files: ai, auditQueue, config, crypto,
                          embeddings, errors, fileType, logger, models,
                          notify, passwords, platform, promptSafe,
                          prompts, redact, sessions, transcribe*,
                          ttsGoogle)
  src/db/database.ts, migrate.ts (2 files)

Spot-fixes to satisfy tsc (all within the spirit of 'no behavior
change' — added `: any` annotations where the original JS relied on
duck typing that tsc's default inference narrows too aggressively):

  utils/ai.ts — body, converseParams, request literals + fallback
    result object + err.code/model/message casts. AI client has lots
    of provider-specific ad-hoc object shapes; Day 5 will replace the
    `any`s with proper provider-response interfaces.
  utils/embeddings.ts — payload + request as `any`; generateEmbedding
    call sites pass `undefined as any` for the now-required second
    arg (model) until we refactor the signature.
  utils/prompts.ts — PROMPTS typed as Record<string, any> so
    .loadFromDb / .updatePrompt / .getAllPrompts attachments after
    the const literal compile.
  utils/transcribeLocal.ts — buildArgs() has two `var args = [...]`
    in the same function scope (var-hoisted); both now typed as
    any[] so they don't type-clash across conditionals.

Backend is now 54 of 54 TypeScript files, permissive mode.
`npm run typecheck` EXIT 0. Prod container still running the old
JS image — no Dockerfile change yet.

Next: Day 5 flips strict: true, fixes every error tsc surfaces, adds
Vitest + Zod + Knip tooling.
2026-04-23 19:52:16 +02:00
Daniel
3d597d61a8 refactor(ts): day 3 batch 6 — final 8 routes renamed
admin.ts adminConfig.ts adminMilestones.ts
  auth.ts oidc.ts
  learningHub.ts learningAI.ts learningAdmin.ts

These were the largest files (auth alone is ~600 lines). Minimum-
viable conversion: extension change + spot-fix the few places where
TypeScript's default inference caught genuine `unknown` escapes
from fetch().json() — patched with `: any` type annotations so the
compile passes. Day 5 strict-mode pass will replace those `any`s
with proper response type narrowing.

Fixes in this batch:
- adminConfig.ts: sttResp var shadowing (two declarations of same
  name with different types); renamed to sttRespFetch / sttRespAxios
- auth.ts: turnstileData + tsData from fetch(...).json() now `: any`
- auth.ts: resp object for password change / reset now typed
  { success: boolean; message?: string; passwordWarning?: string }
- learningAI.ts: parseInt(questionCount) cast through `any`

All 29 of 29 routes now .ts. Backend progress: 30/54 files
migrated (server.ts + 29 routes). Remaining: 2 middleware + 20
utils + 2 db files. Day 4 handles those.

tsc --noEmit green (EXIT 0).
2026-04-23 19:48:56 +02:00
Daniel
acdd9313d0 refactor(ts): day 3 batch 5 — billing, nextcloud (+ CPT/ICD10 types) 2026-04-23 19:46:25 +02:00
Daniel
9da31b4d34 refactor(ts): day 3 batch 4 — hospital course, encounters, memories, documents, audio backups
hospitalCourse.ts — /api/generate-hospital-course + clarify/update
  encounters.ts     — /api/encounters/saved CRUD with optimistic locking
  memories.ts       — /api/memories CRUD + /context + /correction
  documents.ts      — S3-backed /api/documents upload/list/download/delete
  audioBackups.ts   — /api/audio-backups gzip+AES storage

19 of 29 routes converted. 19/54 backend files. tsc --noEmit green.
2026-04-23 19:43:42 +02:00
Daniel
c48406536b refactor(ts): day 3 batch 3 — clinical note generators
sickVisit.ts       — /api/sick-visit/note
  wellVisit.ts       — /api/well-visit/{shadess,note}
  peGuide.ts         — /api/generate-pe-narrative (with typed PeStep)
  milestones.ts      — /api/{milestones-data, generate-milestone-narrative, generate-milestone-summary}
  chartReview.ts     — /api/generate-chart-review (typed VisitEntry etc.)

All five follow the established pattern. Added a handful of inline
interfaces (PeStep, MilestoneItem, VisitEntry) where the existing code
was juggling anonymous object shapes — these will migrate into
shared/types.ts during Day 5 if reused elsewhere.

14 of 29 routes converted. Progress: 14/54 backend files.
tsc --noEmit green.
2026-04-23 19:39:51 +02:00
Daniel
5ebabb633a refactor(ts): day 3 batch 2 — hpi, soap, refine, tts, transcribe
Same CJS-compatible pattern as batch 1 (import express = require;
const {...} = require for internal utils; export = router). No
behavior change — TS only strips annotations during compile.

AI route touchpoints:
  /api/generate-hpi-encounter, /generate-hpi-dictation  → hpi.ts
  /api/generate-soap                                    → soap.ts
  /api/refine, /shorten, /clarify                       → refine.ts
  /api/text-to-speech                                   → tts.ts
  /api/transcribe, /transcribe/status                   → transcribe.ts

All five handlers retain identical request/response shapes; the
shared/types.ts contract was defined on day 2 from these very files.

One fetch() typing fix: the transcribe route's LiteLLM branch uses
DOM fetch, but the file imports Express's Response type, so the
`.then((r: Response) => ...)` annotation was shadowing the global
Response. Removed the explicit annotation — tsc infers correctly.

Progress: 9 of 54 files migrated. tsc --noEmit green.
2026-04-23 19:36:43 +02:00
Daniel
bf47d29ab5 refactor(ts): day 3 batch 1 — convert 4 small route files
Migrates the 4 smallest and best-understood routes to TypeScript:
  src/routes/logs.ts
  src/routes/userPreferences.ts
  src/routes/sessions.ts
  src/routes/extensions.ts   (11 Playwright tests cover this one)

Pattern established for the remaining 25 routes:

  import express = require('express');            // CJS-style, fully typed
  import type { Request, Response } from 'express';
  const db = require('../db/database');           // stays `any` until Day 4
  const { authMiddleware } = require('../middleware/auth');

  const router = express.Router();
  router.get('/foo', async function (req: Request, res: Response) {
    // req.user is typed via src/types/express.d.ts augmentation
  });

  export = router;                                 // CJS-compatible export

Why `import = require()` instead of `import from`:

Express is imported via the namespace-import syntax so tsc preserves
`require("express")` in the emitted CJS. Using plain ES import would
emit `require("express").default` which doesn't exist on Express'
CommonJS default export. The pattern keeps the compiled dist/*.js
byte-identical to what vanilla Node expects.

Why `const { authMiddleware } = require(...)` for other imports:

The middleware + utils files are still .js and their module.exports
shape isn't fully typed yet (Day 4 work). Using `require` avoids
dragging Day 4 work forward; the destructuring still gives us the
variable name we want.

New file — src/types/express.d.ts:

Augments Express.Request with `user?: AuthUser` and `sessionId?: string`
(attached by authMiddleware). Route handlers now type-check against
`req.user!.id` instead of requiring a runtime cast.

`req.user!` uses non-null assertion because authMiddleware guarantees
user is set for every mounted route; strict mode on Day 5 will keep
the assertion but add a type-guard check inside authMiddleware itself
so it propagates.

Verification

- npm run typecheck → 0 errors
- scripts/lint-references.js → green
- Compiled dist/src/routes/logs.js diffs against the original .js by
  whitespace + var→const only. Behavior preserved.

Nothing deployed. Prod + e2e containers still run the previous image.

Progress: 1/30 (server.ts) + 4/29 routes = 5 of 54 total files migrated.
Remaining batches go in subsequent Day 3 commits.
2026-04-23 19:31:20 +02:00
Daniel
3b69d85225 build(ts): day 2 — shared/types.ts + server.ts rename (no behavior
change)

Creates the wire-protocol contract every route and every client
component will import from. Renames server.js → server.ts as a pure
rename (zero bytes of logic changed) so the entry point becomes the
first file tsc type-checks against the real compilerOptions.

shared/types.ts — what's in it and why

- ApiResponse<T> envelope: (ApiOk<T> & T) | ApiErr. Every route
  returns one of these; client code narrows on `r.success`.
- One typed "Ok" shape per endpoint, keyed by the actual res.json()
  call in the current handler. Walked every src/routes/*.js file
  and transcribed the literal keys: hpi, soap, note, hospitalCourse,
  review, refined, shortened, questions, narrative+summary, etc.
  No inventive renaming — wire stays identical, only the types are
  new.
- Critical mismatches the types now prevent at compile time:
    /api/refine returns `refined` (not `content` — a past bug)
    /api/sick-visit/note (not /api/generate-sick-visit — past bug)
    /api/generate-hospital-course returns `hospitalCourse` (not
      `narrative` — past bug)
  All three were caught and fixed earlier in Playwright; with the
  shared types they become compile errors for any future regression.

server.ts

- Pure rename via `git mv`. Body unchanged.
- Compiled dist/server.js diffs against the original server.js by
  two lines (TypeScript prepends `"use strict"` and the CommonJS
  export marker). No semantic drift.

tsconfig.json tweak

- Include list adds server.ts alongside server.js so tsc doesn't
  silently skip the entry point during the intermediate state
  where `.js` entries might reappear.
- baseUrl removed (deprecated in TS 6); paths now uses './shared/*'.

package.json

- main: dist/server.js (post-compile entry)
- start: node dist/server.js
- prebuild: rm -rf dist (clean emit every time)
- dev: ts-node-dev for fast TS-aware reloads

The Dockerfile is still unchanged. The deployed prod and e2e
containers still run their baked-in server.js from the previous
image — this migration day has no effect on either until the final
rebuild at the end of day 7.

Next: day 3 renames the 29 route files one-by-one, each adding the
ApiResponse<T> type parameter to its res.json() calls.
2026-04-23 19:21:22 +02:00
Daniel
f9d5101158 build(ts): day 1 — bootstrap TypeScript, zero code changes
Installs TypeScript toolchain and configures it to accept every
existing .js file untouched. tsc --noEmit is green; the app runs
identically and nothing is deployed or rebuilt yet.

Config choices:
- extends @tsconfig/node20 (matches the runtime version)
- allowJs: true, checkJs: false — existing files pass through
- strict: false — tightened progressively on day 5, not day 1
- skipLibCheck: true — node_modules .d.ts quality varies, not our
  job on migration day
- paths: { "@shared/*": ["./shared/*"] } — pre-wired for the
  shared/types.ts file landing on day 2

Dependency notes:
- typescript 6.0.3 (current stable, released late 2025)
- @types/express pinned to ^4 because the app uses Express 4.21;
  the default npm install picked @types/express 5 which doesn't
  match runtime shapes for Request/Response
- @tsconfig/node20 for the known-good strict / module / target
  triple for Node 20 LTS
- ts-node-dev for the new `npm run dev` script — transpile-only
  mode, keeps startup fast

package.json script additions:
- build: tsc (compiles to dist/)
- typecheck: tsc --noEmit (CI-friendly)
- dev: ts-node-dev for TS-aware hot reload
- lint:refs: wraps the existing static reference linter

Left alone (no behavior change):
- start: still node server.js
- Dockerfile unchanged (prod still runs vanilla JS)
- All 54 backend .js files untouched

Day 1 scope matches the migration rule: no runtime behavior changes,
nothing deployed. Next: day 2 creates shared/types.ts and flips
server.js to server.ts.

Reference tag: pre-migration-v1 (commit 447eb78).
2026-04-23 19:18:01 +02:00
github-actions[bot]
4760d3d55a Release v6.21.0 2026-04-23 16:58:52 +00:00
470 changed files with 37508 additions and 65983 deletions

View file

@ -3,6 +3,7 @@
!.env.example
.git
.gitignore
.agent-config
node_modules
data/
*.log

View file

@ -1,184 +0,0 @@
name: Forgejo Android APK
on:
workflow_dispatch:
push:
branches:
- '**'
tags:
- 'v*'
jobs:
build:
name: Build signed APK
runs-on: forgejo-local
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK 17
uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Node 20
uses: https://github.com/actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: mobile/package-lock.json
- name: Set up Android SDK
uses: https://github.com/android-actions/setup-android@v3
- name: Install Capacitor dependencies
working-directory: mobile
run: |
npm install --no-audit --no-fund
npx cap sync android
- name: Restore signing keystore
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
test -n "$KEYSTORE_B64"
CLEAN_KEYSTORE_B64="${KEYSTORE_B64#ANDROID_KEYSTORE_BASE64=}"
printf '%s' "$CLEAN_KEYSTORE_B64" | tr -d '\r\n' | base64 -d > "$RUNNER_TEMP/pedscribe-release.jks"
test -s "$RUNNER_TEMP/pedscribe-release.jks"
- name: Build signed release APK
working-directory: mobile/android
env:
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file="$RUNNER_TEMP/pedscribe-release.jks" \
-Pandroid.injected.signing.store.password="$KS_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS" \
--no-daemon --stacktrace
- name: Check Google Play secret
id: play_publish
run: |
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
elif [ -z "${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64:-}" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64 }}
- name: Build signed release App Bundle
if: steps.play_publish.outputs.enabled == 'true'
working-directory: mobile/android
env:
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file="$RUNNER_TEMP/pedscribe-release.jks" \
-Pandroid.injected.signing.store.password="$KS_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS" \
--no-daemon --stacktrace
- name: Install fastlane
if: steps.play_publish.outputs.enabled == 'true'
working-directory: mobile/android
run: |
gem install bundler -N
bundle install
- name: Upload bundle to Google Play (internal track)
if: steps.play_publish.outputs.enabled == 'true'
working-directory: mobile/android
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64 }}
PLAY_TRACK: internal
run: |
test -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64"
CLEAN_PLAY_JSON_B64="${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64#GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64=}"
printf '%s' "$CLEAN_PLAY_JSON_B64" | tr -d '\r\n' | base64 -d > fastlane/google-play-service-account.json
AAB=$(find app/build/outputs/bundle/release -name '*.aab' | head -1)
test -n "$AAB"
AAB_PATH="$AAB" bundle exec fastlane android publish_internal
rm -f fastlane/google-play-service-account.json
- name: Collect APK
run: |
mkdir -p artifacts
APK=$(find mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
test -n "$APK"
cp "$APK" "artifacts/pedscribe-${GITHUB_REF_NAME:-manual}.apk"
- name: Upload APK artifact
uses: https://github.com/actions/upload-artifact@v3
with:
name: pedscribe-android-apk
path: artifacts/*.apk
retention-days: 30
- name: Publish Forgejo release
if: startsWith(github.ref, 'refs/tags/v')
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
TARGET_COMMIT: ${{ github.sha }}
run: |
test -n "$FORGEJO_TOKEN"
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
APK=$(find artifacts -name '*.apk' | head -1)
test -n "$APK"
node - <<'NODE'
const fs = require('fs');
fs.writeFileSync('release-payload.json', JSON.stringify({
tag_name: process.env.TAG_NAME,
target_commitish: process.env.TARGET_COMMIT,
name: process.env.TAG_NAME,
body: 'Signed Android APK for Obtainium updates.',
draft: false,
prerelease: false,
}));
NODE
status=$(curl -sS -o release.json -w '%{http_code}' \
-X POST "$API_URL/releases" \
-H "Authorization: token $FORGEJO_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @release-payload.json)
if [ "$status" = "409" ]; then
curl -fsS "$API_URL/releases/tags/$TAG_NAME" \
-H "Authorization: token $FORGEJO_TOKEN" > release.json
elif [ "$status" != "201" ]; then
cat release.json
exit 1
fi
RELEASE_ID=$(node -e "console.log(JSON.parse(require('fs').readFileSync('release.json', 'utf8')).id)")
ASSET_NAME=$(basename "$APK")
export ASSET_NAME
curl -fsS "$API_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $FORGEJO_TOKEN" > release-assets.json
EXISTING_ASSET_ID=$(node -e "const fs=require('fs'); const name=process.env.ASSET_NAME; const assets=JSON.parse(fs.readFileSync('release-assets.json','utf8')); const asset=assets.find((item)=>item.name===name); if (asset) console.log(asset.id);" )
if [ -n "$EXISTING_ASSET_ID" ]; then
curl -fsS -X DELETE "$API_URL/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" \
-H "Authorization: token $FORGEJO_TOKEN"
fi
curl -fsS -X POST "$API_URL/releases/$RELEASE_ID/assets?name=$ASSET_NAME" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@$APK" > release-asset.json

View file

@ -1,45 +0,0 @@
name: Forgejo Docker Build
on:
workflow_dispatch:
inputs:
push_image:
description: Push image to Forgejo container registry
required: false
default: 'true'
jobs:
build:
name: Build Docker image
runs-on: forgejo-local
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare compose env files
run: |
touch .env
- name: Validate Compose config
run: docker compose -f docker-compose.yml config >/tmp/ped-ai-compose.yml
- name: Build compose service
run: docker compose -f docker-compose.yml build pediatric-scribe
- name: Tag image
run: |
IMAGE="git.danvics.com/danvics/pediatric-ai-scribe-v3"
SHORT_SHA=$(git rev-parse --short HEAD)
docker tag ped-ai-local:latest "$IMAGE:$SHORT_SHA"
docker tag ped-ai-local:latest "$IMAGE:latest"
- name: Push image to Forgejo registry
if: ${{ github.event.inputs.push_image != 'false' }}
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
IMAGE="git.danvics.com/danvics/pediatric-ai-scribe-v3"
SHORT_SHA=$(git rev-parse --short HEAD)
echo "$FORGEJO_TOKEN" | docker login git.danvics.com -u danvics --password-stdin
docker push "$IMAGE:$SHORT_SHA"
docker push "$IMAGE:latest"

View file

@ -1,16 +0,0 @@
## Summary
-
-
-
## Type of change
- [ ] refactor
- [ ] feature
- [ ] fix
- [ ] docs
## Verification
What did you run locally? (e.g. `npm test`, `npm run typecheck`, manual smoke)
## Linked issues
Closes #

View file

@ -21,7 +21,6 @@ permissions:
jobs:
build:
if: ${{ github.server_url == 'https://github.com' }}
name: Build signed APK
runs-on: ubuntu-latest
steps:

View file

@ -31,7 +31,7 @@ permissions:
jobs:
version:
runs-on: ubuntu-latest
if: "github.server_url == 'https://github.com' && !contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
if: "!contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Checkout
uses: actions/checkout@v4

View file

@ -14,7 +14,6 @@ env:
jobs:
build-apk:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
permissions:
contents: write

View file

@ -1,34 +0,0 @@
name: CI
# Runs root app tests on every PR and push to main.
on:
pull_request:
push:
branches: [main]
# Cancel superseded runs on the same ref to save minutes.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Root app tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install
run: npm install
- name: Unit tests
run: npm test

View file

@ -24,7 +24,6 @@ env:
jobs:
build:
if: ${{ github.server_url == 'https://github.com' }}
# Build one variant per matrix entry, push by digest only.
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
@ -81,7 +80,6 @@ jobs:
retention-days: 1
merge:
if: ${{ github.server_url == 'https://github.com' }}
# Combine the two single-platform digests into one multi-arch manifest
# published under the real tags (vX.Y.Z and latest).
name: Merge manifests

View file

@ -1,30 +0,0 @@
name: Security audit
# Weekly npm audit at high+ severity for the root app. Reports to the job summary; does NOT fail the build
# (advisories appear constantly and a red checkmark train would just get
# muted). Re-run on demand via workflow_dispatch.
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch:
jobs:
audit:
name: npm audit (high+)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Audit root app
run: |
echo '## Root app advisories' >> "$GITHUB_STEP_SUMMARY"
npm audit --audit-level=high --json > legacy-audit.json || true
node -e "const a=require('./legacy-audit.json');const m=a.metadata?.vulnerabilities||{};console.log('high:'+(m.high||0)+' critical:'+(m.critical||0));" >> "$GITHUB_STEP_SUMMARY"
continue-on-error: true

View file

@ -30,7 +30,6 @@ permissions:
jobs:
bump:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
steps:
- name: Checkout

6
.gitignore vendored
View file

@ -2,7 +2,7 @@ node_modules/
.env
.env.local
.env.production
data/
/data/
!public/data/
*.db
*.db-journal
@ -37,7 +37,5 @@ e2e/node_modules/
e2e/test-results/
e2e/playwright-report/
# Codex CLI marker
.codex
.firecrawl/
# Refactored test stack stays local for now

174
BROWSER_WHISPER_SETUP.md Normal file
View file

@ -0,0 +1,174 @@
# Browser Whisper Self-Hosted Setup
## Overview
As of v3, Browser Whisper is **fully self-hosted** with **zero CDN dependencies**. All models and libraries are bundled with the application and served from your own server.
## What Changed
**Before (v2 and earlier):**
- Loaded transformers.js from `cdn.jsdelivr.net`
- Downloaded models from `cdn-lfs.huggingface.co`
- Failed in corporate/clinical networks with firewall restrictions
**Now (v3+):**
- Transformers.js library (v2.6.2) bundled at `/models/transformers.min.js` (760KB)
- Whisper model bundled at `/models/Xenova/whisper-tiny.en/` (42MB)
- Everything served from your own server
- **Works in any network environment** (firewalled, air-gapped, offline)
## Files Included
```
public/models/
├── transformers.min.js (760KB) - Transformers.js v2.6.2 (worker-compatible)
└── Xenova/
└── whisper-tiny.en/ (42MB total)
├── config.json
├── tokenizer.json
├── preprocessor_config.json
├── generation_config.json
└── onnx/
├── encoder_model_quantized.onnx
└── decoder_model_merged_quantized.onnx
```
## How It Works
1. **Worker loads transformers.js locally:**
```javascript
importScripts('/models/transformers.min.js');
```
2. **Transformers.js configured for local models:**
```javascript
T.env.localModelPath = '/models/';
T.env.allowRemoteModels = false;
```
3. **Models load from your server:**
- Browser requests: `GET /models/Xenova/whisper-tiny.en/config.json`
- Served by Express static middleware
- No external network calls
## Docker Build
Models are downloaded **during Docker build** (not runtime):
```dockerfile
RUN curl -sL -o onnx/encoder_model_quantized.onnx \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
```
This means:
- Docker image is ~200MB larger (one-time cost)
- Runtime has zero dependencies
- Works in air-gapped environments (after image is pulled)
## Development Setup
If you're running locally (not Docker), download models:
```bash
cd public/models
mkdir -p Xenova/whisper-tiny.en/onnx
# Download transformers.js
curl -L -o transformers.min.js \
https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js
# Download model files
cd Xenova/whisper-tiny.en
curl -L -o config.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json
curl -L -o tokenizer.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json
curl -L -o preprocessor_config.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json
curl -L -o generation_config.json \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json
curl -L -o onnx/encoder_model_quantized.onnx \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
curl -L -o onnx/decoder_model_merged_quantized.onnx \
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx
```
Or use the helper script:
```bash
./scripts/download-whisper-models.sh
```
## Adding More Models
To add base or small models:
1. **Create directory:**
```bash
mkdir -p public/models/Xenova/whisper-base.en/onnx
```
2. **Download from HuggingFace:**
- https://huggingface.co/Xenova/whisper-base.en
- https://huggingface.co/Xenova/whisper-small.en
3. **Update UI in `settings.html`:**
```html
<option value="Xenova/whisper-base.en">Base (~74MB, better quality)</option>
```
4. **Update Dockerfile** to download during build
## Benefits
**Works everywhere** - No firewall/CDN issues
**Privacy-first** - Audio never leaves browser
**Offline capable** - After initial page load
**No API costs** - Zero transcription expenses
**Predictable** - Same model, same results
**Fast** - Local processing, no network latency
## Limitations
- Docker image is larger (~200MB vs ~150MB)
- Only tiny model included by default (base/small optional)
- Slower than cloud APIs for long recordings
- Requires modern browser with WebAssembly support
## Testing
```bash
# 1. Start server
docker-compose up -d
# 2. Open browser DevTools → Network tab
# 3. Go to Settings → Browser Transcription
# 4. Click "Pre-download model"
# 5. Watch for requests to /models/* (should all be 200 OK from your server)
# 6. NO requests to cdn.jsdelivr.net or huggingface.co
```
## Troubleshooting
**Issue: "Failed to load transformers library"**
- Check: `GET /models/transformers.min.js` returns 200 OK
- Verify file exists: `ls public/models/transformers.min.js`
**Issue: "Model load failed"**
- Check: `GET /models/Xenova/whisper-tiny.en/config.json` returns 200 OK
- Verify files exist: `ls public/models/Xenova/whisper-tiny.en/`
**Issue: Still seeing CDN requests**
- Clear browser cache (Ctrl+Shift+R)
- Check you're running v18+ (`/api/health` should show version)
## Migration from v17
If upgrading from v17:
1. Pull new Docker image: `docker-compose pull`
2. Restart: `docker-compose up -d`
3. Clear browser cache
4. Test: Settings → Browser Transcription → Pre-download
No configuration changes needed - it just works!

View file

@ -0,0 +1,240 @@
# Browser Whisper Troubleshooting
## 🎙️ What is Browser Whisper?
Browser Whisper is an **optional** client-side transcription feature that runs entirely in your browser using WebAssembly. It provides:
- ✅ Zero network transmission (HIPAA-safe)
- ✅ No API costs
- ✅ Works offline
- ✅ Privacy-first (audio never leaves device)
**However**, it requires downloading AI models from CDN servers.
---
## ⚠️ Common Issue: CDN Blocked
### Error Message:
```
NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope':
The script at 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2' failed to load.
```
### What This Means:
Your network/firewall is blocking access to:
- `cdn.jsdelivr.net` (JavaScript library CDN)
- `cdn-lfs.huggingface.co` (AI model files)
### Why It Happens:
1. **Corporate firewall** - Many organizations block CDN domains
2. **Browser extensions** - Ad blockers, privacy tools may block CDN
3. **Network proxy** - Company proxy might filter JavaScript CDN
4. **CSP restrictions** - Very strict Content Security Policy
---
## ✅ Solutions
### Option 1: Use Server Transcription (Recommended)
**Browser Whisper is optional!** The app works perfectly fine with server-side transcription.
**Server transcription providers:**
- Google Gemini (via Vertex AI) - HIPAA-eligible
- AWS Transcribe - HIPAA-eligible
- OpenAI Whisper - Fast, accurate
- LiteLLM - Routes to any provider
**To use server transcription:**
1. Go to Settings → Browser Transcription
2. **Leave it disabled** (or if stuck, disable it)
3. Record audio normally - will use server
**Advantages:**
- More accurate (larger models)
- No download needed
- Works immediately
- Professional grade
### Option 2: Whitelist CDN Domains
If you control your network/firewall, whitelist these domains:
```
cdn.jsdelivr.net
cdn-lfs.huggingface.co
cdn-lfs-us-1.huggingface.co
cdn-lfs-us-2.huggingface.co
huggingface.co
```
**For corporate IT:**
- These are legitimate AI/JavaScript CDNs
- Used by major companies worldwide
- No security risk (public CDN content)
- Required only for browser-based AI features
### Option 3: Disable Browser Extensions
Try disabling:
- Ad blockers (uBlock Origin, AdBlock Plus)
- Privacy extensions (Privacy Badger, Ghostery)
- Script blockers (NoScript, ScriptSafe)
Then refresh and try again.
### Option 4: Try Different Browser
Some browsers have stricter security:
- ✅ **Chrome** - Best compatibility
- ✅ **Edge** - Works well
- ⚠️ **Firefox** - May block CDN
- ❌ **Safari** - Limited WebAssembly support
---
## 🧪 How to Test If It's Working
### Test 1: Check CDN Access
```bash
# From your computer, run:
curl -I https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2
# Should return: HTTP/2 200
# If 403 or timeout: CDN is blocked
```
### Test 2: Browser Console
1. Open DevTools (F12)
2. Go to Console tab
3. Settings → Browser Transcription
4. Click "Pre-download model"
5. Watch for:
```
✅ [WhisperWorker] Transformers library loaded successfully
OR
❌ NetworkError: Failed to load
```
### Test 3: Network Tab
1. Open DevTools (F12)
2. Go to Network tab
3. Click "Pre-download model"
4. Look for requests to:
- `cdn.jsdelivr.net` (should be 200 OK)
- `cdn-lfs.huggingface.co` (should be 200 OK)
5. If blocked: Status will show "failed" or "blocked"
---
## 📊 When to Use Each Option
| Scenario | Recommendation | Why |
|----------|---------------|-----|
| Corporate network | **Server transcription** | CDN likely blocked |
| Home network | **Browser Whisper** | Fast, free, private |
| Mobile device | **Server transcription** | Limited storage/memory |
| Offline use needed | **Browser Whisper** | Works without internet (after initial download) |
| High accuracy needed | **Server transcription** | Larger models available |
| Maximum privacy | **Browser Whisper** | Audio never leaves device |
| Can't access CDN | **Server transcription** | No choice - CDN blocked |
---
## 🔧 Technical Details
### What Gets Downloaded (First Time Only):
**Tiny model** (~39 MB):
- onnx-runtime.wasm (~10 MB)
- whisper-tiny.en model files (~29 MB)
- Cached in browser IndexedDB (permanent)
**Base model** (~74 MB):
- Larger model, better accuracy
**Small model** (~244 MB):
- Best quality, slower processing
### Where It's Stored:
- **Location:** Browser IndexedDB
- **Persistence:** Permanent (until you clear browser data)
- **Shared:** Across all tabs/windows for this domain
- **Size:** Selected model size (39/74/244 MB)
### Performance:
- **Tiny:** 2-3 seconds per 30-second clip
- **Base:** 3-5 seconds per 30-second clip
- **Small:** 6-10 seconds per 30-second clip
---
## ❓ FAQ
**Q: Is Browser Whisper required?**
A: No! It's completely optional. Server transcription works great.
**Q: Why doesn't it work on my corporate network?**
A: Most corporate firewalls block CDN domains for security. Use server transcription instead.
**Q: Can I download the models manually?**
A: Not easily - they're optimized for CDN delivery. Use server transcription if CDN is blocked.
**Q: Will server transcription cost money?**
A: Depends on your provider:
- Google Vertex AI: ~$0.005 per minute
- AWS Transcribe: ~$0.024 per minute
- OpenAI: $0.006 per minute
- Very affordable for typical use
**Q: Is server transcription HIPAA-safe?**
A: Yes, if using:
- Google Vertex AI (with BAA)
- AWS Transcribe (with BAA)
- Azure OpenAI (with BAA)
OpenAI Whisper direct is NOT HIPAA-eligible.
**Q: Can I use both?**
A: Yes! Enable Browser Whisper in Settings. If it fails (CDN blocked), it automatically falls back to server transcription.
**Q: How do I know which one is being used?**
A: Check the toast notification after recording:
- "Transcribed locally" = Browser Whisper
- "Transcribed via google-gemini/aws/openai" = Server
---
## 🚀 Recommended Setup
### For Maximum Privacy (Home Network):
1. Enable Browser Whisper
2. Choose "Tiny" model (fast, good enough for dictation)
3. Pre-download model
4. Use offline
### For Corporate/Clinical Use:
1. Keep Browser Whisper **disabled**
2. Configure server transcription:
```bash
# In .env:
TRANSCRIBE_PROVIDER=google
GOOGLE_VERTEX_PROJECT=your-project
```
3. Use with BAA for HIPAA compliance
### For Best Accuracy:
1. Use server transcription
2. Configure Google Gemini 2.0 Flash or AWS Transcribe Medical
3. Audio quality + large models = best results
---
## 🛠️ Still Having Issues?
1. **Check console logs:** DevTools → Console → Look for `[BrowserWhisper]` errors
2. **Check network logs:** DevTools → Network → Filter by `jsdelivr` or `huggingface`
3. **Verify server transcription works:** Just disable Browser Whisper and record
4. **Contact IT:** Ask to whitelist CDN domains (if you need Browser Whisper)
**Remember:** Browser Whisper is a nice-to-have feature. Server transcription is the primary, production-ready method that works everywhere!

View file

@ -28,7 +28,7 @@ or Actions tab → **Version bump & release** → Run workflow → pick bump typ
| Workflow | Output |
|---|---|
| `.forgejo/workflows/android-apk.yml` | signed APK on Forgejo release (`pedscribe-<tag>.apk`), optional Google Play internal track upload |
| `android-release.yml` | signed APK on GitHub release, `make_latest=true` |
| `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) |
## Local dev

976
DEVELOPER_GUIDE.md Normal file
View file

@ -0,0 +1,976 @@
# Pediatric AI Scribe — Developer Guide
**Version:** 6.0 | **Stack:** Node.js / Express / PostgreSQL / Vanilla JS
---
## Table of Contents
1. [Project Overview](#1-project-overview)
2. [Architecture](#2-architecture)
3. [Directory Structure](#3-directory-structure)
4. [Environment Variables](#4-environment-variables)
5. [Database Schema](#5-database-schema)
6. [Authentication System](#6-authentication-system)
7. [Backend API Reference](#7-backend-api-reference)
8. [Frontend Architecture](#8-frontend-architecture)
9. [AI Integration](#9-ai-integration)
10. [Learning Hub & CMS](#10-learning-hub--cms)
11. [Deployment](#11-deployment)
12. [Known Issues & Security Notes](#12-known-issues--security-notes)
13. [Adding New Features](#13-adding-new-features)
14. [Resetting Admin Password via Console](#14-resetting-admin-password-via-console)
---
## 1. Project Overview
Pediatric AI Scribe is a clinical documentation platform for pediatric healthcare providers. It uses AI (via OpenRouter, AWS Bedrock, or Azure OpenAI) to generate:
- HPI notes from live encounter recordings
- SOAP notes from dictation
- Hospital course summaries
- Chart reviews
- Well-visit notes (including SSHADESS, ROS/PE, milestones)
- Sick visit notes
- Learning Hub content (articles, quizzes, clinical pearls, presentations)
**Key design principle:** Single-page application. All tabs are lazy-loaded HTML components (`/public/components/*.html`). JavaScript modules initialize only when their tab is first activated via the `tabChanged` custom event.
---
## 2. Architecture
```
Browser (Vanilla JS + Tiptap)
|
| HTTP (JWT Bearer token in Authorization header)
|
Express.js (Node.js) — server.js
|
|— Helmet (CSP, security headers)
|— CORS (restricted to APP_URL in production)
|— express-rate-limit (login: 10/15min, register: 5/hr, resend-verify: 3/15min, general: 60/min)
|— cookie-parser
|— Routes (/src/routes/)
|
PostgreSQL (pg driver, no ORM)
|
|— users, app_settings, audit_log, saved_encounters
|— user_memories, learning_*, access_log, api_log
```
### How Requests Flow
1. **Browser** sends HTTP request with `Authorization: Bearer <jwt>` header
2. **Express middleware chain:** Helmet (security headers) → CORS → rate limiter → body parser → logging middleware → route handler
3. **Auth middleware** (`src/middleware/auth.js`) decodes JWT, queries `users` table, attaches `req.user` with `{ id, email, name, role }`
4. **Route handler** processes the request — for AI routes, calls `callAI()` which routes to the configured provider
5. **Database** is accessed via the `pg` driver directly (no ORM). All queries use parameterized placeholders (`$1`, `$2`) to prevent SQL injection
6. **Response** is JSON for API calls, or static files served from `/public`
### AI Providers
Configured via environment variables. The provider is selected at startup in `src/utils/ai.js` using this priority:
1. **AWS Bedrock** — if `AWS_BEDROCK_REGION` is set. HIPAA eligible with BAA. Uses `@aws-sdk/client-bedrock-runtime`. Anthropic models use the native Messages API (`InvokeModel`); all others use the Converse API.
2. **Azure OpenAI** — if `AZURE_OPENAI_ENDPOINT` is set. HIPAA eligible. Uses the OpenAI SDK pointed at your Azure endpoint.
3. **OpenRouter** — default fallback if `OPENROUTER_API_KEY` is set. Routes to 20+ models from various providers. Not HIPAA compliant.
The provider cannot be changed at runtime — it's determined once at startup. To switch providers, update `.env` and restart the container.
### Database Layer
The app uses **raw SQL via the `pg` driver** — no ORM (Sequelize, Prisma, etc.). This is intentional:
- **Simplicity:** Every query is visible and explicit. No magic, no migrations framework, no model definitions to sync.
- **Performance:** No ORM overhead or N+1 query problems.
- **Schema management:** `src/db/database.js` runs `CREATE TABLE IF NOT EXISTS` on startup, plus `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for migrations. This means the schema is always up-to-date when the app starts.
- **Future ORM migration:** If needed, the queries are standard PostgreSQL and can be wrapped by any ORM. The main work would be defining models and replacing direct `db.get()`/`db.run()` calls.
The `database.js` file exports a helper object (`db`) with convenience methods:
- `db.get(sql, params)` — returns first row or `null`
- `db.all(sql, params)` — returns all rows as array
- `db.run(sql, params)` — executes INSERT/UPDATE/DELETE, returns `{ rowCount }`
- `db.getSetting(key)` / `db.setSetting(key, value)` — shorthand for `app_settings` table
---
## 3. Directory Structure
```
/
├── server.js # Express app entry point (route registration, Helmet CSP,
│ # rate limiters, static file serving, error handlers)
├── package.json # Dependencies (~25 production deps, no devDeps)
├── Dockerfile # Multi-stage Node.js 20 Alpine build
├── docker-compose.yml # Production compose (uses Docker Hub image)
├── docker-compose.local.yml # Local development (builds from source, port 3552)
├── admin-cli.js # CLI tool for admin tasks (create user, reset password)
├── DEVELOPER_GUIDE.md # This file
├── src/
│ ├── db/
│ │ └── database.js # DB connection pool (pg.Pool), schema init
│ │ # (CREATE TABLE IF NOT EXISTS for all tables),
│ │ # column migrations (ALTER TABLE ADD COLUMN IF NOT EXISTS),
│ │ # helper methods: db.get(), db.all(), db.run(),
│ │ # db.getSetting(), db.setSetting()
│ ├── middleware/
│ │ ├── auth.js # authMiddleware (JWT decode → req.user),
│ │ # adminMiddleware (role === 'admin'),
│ │ # moderatorMiddleware (role === 'admin' || 'moderator')
│ │ └── logging.js # Logs every request to api_log table (method, path, user, IP, duration)
│ ├── routes/
│ │ ├── auth.js # Login, register, 2FA, password reset, /me
│ │ ├── admin.js # User management (admin only)
│ │ ├── adminConfig.js # Site settings, feature flags, AI prompts, models
│ │ ├── encounters.js # Save/load/delete draft encounters
│ │ ├── memories.js # User templates (physical exam, ROS, etc.)
│ │ ├── hpi.js # Generate HPI from encounter/dictation transcript
│ │ ├── soap.js # Generate SOAP note
│ │ ├── hospitalCourse.js # Generate hospital course summary
│ │ ├── chartReview.js # Generate outpatient chart review
│ │ ├── milestones.js # Generate developmental milestone narrative
│ │ ├── wellVisit.js # Well-visit note generation (ROS/PE/ICD-10)
│ │ ├── sickVisit.js # Sick visit note generation
│ │ ├── refine.js # Refine/shorten any generated document
│ │ ├── transcribe.js # Whisper audio transcription
│ │ ├── tts.js # Text-to-speech (if configured)
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/export/disconnect
│ │ ├── learningHub.js # User-facing: feed, content, quiz submission
│ │ ├── learningAdmin.js # CMS: categories, content, questions CRUD
│ │ ├── learningAI.js # AI generation for Learning Hub content
│ │ └── logs.js # Usage/audit/API/access logs + client error
│ └── utils/
│ ├── ai.js # callAI(messages, options) — routes to OpenRouter/Bedrock/Azure.
│ │ # Handles Anthropic InvokeModel (Messages API) vs Converse API,
│ │ # thinking block extraction, fallback model retry, duration tracking.
│ ├── models.js # OPENROUTER_MODELS[], BEDROCK_MODELS[], AZURE_MODELS[]
│ │ # Each model: { id, name, cost, tag, category, bedrockId, maxOut, regions }
│ │ # getBedrockModelId() maps app IDs to Bedrock/inference profile IDs.
│ │ # getAvailableModels() filters by region. getAvailableModelsWithOverrides()
│ │ # applies admin-disabled/custom models from DB.
│ ├── prompts.js # Default prompt templates for every AI route. Loaded on startup,
│ │ # then overridden by DB values (app_settings: 'prompt.*' keys).
│ │ # PROMPTS.get('key') returns the DB override or default.
│ ├── config.js # App configuration helpers
│ └── logger.js # Winston logger (file + console, JSON format)
├── public/
│ ├── index.html # Single HTML shell, loads all components
│ ├── 404.html # Custom 404 page
│ ├── css/
│ │ └── styles.css # All CSS (single file, ~750 lines)
│ ├── js/
│ │ ├── app.js # Core: tab switching via data-tab buttons, loadComponent()
│ │ │ # fetches HTML from /components/, global helpers (showToast,
│ │ │ # showLoading, getAuthHeaders, getSelectedModel, etc.)
│ │ ├── auth.js # Login/register/forgot-password forms, JWT storage in
│ │ │ # localStorage ('ped_scribe_token'), enterApp()/clearSession(),
│ │ │ # resend verification link handler, 2FA code input
│ │ ├── admin.js # Admin panel: user management, site settings, SMTP config,
│ │ │ # model enable/disable, prompt editor, announcement banner
│ │ ├── liveEncounter.js # MediaRecorder → Whisper transcription → AI HPI generation.
│ │ │ # Handles start/stop recording, timer, save/load encounters
│ │ ├── voiceDictation.js # Web Speech API (real-time) or Whisper (recorded) dictation
│ │ ├── hospitalCourse.js # Paste/dictate hospital course → AI summary
│ │ ├── chartReview.js # Paste/dictate chart data → AI outpatient review
│ │ ├── soap.js # Paste/dictate → AI SOAP note
│ │ ├── milestones.js # Age-based milestone checklist → AI narrative
│ │ ├── wellVisit.js # Well Visit guide: vaccine schedule display, age calculator
│ │ ├── shadess.js # SSHADESS psychosocial form + ROS/PE checkboxes → AI note
│ │ ├── sickVisit.js # Chief complaint + HPI → AI sick visit SOAP
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/disconnect/export settings UI
│ │ ├── encounters.js # Save/load/delete encounter drafts (shared across all tabs)
│ │ ├── memories.js # User template CRUD (physical exam defaults, ROS, etc.)
│ │ ├── learningHub.js # Learning Hub (user feed, content viewer, quiz engine) +
│ │ │ # CMS (category CRUD, content editor with Tiptap, question
│ │ │ # builder, AI generation panel, Nextcloud file picker,
│ │ │ # slide preview modal). Single file, ~1400 lines.
│ │ ├── milestonesData.js # Static milestone data by age group (2mo → 6yr)
│ │ └── pediatricScheduleData.js # CDC vaccine schedule data + catch-up schedule
│ ├── components/ # Lazy-loaded tab HTML (injected by loadComponent)
│ │ ├── encounter.html ├── dictation.html ├── hospital.html
│ │ ├── chart.html ├── soap.html ├── wellvisit.html
│ │ ├── sickvisit.html ├── vaxschedule.html ├── catchup.html
│ │ ├── learning.html ├── cms.html ├── admin.html
│ │ └── settings.html
│ └── vendor/
│ └── tiptap.bundle.js # Tiptap 2 + extensions (esbuild bundle, self-hosted)
```
---
## 4. Environment Variables
Set in `.env` file (copy `.env.example` to get started):
```bash
# ── Required ──────────────────────────────────────────────────
DATABASE_URL=postgresql://user:<password>@host:5432/dbname
JWT_SECRET=change-this-to-a-random-64-char-string
# ── AI Provider (choose one or let it default to OpenRouter) ──
OPENROUTER_API_KEY=sk-or-... # Default provider
# OR
AZURE_OPENAI_ENDPOINT=https://... # Azure (HIPAA eligible)
AZURE_OPENAI_API_KEY=...
AZURE_DEPLOYMENT_NAME=gpt-4o-mini
AZURE_OPENAI_API_VERSION=2024-08-01-preview
# OR
AWS_BEDROCK_REGION=us-east-1 # AWS Bedrock (HIPAA eligible)
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
# ── Optional ──────────────────────────────────────────────────
OPENAI_API_KEY=sk-... # For Whisper transcription only
APP_URL=https://yourdomain.com # Enables secure CORS + Secure cookies
NODE_ENV=production # Enables production optimizations
PORT=3000 # Default: 3000
# ── Email (for password reset, registration verification) ──────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASS=...
SMTP_FROM=Pediatric AI Scribe <noreply@example.com>
```
**Note:** If no SMTP is configured, registration auto-verifies and password reset won't work. Configure SMTP or use the console reset method (see Section 14).
---
## 5. Database Schema
All tables are created automatically on first run by `src/db/database.js`. The file runs `CREATE TABLE IF NOT EXISTS` for every table, followed by `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` migrations for upgrades.
### Core Tables
#### `users`
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| email | TEXT UNIQUE | Lowercase |
| password | TEXT | bcrypt hash (cost 12) |
| name | TEXT | Display name |
| role | TEXT | `'user'` \| `'moderator'` \| `'admin'` |
| totp_enabled | BOOLEAN | 2FA status |
| totp_secret | TEXT | TOTP secret (base32) |
| disabled | BOOLEAN | Soft disable |
| email_verified | BOOLEAN | |
| verify_token / verify_expires | TEXT / BIGINT | Email verification |
| reset_token / reset_expires | TEXT / BIGINT | Password reset |
| nextcloud_url / nextcloud_user / nextcloud_token / nextcloud_folder | TEXT | Nextcloud integration |
| webdav_learning_path | TEXT | Default WebDAV path for Learning Hub file picker |
| created_at | TIMESTAMPTZ | |
#### `app_settings`
Key-value store for all site configuration. Read via `db.getSetting(key)`, written via admin panel or direct DB.
Important keys:
- `registration_enabled``'true'` / `'false'`
- `announcement.enabled` / `announcement.text` / `announcement.type`
- `smtp.*` — SMTP config (overrides env vars)
- `ai.prompt.*` — AI prompt overrides
- `model.*` — enabled/disabled models
#### `saved_encounters`
Draft encounters (7-day auto-expiry). Columns: `label`, `enc_type`, `transcript`, `generated_note`, `partial_data` (JSON), `status`, `expires_at`.
#### `user_memories`
User templates fed into AI generation. `category` is one of: `physical_exam`, `ros`, `encounter_format`, `family_history`, `assessment_plan`, `custom`.
### Learning Hub Tables
#### `learning_categories`
Simple category list with `name`, `slug`, `sort_order`.
#### `learning_content`
Articles, quizzes, pearls, presentations. Key columns: `title`, `slug`, `body` (HTML for articles/pearls/quizzes; Marp markdown for presentations), `content_type` (`article` | `quiz` | `pearl` | `presentation`), `published`, `author_id`.
#### `learning_questions`
Quiz questions linked to `learning_content`. `question_type`: `mcq` | `true_false` | `multi`. `explanation` = general explanation shown after answering.
#### `learning_options`
Answer options for quiz questions. `is_correct: boolean`, `explanation` = shown when this wrong option is chosen.
#### `learning_progress`
Quiz attempt scores per user per content item.
---
## 6. Authentication System
**Current implementation: JWT in localStorage**
### Flow
1. `POST /api/auth/login` → returns `{ success, token, user }`
2. Frontend stores token in `localStorage` as `ped_scribe_token` and in `window.AUTH_TOKEN`
3. All API calls include `Authorization: Bearer <token>` header via `getAuthHeaders()`
4. `src/middleware/auth.js` validates the Bearer token, attaches `req.user`
5. Logout: `clearSession()` removes token from localStorage (client-side only)
### Token
- Signed with `JWT_SECRET` env var
- 7-day expiry
- Payload: `{ userId: number }`
### Roles
- `user` — standard access (clinical tools only)
- `moderator` — can create/edit Learning Hub content
- `admin` — full access including user management and site settings
### Middleware
- `authMiddleware` — validates JWT, populates `req.user`
- `adminMiddleware` — run after auth, requires `role === 'admin'`
- `moderatorMiddleware` — run after auth, requires `role === 'admin' OR 'moderator'`
### 2FA
Uses TOTP (speakeasy). If enabled, login returns `{ requires2FA: true }` and the client must POST the TOTP code to complete login.
### Session Check on Page Load (auth.js)
```javascript
var savedToken = localStorage.getItem('ped_scribe_token');
if (savedToken) {
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } })
.then(/* if ok → enterApp(), else → clearSession() */);
}
```
The `has-session` CSS class on `<html>` hides the auth screen immediately when a localStorage token exists, preventing a white flash.
---
## 7. Backend API Reference
All routes are prefixed `/api`. Routes requiring auth are marked (A). Admin-only: (ADM). Moderator+: (MOD).
### Auth — `/api/auth/`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/login` | — | Email + password login. Returns `{ token, user }` |
| POST | `/register` | — | Create account (checks `registration_enabled` setting) |
| GET | `/me` | A | Returns current user object |
| POST | `/logout` | — | Clears server-side state (currently no-op, kept for future) |
| POST | `/setup-2fa` | A | Generates TOTP secret + QR code |
| POST | `/verify-2fa` | A | Confirms TOTP code, enables 2FA |
| POST | `/disable-2fa` | A | Disables 2FA (requires password) |
| POST | `/forgot-password` | — | Sends reset email |
| POST | `/reset-password` | — | Sets new password via reset token |
| GET | `/registration-status` | — | Returns `{ registrationEnabled: bool }` |
| GET | `/verify-email` | — | Verifies email via token in query string |
### Clinical — AI Generation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/generate-hpi-encounter` | A | HPI from live encounter transcript |
| POST | `/generate-hpi-dictation` | A | HPI from dictation |
| POST | `/generate-soap` | A | SOAP note |
| POST | `/generate-hospital-course` | A | Hospital course summary |
| POST | `/generate-chart-review` | A | Chart review |
| POST | `/generate-milestone-narrative` | A | Milestone narrative |
| POST | `/generate-milestone-summary` | A | 3-sentence milestone summary |
| POST | `/well-visit/note` | A | Full well-visit note |
| POST | `/sick-visit/note` | A | Sick visit SOAP |
| POST | `/transcribe` | A | Whisper audio → text (multipart/form-data, field: `audio`) |
| POST | `/refine` | A | Refine existing document |
| POST | `/shorten` | A | Shorten existing document |
| POST | `/clarify` | A | Find missing info in a document |
### Encounters (Save/Load)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/encounters` | A | List user's saved encounters |
| POST | `/encounters` | A | Save/update encounter draft |
| DELETE | `/encounters/:id` | A | Delete a draft |
### User Templates
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/memories` | A | List user's templates |
| POST | `/memories` | A | Create template |
| PUT | `/memories/:id` | A | Update template |
| DELETE | `/memories/:id` | A | Delete template |
| GET | `/memories/context` | A | Returns templates formatted for AI injection |
### Nextcloud
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/nextcloud/connect` | A | Connect + test Nextcloud credentials |
| POST | `/nextcloud/export` | A | Export text file to Nextcloud |
| POST | `/nextcloud/disconnect` | A | Remove Nextcloud credentials |
### Learning Hub (User-Facing)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/learning/categories` | A | List categories |
| GET | `/learning/feed` | A | Paginated published content |
| GET | `/learning/category/:slug` | A | Content by category |
| GET | `/learning/content/:slug` | A | Single content item + questions |
| POST | `/learning/submit-quiz` | A | Submit quiz answers, returns scored results |
| GET | `/learning/search` | A | Full-text search |
### Learning Hub CMS (Moderator+)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/admin/learning/categories` | MOD | All categories with counts |
| POST | `/admin/learning/categories` | MOD | Create category |
| PUT | `/admin/learning/categories/:id` | MOD | Update category |
| DELETE | `/admin/learning/categories/:id` | MOD | Delete category |
| GET | `/admin/learning/content` | MOD | All content (including drafts) |
| GET | `/admin/learning/content/:id` | MOD | Single item with questions |
| POST | `/admin/learning/content` | MOD | Create content |
| PUT | `/admin/learning/content/:id` | MOD | Update content |
| DELETE | `/admin/learning/content/:id` | MOD | Delete content + questions |
| POST | `/admin/learning/content/:id/questions` | MOD | Add question to content |
| PUT | `/admin/learning/questions/:id` | MOD | Update question + options |
| DELETE | `/admin/learning/questions/:id` | MOD | Delete question |
| GET | `/admin/learning/stats` | MOD | Dashboard stats |
### Learning Hub AI (Moderator+)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/admin/learning/ai-generate` | MOD | Generate content from topic/file/Nextcloud (multipart/form-data) |
| POST | `/admin/learning/ai-refine` | MOD | Refine body HTML with instructions |
| POST | `/admin/learning/preview-slides` | MOD | Render Marp markdown → `{ css, slides[] }` for preview |
| POST | `/admin/learning/generate-pptx` | MOD | Marp markdown → `.pptx` download (pptxgenjs) |
| GET | `/admin/learning/webdav-browse` | MOD | PROPFIND Nextcloud folder |
| POST | `/admin/learning/webdav-path` | MOD | Save user's default WebDAV path |
### Admin (Admin Only)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/admin/users` | ADM | List all users |
| POST | `/admin/users` | ADM | Create user |
| PUT | `/admin/users/:id` | ADM | Update user (role, disable) |
| DELETE | `/admin/users/:id` | ADM | Delete user |
| GET/POST | `/admin/config/*` | ADM | Site settings (announcement, SMTP, models, prompts, etc.) |
### Logs & Health
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | — | Returns `{ status: 'running', version, provider }` |
| GET | `/models` | — | Returns available AI models list |
| POST | `/logs/client-error` | — | Client-side error logging (public) |
| GET | `/logs/usage` | ADM | API usage log |
| GET | `/logs/audit` | ADM | Audit log |
---
## 8. Frontend Architecture
### Tab Loading (Lazy Components)
Every tab's HTML lives in `/public/components/<tabname>.html`. When a tab button is clicked, `loadComponent()` in `app.js` fetches the HTML, injects it into the tab section, then fires `tabChanged` event.
```javascript
// app.js
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
```
**Critical pattern:** Every JS module that needs to access tab DOM elements MUST listen for `tabChanged`, not `DOMContentLoaded`:
```javascript
// Correct pattern for every tab module
(function() {
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'myTab' || _inited) return;
_inited = true;
// Now safe to querySelector elements — they exist in the DOM
var btn = document.getElementById('my-btn');
btn.addEventListener('click', ...);
});
})();
```
If you use `DOMContentLoaded` instead, the elements won't exist yet (they're loaded async) and you'll get `null.addEventListener` errors.
### Global Functions (defined in app.js)
These are available everywhere — no imports needed:
| Function | Description |
|----------|-------------|
| `getAuthHeaders()` | Returns `{ 'Content-Type': 'application/json', 'Authorization': 'Bearer <token>' }` |
| `getSelectedModel()` | Returns model ID from active tab's selector or global selector |
| `showLoading(msg)` | Shows full-screen loading overlay |
| `hideLoading()` | Hides loading overlay |
| `showToast(msg, type)` | Shows toast notification. `type`: `'success'`\|`'error'`\|`'info'`\|`'warning'` |
| `setOutputText(el, text)` | Sets text on contenteditable div, converting `\n` to `<br>` |
| `transcribeAudio(blob)` | Sends audio blob to `/api/transcribe`, returns `{ success, text }` |
| `createSpeechRecognition()` | Returns Web Speech API recognition instance |
| `createTimer(el)` | Returns timer object with `.start()` / `.stop()` |
### Rich Text Editor (Tiptap)
The body editor in the CMS uses Tiptap 2 (headless, no styling framework). The bundle is pre-built at `/public/vendor/tiptap.bundle.js` and exposes `window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color }`.
To rebuild the bundle after updating Tiptap packages:
```bash
cat > tiptap-entry.js << 'EOF'
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color };
EOF
npx esbuild tiptap-entry.js --bundle --format=iife --minify --outfile=public/vendor/tiptap.bundle.js
rm tiptap-entry.js
```
---
## 9. AI Integration
### `src/utils/ai.js``callAI(messages, options)`
The single function used by all routes. It routes to the correct provider automatically.
```javascript
const { callAI } = require('../utils/ai');
const result = await callAI(
[{ role: 'user', content: 'Generate a note...' }],
{
model: 'google/gemini-2.5-flash', // optional, uses default if omitted
temperature: 0.3, // optional, default 0.3
maxTokens: 4000 // optional, default 4000
}
);
// result = { success: true, content: '...', model: '...', provider: '...', duration: ms }
```
### Bedrock Model Notes
**Inference Profiles:** Most newer models (Anthropic vendor model 4.x, Meta Llama 4, DeepSeek R1, Amazon Nova, Writer) require cross-region inference profiles. These use a `us.` prefix on the model ID (e.g. `us.anthropic.agent-config-sonnet-4-6`). Direct model IDs will return "on-demand throughput not supported" errors.
**Max Output Tokens:** Some models have low output limits (Cohere Command R/R+: 4096, AI21 Jamba: 4096). The `maxOut` field in `models.js` auto-clamps `maxTokens` in `callBedrock()`.
**JSON Sanitization:** Some models (notably vendor model Sonnet 4.6, Opus 4.6) output literal newline characters inside JSON string values. `learningAI.js` includes a `sanitizeJsonString()` function that escapes these before parsing.
### Prompt System
Prompts are defined in `src/utils/prompts.js`. Admins can override any prompt via the Admin panel (`/admin/config/prompts`). Overrides are stored in `app_settings` table and loaded into memory on startup (with 3s grace period for DB readiness).
To add a new prompt:
1. Add a default in `prompts.js`
2. Use `PROMPTS.get('your-prompt-key')` in your route
3. The admin panel will auto-discover it
### AI Generate for Learning Hub
The `src/routes/learningAI.js` file handles all Learning Hub AI generation.
**For presentations:** The AI is prompted to return raw Marp markdown (not JSON). The response is stored in the `body` column. Detection: `content_type === 'presentation'`.
**For articles/quizzes/pearls:** The AI returns JSON:
```json
{
"title": "...",
"subject": "...",
"body": "<p>HTML content</p>",
"questions": [
{
"question_text": "...",
"question_type": "mcq",
"explanation": "...",
"options": [
{ "option_text": "...", "is_correct": true, "explanation": "..." }
]
}
]
}
```
---
## 10. Learning Hub & CMS
### Content Types
| Type | Body format | Has questions |
|------|-------------|---------------|
| `article` | HTML (Tiptap) | Optional |
| `quiz` | HTML (brief intro) | Always |
| `pearl` | HTML | Optional |
| `presentation` | Marp markdown | Never |
### Quiz Question Types
- `mcq` — Single choice (radio buttons), 4 options, 1 correct
- `true_false` — 2 options: "True" / "False", 1 correct
- `multi` — Multiple select (checkboxes), scoring: all correct chosen AND no incorrect chosen
### PPTX Generation
`POST /admin/learning/generate-pptx` parses Marp markdown (splits on `---`), extracts `#` headings as slide titles, bullet points as content, and uses `pptxgenjs` to create a real `.pptx`. **No Chromium required** — pure Node.js.
### Slide Preview
`POST /admin/learning/preview-slides` uses `@marp-team/marp-core` to render Marp markdown to HTML, then extracts individual `<section>` elements. Returns `{ css, slides[] }`. The frontend renders these one at a time in a full-screen modal with arrow key + swipe navigation.
### Content Display
In the Learning Hub viewer, content `body` is rendered via `sanitizeHtml()` in `learningHub.js`. This function allows a safe subset of HTML tags only (no `<script>`, no `on*` attributes, no `style` attributes except `class`).
---
## 11. Deployment
### Local Development
```bash
cp .env.example .env # Fill in your credentials
docker compose -f docker-compose.local.yml build --no-cache
docker compose -f docker-compose.local.yml up -d
# App runs at http://localhost:3552
```
### Logs & Debugging
**View container logs (live):**
```bash
docker logs -f pediatric-ai-scribe
```
**View last N lines:**
```bash
docker logs --tail 50 pediatric-ai-scribe
```
**Filter for specific issues:**
```bash
# AI/Bedrock errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "Bedrock\|LearningAI\|callAI"
# Auth errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "Auth\|login\|verify"
# All errors
docker logs pediatric-ai-scribe 2>&1 | grep -i "error\|ERR\|fail"
```
**Key log prefixes:**
| Prefix | Source |
|--------|--------|
| `[Bedrock] Model:` | AI response metadata (block types, stop reason) |
| `[LearningAI]` | JSON parse failures with raw output context |
| `[Auth]` | Login, registration, verification events |
| `[TTS]` | Text-to-speech generation |
| `🤖 Provider:` | Startup: which AI provider is active |
| `✅ AWS Bedrock:` | Startup: Bedrock configured successfully |
**Database logs (PostgreSQL):**
```bash
docker logs pedscribe-db
```
### Production (Docker Hub image)
```bash
# docker-compose.yml (production)
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
ports: ["3000:3000"]
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: your_secure_password
volumes:
- pgdata:/var/lib/postgresql/data
```
### Docker Hub
Repository: `danielonyejesi/pediatric-ai-scribe-v3`
Tags use versioned format: `v5.0`, `v5.1`, etc. Production should always pin to a specific tag.
### Git Repository
Repository: `ifedan-ed/pediatric-ai-scribe-v3` (private)
### Build & Push Process
```bash
# 1. Test locally first
docker compose -f docker-compose.local.yml build --no-cache
docker compose -f docker-compose.local.yml up -d
# Test at http://localhost:3552
# 2. When ready, tag and push to Docker Hub
docker tag scribe-pediatric-scribe:latest danielonyejesi/pediatric-ai-scribe-v3:v5.x
docker push danielonyejesi/pediatric-ai-scribe-v3:v5.x
# 3. Update production docker-compose.yml to use new tag
```
---
## 12. Known Issues & Security Notes
### Active Known Issues
1. **`nodemailer` HIGH vulnerability** — v6.9.x has an email domain interpretation conflict. Upgrade to `^6.10.0` when available.
2. **`unsafe-inline` in CSP** — `scriptSrc` includes `'unsafe-inline'` to support inline event handlers in HTML components. Should migrate to event listeners and remove this directive.
3. **JWT in localStorage** — Tokens stored in `localStorage` are readable by JavaScript and therefore vulnerable to XSS attacks. A future migration to `httpOnly` cookies would eliminate this risk. See notes in auth.js and Section 6.
4. **`window.prompt()` in `runAiRefineBody`** — Uses browser native prompt, which can be blocked in certain contexts. Should be replaced with an inline input field.
5. **`webdav-learning-path` endpoint** — Sits behind `moderatorMiddleware` but is a user preference that non-moderator users might reasonably need. Consider moving to plain `authMiddleware`.
### Security Hardening Already In Place
- Helmet.js with custom CSP (no external script sources)
- CORS restricted to `APP_URL` in production
- Rate limiting on login (10/15min), register (5/hr), forgot-password (5/hr), resend-verification (3/15min), general API (60/min)
- bcrypt cost 12 for password hashing
- JWT with 7-day expiry
- SQL injection protection: all queries use parameterized `?` / `$1` placeholders
- Dynamic table names validated against an explicit allowlist (`ALLOWED_SLUG_TABLES`)
- User input in HTML contexts goes through `sanitizeHtml()` (tag allowlist, strips `on*` attributes)
- File upload MIME type validated by extension + content type
- Admin/moderator route protection via middleware
---
## 13. Adding New Features
### Adding a New Clinical Tab
1. Create `public/components/mytab.html` with the tab's UI
2. Add to `index.html`:
- Tab button: `<button class="tab-btn" data-tab="mytab">...</button>`
- Tab section: `<section id="mytab-tab" class="tab-content" data-component="mytab"></section>`
- Script tag: `<script defer src="/js/myTab.js"></script>`
3. Create `public/js/myTab.js`:
```javascript
(function() {
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'mytab' || _inited) return;
_inited = true;
// Wire up DOM elements here
});
})();
```
4. Create `src/routes/myTab.js` with the API route
5. Register in `server.js`: `app.use('/api', require('./src/routes/myTab'));`
### Adding a New AI Prompt
1. In `src/utils/prompts.js`, add to the defaults object:
```javascript
'my-prompt': 'You are a pediatric physician...'
```
2. In your route: `const prompt = PROMPTS.get('my-prompt') + '\n\n' + userInput`
3. The admin panel will show an editor for this prompt automatically.
### Adding a New Learning Hub Content Type
1. Add the new type to the `content_type` selector in `cms.html`
2. Handle it in `toggleEditorMode()` in `learningHub.js`
3. Add to the type detection in `buildGeneratePrompt()` in `learningAI.js`
4. Handle rendering in `learningHub.js` `loadContent()` function
5. No DB migration needed — `content_type` is a free-text column
---
## 14. Resetting Admin Password via Console
If you lose admin access and have no SMTP for password reset, use the Docker console:
```bash
# Step 1: Get a shell in the running app container
docker exec -it pediatric-ai-scribe sh
# Step 2: Open Node.js REPL
node
# Step 3: Hash your new password
const bcrypt = require('bcryptjs');
const hash = await bcrypt.hash('YourNewPassword123!', 12);
console.log(hash);
// Copy the hash output
# Step 4: Exit Node REPL
.exit
# Step 5: Open a DB shell
# (Exit app container first, then:)
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
# Step 6: Update the password (paste the hash)
UPDATE users
SET password = '$2a$12$...(your-hash-here)...'
WHERE email = 'your-admin@email.com';
# Verify:
SELECT email, left(password, 7) as hash_prefix FROM users WHERE email = 'your-admin@email.com';
# Exit:
\q
```
### Enabling Registration via Console
```bash
docker exec -it pedscribe-db psql $POSTGRES_USER $POSTGRES_DB
UPDATE app_settings SET value = 'true' WHERE key = 'registration_enabled';
\q
```
### Creating First Admin User (empty database)
The first user to register is automatically made admin. Enable registration, register, then disable registration again.
Or directly:
```bash
# In the Node REPL inside the app container:
const bcrypt = require('bcryptjs');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const hash = await bcrypt.hash('YourPassword', 12);
await pool.query(
"INSERT INTO users (email, password, name, role, email_verified) VALUES ($1, $2, $3, 'admin', true)",
['admin@yourdomain.com', hash, 'Admin']
);
pool.end();
```
---
## 15. Version History (Recent)
| Tag | Key changes |
|-----|-------------|
| v3.19 | Login flash fixed (auth screen hidden by CSS default); presentation quiz option; feed labels corrected |
| v3.18 | pdf-parse downgraded to v1.1.1; WebDAV selection UX fixed; topic context on upload/WebDAV tabs; inline refine bar replaces window.prompt(); CSP: removed unsafe-inline (all onclick= converted to data-action delegation); webdav-path moved to /api/user/webdav-path (auth-only) |
| v3.17 | AI panel context-aware options fixed (style.display replaces classList — CSS cascade bug); quiz card redesign |
| v3.16 | DEVELOPER_GUIDE.md created |
| v3.15 | Auth reverted to localStorage tokens; slide preview padding fixed |
| v3.14 | AI panel context-aware options (word count, slide count, quiz toggle); delete wording per type |
| v3.13 | Delete confirm inline bar CSS bug fixed; slide preview in-page modal (arrow/swipe nav); Marp textarea placeholder |
| v3.12 | Delete inline confirm bar; lighter login screen; Presentation type (Marp + pptxgenjs PPTX) |
| v3.11 | AI content generation for Learning Hub (topic/file/Nextcloud, pdf-parse, pptxgenjs) |
| v3.10 | Custom 404 page; server returns 404 for unknown paths |
| v3.8 | Quill replaced with Tiptap 2 (self-hosted bundle, inline link bar) |
| v5.0 | Resend verification link on login + rate limit (3/15min) |
| v5.1v5.4 | Bedrock model fixes: inference profiles, region filtering, thinking block handling |
| v5.5 | Comprehensive Bedrock fix: all us. prefix IDs, maxTokens clamping |
| v5.6 | Re-add Qwen3 235B |
| v5.7 | Remove Opus 4.6 (JSON issues) |
| v5.8 | Fix JSON parse: sanitize literal newlines in strings; re-add Opus 4.6 |
| v5.9 | Re-add Opus 4.6 with sanitizer; updated DEVELOPER_GUIDE |
| v6.0 | Increase PDF/doc context to 50k chars; maxTokens ceiling to 8k |
## 16. Current Docker Image
**Latest stable:** `danielonyejesi/pediatric-ai-scribe-v3:v6.0`
```bash
docker pull danielonyejesi/pediatric-ai-scribe-v3:v6.0
```
---
## 17. PDF & Document Uploads
### How It Works
The Learning Hub AI generator accepts documents via two paths — both produce the same result:
1. **Direct upload** — user selects a file from their computer (up to 20 MB)
2. **Nextcloud WebDAV** — user browses their Nextcloud and picks a file
The flow:
1. `extractText()` in `learningAI.js` detects file type by MIME/extension
2. **PDF:** `pdf-parse` v1.1.1 extracts all text pages into a single string
3. **PPTX/DOCX/TXT:** extracted via appropriate parser or read as UTF-8
4. Text is truncated to **50,000 characters** (~25-30 pages) and sent as context in the AI prompt
5. AI generates structured content (title, HTML body, quiz questions) from the full context
### Supported File Types
| Extension | Handler | Notes |
|-----------|---------|-------|
| `.pdf` | `pdf-parse` | Extracts text only — images, charts, tables are lost |
| `.pptx` | Text extraction from slides | Slide text only |
| `.docx` | Text extraction | Body text only |
| `.txt`, `.md`, `.csv` | Read as UTF-8 | Full content preserved |
### Limits
- **Upload size:** 20 MB (`multer` limit in `learningAI.js`)
- **Context sent to AI:** 50,000 characters (configurable in `buildGeneratePrompt()`)
- **AI response tokens:** 8,000 max (ceiling — model stops when done)
### Why No Vector Embeddings / RAG
Embeddings and RAG (Retrieval Augmented Generation) are unnecessary for this use case:
- **Single document → single generation** — the full text fits in the model's context window
- Most Bedrock models support 100K-200K token inputs — 50,000 chars is well within that
- Embeddings would add complexity (pgvector, chunking, retrieval pipeline) with no benefit
If you later need to **search across hundreds of stored documents** or handle 200+ page PDFs, then consider pgvector + chunked retrieval. For now, the direct approach is correct.
---
## 18. Scalability
### Current Architecture (Single Instance)
The app runs as a single Node.js process. This is fine for a team/department deployment (tens to hundreds of concurrent users).
### What Scales Well Already
- **Stateless JWT auth** — no server-side session store; any instance can validate any token
- **PostgreSQL** — handles concurrent connections well; supports read replicas
- **Lazy-loaded component HTML** — reduces initial page size; tabs load on demand
- **AI calls** — fully async; expensive calls don't block other requests
### Bottlenecks to Address Before Horizontal Scaling
| Issue | Current | Fix for multi-instance |
|-------|---------|----------------------|
| Rate limiting | In-memory (per process) | Replace with Redis (`rate-limit-redis`) |
| File uploads | `multer` in RAM | Route uploads to S3/object storage |
| Scheduled cleanup | `setTimeout` in server.js | Use a dedicated cron job or DB-scheduled task |
### How to Scale Horizontally
```yaml
# docker-compose with 3 app replicas + nginx load balancer
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
deploy:
replicas: 3
environment:
DATABASE_URL: postgresql://... # shared external Postgres
REDIS_URL: redis://redis:6379 # add when rate-limit-redis is wired
nginx:
image: nginx:alpine
# upstream: round-robin across app replicas
redis:
image: redis:7-alpine
postgres:
image: postgres:16-alpine
```
Cloud deployment options (all work with the current Docker image):
- **AWS ECS/Fargate** — managed containers, easy auto-scaling
- **Railway / Render / Fly.io** — simple push-to-deploy with Docker
- **Kubernetes** — full control, overkill for most deployments
---
## 19. Security Architecture — localStorage vs httpOnly Cookies
The app stores JWT tokens in `localStorage`. This is a deliberate choice appropriate for this scale. The key security facts:
**Current protections in place (more important than storage location):**
- `Content-Security-Policy: script-src 'self'` — blocks all external scripts and inline JS (v3.18)
- Input sanitization via `sanitizeHtml()` allowlist on all user-generated HTML
- All 26 `onclick=` inline event handlers removed (v3.18) — reduces XSS surface
- Rate limiting on auth endpoints
- Helmet.js security headers
- Parameterized SQL queries throughout
**The reality about localStorage vs httpOnly cookies:**
> "Unless you're a bank or large enterprise, it doesn't really matter. Focus on preventing XSS, because that's what actually matters... fundamentally, the security benefit of using httpOnly cookies is very minimal. If your site suffers any kind of XSS, it makes it slightly more difficult for an attacker to use the auth token." — Security engineering community consensus
httpOnly cookies prevent token *copying* but not token *use* — an XSS attacker can still make authenticated requests on the user's behalf regardless of where the token is stored.
**If you later want httpOnly cookies:** The infrastructure is already in place (cookie-parser, CORS `credentials:true`). The change is: (1) set cookie on login, (2) remove token from `getAuthHeaders()`, (3) add `/api/auth/logout` to clear cookie. See notes in `auth.js`. This was implemented and reverted in v3.14 — it works but adds CSRF considerations.
**Token lifetime:** Currently 7 days. For higher security, reduce to 1-2 hours and add refresh token rotation.
---
---
*Last updated: March 2026 — v6.0*
*Generated for developer handover.*

View file

@ -8,7 +8,7 @@ FROM node:20-alpine
WORKDIR /app
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
# curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling
# curl: download Whisper models for browser-based transcription
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
RUN apk add --no-cache ffmpeg curl jq
@ -30,6 +30,22 @@ RUN chmod +x /app/docker-entrypoint.sh
RUN mkdir -p /app/data/logs
# Download Browser Whisper (COMPLETE self-hosting - zero CDN dependencies)
# Library + Models all bundled and served from our server
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
cd /app/public/models && \
echo "Downloading transformers.js library (worker-compatible build)..." && \
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
cd Xenova/whisper-tiny.en && \
echo "Downloading Whisper model files..." && \
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
curl -sL -o tokenizer.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json && \
curl -sL -o preprocessor_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json && \
curl -sL -o generation_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json && \
curl -sL -o onnx/encoder_model_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx && \
curl -sL -o onnx/decoder_model_merged_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx && \
echo "✅ Browser Whisper: 100% self-hosted (library: 760KB, models: 42MB)"
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
@ -40,3 +56,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
# unset, so legacy .env-only deployments continue to work unchanged.
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"]

View file

@ -1,8 +1,8 @@
# Embeddings And Semantic Search Setup
# Embeddings & Semantic Search Setup
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
## What This Enables
## 🎯 What's New
- **Semantic search** - Find content by meaning, not just keywords
- **3 search modes**:
@ -10,9 +10,9 @@ This guide explains how to set up and use the new vector-based semantic search f
- **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity
- **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results
- **Auto-embedding** - Content is automatically vectorized when created/updated
- **Gateway-routed** - Uses LiteLLM embeddings so provider policy stays in one place
- **HIPAA-compliant** - Uses Vertex AI embeddings (BAA available)
## Prerequisites
## 📋 Prerequisites
### 1. Install pgvector Extension
@ -37,24 +37,39 @@ postgres:
# ... rest of your config
```
### 2. Configure LiteLLM Embeddings
### 2. Configure Embedding Provider
Add to your `.env` file:
```bash
# Option 1: Vertex AI (HIPAA-eligible, recommended)
EMBEDDING_MODEL=vertex_ai/text-embedding-005
EMBEDDING_DIMENSIONS=768
VERTEX_PROJECT=your-gcp-project-id
VERTEX_LOCATION=us-central1
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
# Option 2: LiteLLM Proxy (routes to any provider)
LITELLM_API_BASE=http://localhost:4000
LITELLM_API_KEY=your-key
EMBEDDING_MODEL=openai-text-embedding-3-large
EMBEDDING_DIMENSIONS=3072
EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
# Option 3: OpenAI (NOT HIPAA-eligible, fallback only)
OPENAI_API_KEY=sk-your-key
# Uses text-embedding-3-small automatically
```
## Available Embedding Models
## 🚀 Available Vertex AI Embedding Models
The Admin embedding search reads LiteLLM `/model/info` and only shows models with `model_info.mode = "embedding"`. Do not add app-side built-in Vertex/OpenAI embedding lists; configure those choices in LiteLLM.
Tested and working via LiteLLM:
The local LiteLLM instance currently exposes examples such as `openai-text-embedding-3-large`, `openai-text-embedding-3-small`, and Mistral embedding models. Dimensions are read from LiteLLM metadata when available.
| Model | Dimensions | Use Case | HIPAA |
|-------|-----------|----------|-------|
| **vertex_ai/text-embedding-005** | 768 | English + code (recommended) | ✅ Yes |
| **vertex_ai/gemini-embedding-001** | 768-3072 | Multilingual + code, best quality | ✅ Yes |
| **vertex_ai/text-multilingual-embedding-002** | 768 | Multilingual focus | ✅ Yes |
## Setup Steps
## 🔧 Setup Steps
### 1. Database Migration
@ -98,12 +113,12 @@ Response:
"total": 50,
"withEmbeddings": 50,
"missing": 0,
"model": "openai-text-embedding-3-large",
"dimensions": 3072
"model": "vertex_ai/text-embedding-005",
"dimensions": 768
}
```
## Using Semantic Search
## 🔍 Using Semantic Search
### Keyword Search (existing)
```bash
@ -129,12 +144,12 @@ GET /api/learning/search/hybrid?q=fever management
```
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
## How It Works
## 🔬 How It Works
1. **Content Creation/Update**:
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
- Sent to the configured LiteLLM embedding model
- Returns an embedding vector
- Sent to embedding model (Vertex AI)
- Returns 768-dimensional vector
- Stored in `learning_content.embedding` column
2. **Semantic Search**:
@ -149,23 +164,35 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- Deduplicates by content ID
- Sorts by relevance score
## Cost Estimate
## 💰 Cost Estimate (Vertex AI)
Embedding cost depends on the upstream configured in LiteLLM.
**Titan Text Embeddings (AWS) pricing:**
- ~$0.10 per 1M tokens
- Average article: 2,000 words (~2,700 tokens) = $0.00027
- 1,000 articles: ~**$0.27 one-time**
- Search queries: ~500 tokens = $0.00005 per query
## Troubleshooting
**Google Vertex AI pricing:**
- text-embedding-005: $0.025 per 1M characters
- Average article: 10,000 chars = $0.00025
- 1,000 articles: ~**$0.25 one-time**
- Search queries: ~$0.0000125 per query
## 🐛 Troubleshooting
### "pgvector extension not available"
- Install: `apt-get install postgresql-16-pgvector`
- For Docker: Use `pgvector/pgvector:pg16` image
### "Embeddings not configured"
- Verify `.env` has `LITELLM_API_BASE`
- Verify `.env` has `VERTEX_PROJECT` or `LITELLM_API_BASE` or `OPENAI_API_KEY`
- Check service account credentials: `GOOGLE_APPLICATION_CREDENTIALS`
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
### "Embedding generation failed"
- Check logs for API errors
- Verify LiteLLM `/model/info` shows the selected model with `mode: embedding`
- Verify Vertex AI API is enabled in GCP
- Verify service account has `aiplatform.endpoints.predict` permission
- Check content isn't empty (skips empty bodies)
### "No results from semantic search"
@ -173,23 +200,23 @@ Embedding cost depends on the upstream configured in LiteLLM.
- Lower threshold: `?threshold=0.3` (default 0.5)
- Verify pgvector index exists: `\di` in psql
## Performance
## 📊 Performance
- **Embedding generation**: latency depends on the LiteLLM upstream
- **Embedding generation**: ~500ms per article (Vertex AI)
- **Search latency**:
- Keyword: 10-50ms
- Semantic: 20-100ms (with IVFFLAT index)
- Hybrid: 30-150ms
- **Index build time**: ~1-5 seconds per 1,000 articles
## Security And Compliance
## 🔐 Security & Compliance
- **Compliance**: controlled by the upstream provider configured in LiteLLM
- **HIPAA-eligible**: Vertex AI supports BAA (Business Associate Agreement)
- **Data retention**: Embeddings stored in your database only
- **No PHI**: Only article content (not patient data) is embedded
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
## Example Queries
## 🎓 Example Queries
**Before (keyword):**
```
@ -217,7 +244,7 @@ Results:
- Bronchiolitis vs asthma (keyword: 1.0)
```
## API Reference
## 📚 API Reference
### Admin Endpoints

347
FEATURES_EXPLAINED.md Normal file
View file

@ -0,0 +1,347 @@
# Features Explained - Pediatric AI Scribe v14
## 🎙️ **Audio Backups**
### How It Works:
Audio backups happen **automatically every time you record**, regardless of transcription success/failure.
**Flow:**
1. You press "Stop" on recording
2. Audio is immediately saved **before** transcription starts
3. Server-side backup (PostgreSQL, gzip compressed) attempted first
4. If server fails → fallback to browser IndexedDB
5. After successful transcription → audio backup is deleted
6. If transcription fails → audio backup remains for retry
**Location:**
- Server: PostgreSQL `audio_backups` table (auto-deleted after 24 hours)
- Browser: IndexedDB `PedScribeAudioBackup` database (manual cleanup)
**Purpose:**
- Retry transcription if it fails
- Recover audio if browser crashes
- Audit trail (24 hour retention)
**Access:**
Settings → Audio Backups section shows:
- Date/time of recording
- Module (encounter, dictation, etc.)
- File size
- "Retry Transcription" button (if transcription failed)
- "Delete" button
**Cost:**
Server backups are compressed (gzip) to ~1/10 original size. A 2MB recording becomes ~200KB in database.
---
## 🌐 **S3 Document Storage**
### How It Works:
Upload documents (PDFs, images, Word docs, text files) to S3-compatible storage.
**Supported Providers:**
- AWS S3 (default)
- Backblaze B2
- MinIO (self-hosted)
- Any S3-compatible service
**Configuration (.env):**
```bash
# AWS S3 (uses Bedrock credentials if available)
S3_BUCKET=your-bucket-name
S3_REGION=us-east-1
S3_PREFIX=documents/ # Optional: folder prefix
# Backblaze B2
S3_BUCKET=your-bucket-name
S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
S3_REGION=us-west-004
S3_ACCESS_KEY_ID=your-b2-application-key-id
S3_SECRET_ACCESS_KEY=your-b2-application-key
# MinIO (self-hosted)
S3_BUCKET=your-bucket
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=minio-access-key
S3_SECRET_ACCESS_KEY=minio-secret-key
S3_FORCE_PATH_STYLE=true # Required for MinIO
```
**Features:**
- ✅ 10 MB file size limit
- ✅ AES-256 server-side encryption
- ✅ Per-user folder organization (`documents/{userId}/{uuid}/filename`)
- ✅ Metadata stored in PostgreSQL (filename, mime type, size, description)
- ✅ Presigned URLs for secure access (1 hour expiry)
**Allowed File Types:**
- PDF (`.pdf`)
- Images (`.jpg`, `.jpeg`, `.png`, `.gif`)
- Word documents (`.doc`, `.docx`)
- Text files (`.txt`, `.csv`)
**Access:**
Settings → Documents section
**Status Check:**
If S3 is not configured, the Documents section shows empty with message: "S3 not configured"
---
## 📚 **Learning Hub - Default Browse Path**
### What It Is:
A user preference that sets the **starting folder** when browsing Nextcloud files for AI content generation.
### When It's Used:
Only in the **Learning Hub AI Content Generator** (Admin/Moderator feature).
**Scenario:**
1. Admin/Moderator wants to create AI-generated learning content
2. They choose "Upload from Nextcloud"
3. File browser opens
4. Instead of starting at root `/`, it opens at the configured path
**Example:**
```
Default path: /Medical-Resources
When you click "Browse Nextcloud", it opens:
/Medical-Resources/
├── Pediatric-Guidelines/
├── Clinical-Protocols/
└── Research-Papers/
Instead of:
/
├── Personal/
├── Photos/
├── Medical-Resources/ ← you'd have to navigate here every time
└── ...
```
**Configuration:**
Settings → Nextcloud Integration → "Learning Hub — Default Browse Path"
**Examples:**
- `/Medical-Resources` - Opens in Medical Resources folder
- `/Shared/Clinical-Content` - Opens in shared clinical content
- `/` (empty) - Opens at root (default behavior)
**Who Can Use This:**
- Any authenticated user (not just moderators)
- It's a personal preference per user
- Only affects Learning Hub AI file picker
**Why This Exists:**
If you store learning resources in a specific Nextcloud folder, you don't want to navigate there every single time you generate content. Set it once, it remembers.
---
## 🎤 **Browser Whisper Pre-Download**
### Issue You Reported:
"Pre-download models works, stuck at starting download"
### What's Happening:
The download **is actually working** but progress updates are slow because:
1. HuggingFace CDN serves large files (39-244 MB)
2. Progress callbacks are not granular (reported per-file, not per-chunk)
3. Initial ONNX runtime download has no progress tracking
### Fixed:
- ✅ Added console logging to track progress
- ✅ Added 30-second timeout warning (doesn't stop download)
- ✅ Better error messages
### How to Test:
1. Open browser DevTools (F12) → Console tab
2. Click "Pre-download model"
3. Watch console for progress logs:
```
[BrowserWhisper] Starting preload...
[BrowserWhisper] Progress: onnx-runtime 0%
[BrowserWhisper] Progress: model.bin 23%
[BrowserWhisper] Progress: model.bin 47%
...
[BrowserWhisper] Progress: 100%
```
### Expected Download Times:
- **Tiny** (39 MB): 5-15 seconds (fast connection)
- **Base** (74 MB): 10-30 seconds
- **Small** (244 MB): 30-90 seconds
### If Still Stuck:
**Check these:**
1. Open DevTools → Network tab
2. Filter by "HuggingFace"
3. Look for downloads from `cdn-lfs-us-1.huggingface.co`
4. Check if files are actually downloading
**Common issues:**
- Slow internet connection (244 MB takes time!)
- Corporate firewall blocking HuggingFace CDN
- Browser IndexedDB quota exceeded
**Workaround:**
Just enable it and record audio - the model will download on first use (same as pre-download, but triggered automatically).
---
## 🔊 **TTS Voice Preview**
### Issue You Reported:
"Preview button next to TTS seems to do nothing"
### Fixed:
- ✅ Added error logging to console
- ✅ Better validation (checks for empty selection)
- ✅ Clear user feedback messages
### How to Use:
1. Go to Settings → Voice Preferences
2. Select a voice from "Text-to-Speech Voice" dropdown
3. Click "Preview" button
4. Wait 2-3 seconds
5. Audio should play automatically
### If Nothing Happens:
**Check browser console for errors:**
- Open DevTools (F12) → Console tab
- Click Preview
- Look for `[VoicePrefs] Preview error:` message
**Common issues:**
1. **No voice selected** → Select from dropdown first
2. **TTS not configured** → Check `.env` has `GOOGLE_VERTEX_PROJECT` or `LITELLM_API_BASE`
3. **Network error** → Check server logs for TTS API errors
4. **Browser autoplay policy** → Some browsers block autoplay, click page first
### Testing Checklist:
```bash
# 1. Check TTS is configured
curl http://localhost:3000/api/health | grep tts
# 2. Test TTS endpoint directly
curl -X POST http://localhost:3000/api/text-to-speech \
-H "Authorization: Bearer YOUR_JWT" \
-H "Content-Type: application/json" \
-d '{"text":"Test"}' \
--output test.mp3
# 3. Play the audio file
mpg123 test.mp3 # or open in browser
```
---
## 📋 **Summary of User Settings**
### Voice Preferences
**Location:** Settings → Voice Preferences (top section)
| Setting | Options | Default | Purpose |
|---------|---------|---------|---------|
| **STT Model** | gemini-2.0-flash-exp, gemini-2.0-flash, gemini-1.5-flash, gemini-1.5-pro, whisper-1 | Server default | Controls transcription accuracy |
| **TTS Voice** | Journey-F/D, Studio-O/M, Neural2 series, alloy, echo, fable, onyx, nova, shimmer | Server default | Controls read-aloud voice |
### Browser Whisper
**Location:** Settings → Browser Transcription (Local Whisper)
| Setting | Options | Default | Purpose |
|---------|---------|---------|---------|
| **Enable** | On/Off | Off | Local transcription (HIPAA-safe) |
| **Model** | Tiny, Base, Small | Tiny | Accuracy vs speed tradeoff |
### Nextcloud
**Location:** Settings → Nextcloud Integration
| Setting | Purpose |
|---------|---------|
| **Nextcloud URL** | Your Nextcloud instance |
| **Username** | Nextcloud username |
| **App Password** | Generate in Nextcloud → Security |
| **Default Browse Path** | Starting folder for Learning Hub AI picker |
### Documents (S3)
**Location:** Settings → Documents
Shows list of uploaded documents if S3 is configured. Upload limit: 10 MB per file.
### Audio Backups
**Location:** Settings → Audio Backups
Shows last 24 hours of recordings. Can retry transcription or delete.
---
## 🔧 **Troubleshooting Guide**
### Pre-Download Stuck
1. ✅ Open browser console (F12)
2. ✅ Look for `[BrowserWhisper] Progress:` logs
3. ✅ Check Network tab for HuggingFace downloads
4. ✅ Wait - 244 MB takes time!
5. ✅ If truly stuck (no network activity): refresh page, try again
### Preview Button Silent
1. ✅ Check voice is selected in dropdown
2. ✅ Open console for error messages
3. ✅ Test TTS endpoint directly (curl command above)
4. ✅ Check server logs for TTS provider errors
5. ✅ Verify `.env` has TTS provider configured
### S3 Not Working
1. ✅ Check `.env` has `S3_BUCKET` set
2. ✅ Verify credentials: `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY`
3. ✅ Test bucket access from server:
```bash
aws s3 ls s3://your-bucket/ --region us-east-1
```
4. ✅ Check server logs for S3 errors when uploading
### Audio Backups Not Showing
1. ✅ Record audio first (they're created on recording, not transcription)
2. ✅ Check database: `SELECT COUNT(*) FROM audio_backups;`
3. ✅ Verify IndexedDB in browser: DevTools → Application → IndexedDB → `PedScribeAudioBackup`
4. ✅ Backups auto-delete after 24 hours
### Learning Hub Path Not Working
1. ✅ This only affects **AI content generator file picker**
2. ✅ It does NOT affect manual Nextcloud document browsing
3. ✅ Path must exist in your Nextcloud
4. ✅ Path format: `/Folder/Subfolder` (starts with `/`)
---
## 📊 **Feature Status Matrix**
| Feature | Status | Config Required | HIPAA-Safe | Notes |
|---------|--------|-----------------|------------|-------|
| **Audio Backups** | ✅ Working | None (auto) | ✅ Yes | Server + IndexedDB |
| **S3 Documents** | ✅ Working | S3_BUCKET | ✅ Yes (AWS) | Optional feature |
| **Browser Whisper** | ✅ Working | None (optional) | ✅ Yes | Client-side only |
| **Voice Preferences** | ✅ Working | Provider config | Depends | Google/AWS = yes |
| **Learning Hub Path** | ✅ Working | Nextcloud config | ✅ Yes | User preference |
| **TTS Preview** | ✅ Fixed | TTS provider | Depends | Check logs if fails |
| **Embeddings** | ✅ Working | Vertex/LiteLLM | ✅ Yes | Requires pgvector |
---
## 🚀 **Next Steps**
1. **Push v14 to Docker** (in progress via GitHub Actions)
2. **Test features after deployment**
3. **Check browser console for any errors**
4. **Verify TTS preview works with your provider**
5. **Test browser whisper download with different models**
---
**Questions? Check the logs:**
- Browser: F12 → Console tab
- Server: `docker logs pediatric-ai-scribe -f`
- Database: `psql -d pedscribe -c "SELECT COUNT(*) FROM audio_backups;"`

View file

@ -52,7 +52,7 @@ This is the highest-impact improvement for adoption but also the most complex to
### 5. Offline Mode
**Current state:** The app requires configured server-side providers for AI generation and final transcription. Browser Whisper has been removed from the runtime.
**Current state:** The app requires an internet connection for AI generation and cloud-based transcription. Browser Whisper works offline for transcription only.
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
- Rural clinics with unreliable internet
@ -74,9 +74,9 @@ Each specialty has unique documentation requirements that could be addressed wit
### 7. Billing Code Suggestions
**Current state:** Post-note billing suggestions are active as clinician-facing helper panels on supported note outputs.
**Current state:** The well visit tab includes some billing code references.
**Further improvement:** Improve payer-specific rules, add institution-specific favorites, and add export formats that match common EHR coding workflows.
**Improvement:** Automatically suggest ICD-10 and CPT codes based on the generated note content. After the AI generates a note, it could analyze the diagnoses, procedures, and visit complexity to suggest appropriate billing codes. This saves time on coding and reduces missed charges.
### 8. Quality Metrics Dashboard
@ -85,7 +85,7 @@ Each specialty has unique documentation requirements that could be addressed wit
**Improvement:** Add a dashboard showing:
- Average note generation time by type
- Most-used AI models and their accuracy (based on how often users edit the output)
- Transcription quality metrics from explicit user feedback or retry outcomes
- Transcription accuracy metrics (if corrections are tracked)
- Usage patterns by time of day and day of week
- Cost tracking across AI providers
@ -93,9 +93,9 @@ This would help administrators optimize model selection and identify training op
### 9. Patient Education Materials
**Current state:** Patient education handouts are active as post-note helpers. Generated notes can open a Handout panel that creates a parent-facing plain-text draft from the clinician note, with optional diagnosis, medication, patient age, and preferred language context. The Learning Hub remains the physician-facing education/CMS area.
**Current state:** The Learning Hub serves educational content to physicians.
**Further improvement:** Add handout templates, saved handout history, institution-approved language libraries, and printable/PDF export.
**Improvement:** Add a patient-facing education module that generates age-appropriate handouts based on the diagnosis. For example, after generating a note for a child with asthma, the app could produce a parent-friendly handout explaining the diagnosis, medications, and when to seek emergency care — in the parent's preferred language.
### 10. Multi-Language Support
@ -140,7 +140,7 @@ This mirrors the real workflow in training institutions and group practices.
### 14. Template Library
**Current state:** Physician templates and prompt preferences provide per-user personalization. Legacy correction-learning rows may exist but are no longer active behavior.
**Current state:** Physician memories and corrections provide some personalization.
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
- "My asthma follow-up template"
@ -182,7 +182,7 @@ Compared to existing medical scribes and documentation tools:
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
- **Provider-flexible** — routes through OpenRouter, Bedrock, Azure, Vertex, or LiteLLM depending on deployment configuration
- **Privacy-conscious** — self-hosted app, encrypted sensitive fields, auto-expiring encounter/audio recovery data, and configurable BAA-eligible providers
- **Template-aware** — user templates and prompt preferences can shape output without relying on automatic correction learning
- **Provider-agnostic** — works with any AI provider (swap between them without changing anything)
- **Privacy-first** — optional fully offline transcription, auto-expiring data, no permanent PHI storage
- **Learning system** — AI improves its output based on each physician's editing patterns
- **All-in-one** — documentation, calculators, education, and administration in a single platform

407
README.md
View file

@ -1,103 +1,78 @@
# Ped-AI
# Pediatric AI Scribe v6
Ped-AI is a pediatric clinical documentation, education, and bedside decision-support app. This fork has moved well beyond the original scribe app: it now combines encounter documentation, clinical workflows, Learning Hub CMS, admin controls, MCP-backed clinical assistant integration, Redis-backed operational state, and hardened deployment defaults.
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, well/sick visit notes, and developmental milestone assessments from voice recordings or dictation.
The app runs as an authenticated Express/Postgres service with a browser frontend and optional integrations for LiteLLM, Vertex/Gemini, AWS, OpenAI-compatible APIs, Nextcloud WebDAV, S3-compatible storage, OpenBao, Redis, OIDC, TOTP, and Cloudflare Turnstile.
## Current Scope
## Features
### Clinical Documentation
- **Live Encounter** — record doctor-patient conversations, AI generates structured OLDCARTS HPI
- **Voice Dictation** — dictate narrative, AI cleans and restructures
- **Hospital Course** — paste progress notes, generates prose, day-by-day, organ-system (ICU), or psych format
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes
- **SOAP Notes** — full SOAP or subjective-only from dictation
- **Well Visit** — AAP 2025 Bright Futures periodicity with vaccines, screenings, billing codes, SSHADESS (12+), milestones
- **Sick Visit** — quick documentation with auto-suggested ROS and PE from chief complaint
- **Developmental Milestones** — AAP/Nelson tracker (birth-11y) with narrative/structured/summary output
- Live encounter capture with structured pediatric HPI generation.
- Dictation cleanup for narrative notes.
- SOAP, sick visit, well visit, hospital course, chart review, precharting, and ED encounter workflows.
- Parent-facing education handouts generated from clinician notes, with diagnosis, medication, emergency-care guidance, and preferred-language support.
- Pediatric developmental milestone tooling.
- Templates, physician memory, and per-tab model overrides.
- Server-side speech-to-text routing through configured providers.
### Bedside Tools
- Pediatric calculators and emergency dosing helpers.
- PE guide and clinical reference content.
- Vaccines, catch-up schedules, growth/vitals, bilirubin, BSA, GCS, equipment, and resuscitation helpers.
- Mobile-friendly PWA layout for bedside use.
- Per-user phone extension and pager directory with soft-delete, search, ZIP export, and JSON/ZIP import for handoff between users.
### AI & Speech
- **5 AI Providers** — OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
- **5 STT Providers** — Google Gemini, Amazon Transcribe (Medical), OpenAI Whisper, Local Whisper, LiteLLM
- **3 TTS Providers** — Google Cloud TTS, LiteLLM (OpenAI), ElevenLabs
- **Browser Whisper** — fully offline in-browser transcription via WebAssembly (HIPAA-safe)
- **Per-tab model selector** — choose fast vs. smart vs. premium models per task
- **Physician memory system** — Dragon-like learning from your corrections
### Learning Hub
- **Content Management** — articles, clinical pearls, quizzes, presentations
- **AI Content Generation** — generate from topics, uploaded PDFs, or Nextcloud files
- **Marp Presentations** — slide editor with preview and PPTX export
- **Semantic Search** — vector-based search via pgvector embeddings
- **Quiz System** — MCQ, multi-select, true/false with scoring and progress tracking
- CMS for articles, clinical pearls, quizzes, and presentations.
- Tiptap article editor, quiz builder, category management, and draft/publish flow.
- AI-assisted content generation from topic text, uploaded files, or connected Nextcloud WebDAV files.
- Marp slide editing with preview and PPTX export.
- Keyword, semantic, and hybrid search using Postgres/pgvector where configured.
### Platform
- **Multi-user with roles** — admin, moderator, user
- **OIDC/SSO** — Azure AD, Okta, Keycloak, PocketID, Google
- **2FA** — TOTP-based two-factor authentication
- **Cloudflare Turnstile** — bot protection on login, register, password reset
- **Email verification** — with customizable templates
- **Nextcloud integration** — WebDAV export
- **S3 Document Storage** — AWS S3, Backblaze B2, MinIO
- **PWA** — installable, works on mobile
- **Admin Panel** — user management, settings, prompt editor, model configuration, logs
### Clinical Assistant
- Optional MCP-backed clinical assistant integration.
- Prompt suggestions backed by Redis operational cache.
- No clinical answer response caching.
- Designed to retrieve from indexed clinical material while keeping provider selection explicit.
### Admin And Security
- Local auth, role-based access, TOTP 2FA, OIDC/SSO, email verification, and optional Turnstile.
- Admin panel for users, settings, prompts, models, logs, and Learning Hub content.
- Audit, API, access, and client-error logs with redaction hardening.
- OpenBao secret loading support at container startup.
- S3-compatible document storage support.
## Removed Browser STT
Browser Whisper has been removed from the runtime. The app should not ship browser Whisper workers, browser-local Whisper model downloads, Transformers.js browser STT, or Browser Whisper setup docs.
Speech-to-text is handled server-side through configured providers such as Google/Gemini, AWS Transcribe, LiteLLM, or OpenAI Whisper. Browser-native Web Speech remains gated behind an explicit user setting when present in the browser.
---
## Quick Start
### 1. Configure
```bash
cp .env.example .env
docker compose up -d --build
```
The default compose exposes the app on `127.0.0.1:3552` and starts:
- `pediatric-ai-scribe` for the Node app.
- `pedscribe-db` for Postgres with pgvector.
- `ped-ai-redis` for operational Redis state.
Health check:
```bash
curl -fsS http://127.0.0.1:3552/api/health
```
Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix.
The first registered user becomes an admin unless registration has already been configured differently.
## Core Environment
Set real values in `.env` before production use.
Edit `.env` — at minimum set:
```env
APP_URL=https://your-domain.example
JWT_SECRET=<64-char-random-secret>
DB_PASSWORD=<strong-database-password>
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex
LITELLM_API_BASE=https://your-litellm.example.com
LITELLM_API_KEY=sk-...
AI_PROVIDER=litellm
LITELLM_API_BASE=https://your-litellm.example/v1
LITELLM_API_KEY=<key>
OPENAI_API_KEY=sk-... # for Whisper transcription (if not using LiteLLM STT)
TRANSCRIBE_PROVIDER=litellm
LITELLM_STT_MODEL=whisper-1
REDIS_URL=redis://ped-ai-redis:6379
JWT_SECRET=<64-char random> # openssl rand -hex 32
DB_PASSWORD=<strong password>
APP_URL=https://your-domain.com
```
Supported text AI providers include LiteLLM, OpenRouter, AWS Bedrock, Azure OpenAI, and Google Vertex AI. Supported STT routing includes Google/Gemini, AWS Transcribe, OpenAI Whisper, and LiteLLM. Supported TTS routing includes Google Cloud TTS, LiteLLM/OpenAI-compatible audio, and ElevenLabs where configured.
### 2. Start
## Admin CLI
```bash
docker compose up -d
```
App runs on **port 3552**. First user to register becomes admin.
### 3. Admin CLI
```bash
docker exec pediatric-ai-scribe node admin-cli.js list-users
@ -108,70 +83,270 @@ docker exec pediatric-ai-scribe node admin-cli.js toggle-registration
docker exec pediatric-ai-scribe node admin-cli.js stats
```
## Maintenance
---
The app checks Postgres collation drift on startup and can reindex text indexes after image or OS-library changes.
## AI Provider Configuration
Switch providers by setting `AI_PROVIDER` in `.env`. No code changes needed.
| Provider | HIPAA | Config |
|----------|-------|--------|
| **LiteLLM** | Depends on backend | `LITELLM_API_BASE`, `LITELLM_API_KEY` |
| **AWS Bedrock** | Yes (with BAA) | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
| **Azure OpenAI** | Yes (with BAA) | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME` |
| **Google Vertex AI** | Yes (with BAA) | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION` |
| **OpenRouter** | No | `OPENROUTER_API_KEY` |
---
## Transcription (Speech-to-Text)
Set `TRANSCRIBE_PROVIDER` or let the app auto-detect.
| Provider | HIPAA | Config |
|----------|-------|--------|
| **Google Gemini** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_STT_MODEL` |
| **Amazon Transcribe** | Yes | AWS creds + `TRANSCRIBE_PROVIDER=aws` |
| **Amazon Transcribe Medical** | Yes | `AWS_TRANSCRIBE_MEDICAL=true`, `AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE` |
| **Local Whisper** | Yes (offline) | `TRANSCRIBE_PROVIDER=local`, `WHISPER_BINARY`, `WHISPER_MODEL_SIZE` |
| **OpenAI Whisper** | No | `OPENAI_API_KEY` |
| **LiteLLM** | Depends | `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_STT_MODEL` |
| **Browser Whisper** | Yes (client-side) | No config needed — toggle in user settings |
---
## Text-to-Speech
| Provider | HIPAA | Config |
|----------|-------|--------|
| **Google Cloud TTS** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_TTS_VOICE` |
| **LiteLLM** | Depends | `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` |
| **ElevenLabs** | No | `ELEVENLABS_API_KEY` |
---
## OpenID Connect / SSO
Supports Azure AD, Okta, Keycloak, PocketID, Google, and any OIDC-compliant provider.
1. Register callback URL: `https://your-domain.com/api/auth/oidc/callback`
2. Admin Panel > Settings > Configure OIDC (Issuer URL, Client ID, Client Secret)
3. Users are auto-created and linked by email on first SSO login
See [OPENID_SETUP.md](OPENID_SETUP.md) for provider-specific guides.
---
## Cloudflare Turnstile (Bot Protection)
Optional CAPTCHA on login, registration, and password reset forms.
```env
TURNSTILE_SITE_KEY=0x4AAA...
TURNSTILE_SECRET_KEY=0x4AAA...
```
---
## Email
Without SMTP, email verification is skipped and users are auto-verified.
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
```
---
## Maintenance CLI
After a Postgres image upgrade (major version bump or silent base-layer change),
btree indexes on text columns can become inconsistent with the new ICU/glibc
library. The app auto-detects this at startup and reindexes on drift, but you
can also trigger it manually:
```bash
# Health check — no writes
docker exec pediatric-ai-scribe npm run maint:check
# Rebuild all indexes + refresh collation + ANALYZE
docker exec pediatric-ai-scribe npm run maint:reindex
```
Run the reindex command after major Postgres image changes, restoring a dump from another distro, or seeing lookup behavior that suggests collation/index drift.
Run `maint:reindex` any time after:
- Upgrading the Postgres image (major or minor)
- Restoring from a dump created on a different Linux distro
- Seeing "invalid credentials" on credentials you know are correct
- Seeing `0 rows` returned from a lookup that should match
The reindex takes seconds on a small DB and a minute or two on larger ones.
Safe to run while the app is serving traffic, though queries may slow briefly.
---
## Docker Hub
```bash
docker pull danielonyejesi/pediatric-ai-scribe-v3:latest
```
Minimal compose without building:
```yaml
services:
app:
image: danielonyejesi/pediatric-ai-scribe-v3:latest
ports:
- "3552:3000"
env_file: .env
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pedscribe"]
interval: 10s
retries: 5
volumes:
pgdata:
```
---
## HIPAA Notice
This application processes data through third-party AI APIs.
- All connections use HTTPS/TLS
- Authentication required for all AI endpoints
- 2FA and SSO available
- Cloudflare Turnstile bot protection
- **AWS Bedrock**, **Azure OpenAI**, and **Google Vertex AI** offer BAAs
- **OpenRouter** and **ElevenLabs** do NOT offer BAAs
- **Browser Whisper** and **Local Whisper** keep audio fully private
**Do not use real PHI without executed BAAs with all providers in your deployment.**
---
## Documentation
See the [docs/](docs/) directory for detailed documentation:
- [Architecture Overview](docs/architecture.md)
- [API Reference](docs/api-reference.md)
- [Database Schema](docs/database.md)
- [Authentication & Security](docs/authentication.md)
- [AI Providers & Models](docs/ai-providers.md)
- [Speech (STT/TTS)](docs/speech.md)
- [Learning Hub & CMS](docs/learning-hub.md)
- [Configuration Reference](docs/configuration.md)
- [Deployment Guide](docs/deployment.md)
- [Developer Guide](docs/developer-guide.md)
---
## Development
```bash
npm install
cp .env.example .env # edit with your keys
# Requires PostgreSQL with pgvector
node server.js
```
---
## Testing
Run the Node test suite:
Two layers, both zero-config after the initial setup.
### Unit tests — pure dose math (Node built-in)
```bash
npm test
```
Run syntax checks for touched files when doing focused backend work:
```bash
node --check server.js
node --check src/routes/transcribe.js
```
Run the Playwright smoke suite against the e2e compose stack:
Runs `node --test test/` against `public/js/calc-math.js` — pure functions for
APLS / Best Guess weight, Parkland, Holliday-Segar 4-2-1, PRAM, Westley,
epi (anaphylaxis vs arrest vs NRP, different concentrations), RSI drugs,
min SBP, ETT sizing, Lund-Browder TBSA. **36 assertions, no dependencies.**
### End-to-end tests — Playwright smoke suite
Runs a headless Chromium against the live app. **128 tests** covering every
calculator tab, every Bedside sub-pill + widget, auth-gated pages (encounter,
well visit, charts, vaccines, catch-up, learning hub, dictation, settings,
FAQ), at **both desktop and mobile (Pixel 5) viewports**.
```bash
# First-time setup: spin up the auth-less test container (port 3553)
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
# Then run the full suite (runs inside an official Playwright container)
npm run e2e
```
## Deployment Notes
The runner script (`scripts/e2e.sh`) uses `mcr.microsoft.com/playwright` so you
don't need Node or browsers on the host.
- Put the app behind HTTPS before clinical use.
- Use only AI/STT/TTS providers covered by your BAA and data-processing requirements.
- Configure OIDC/SSO and 2FA for production users.
- Keep `JWT_SECRET`, database credentials, provider keys, S3 keys, SMTP credentials, and OpenBao tokens out of git.
- Treat logs as sensitive operational data even with redaction enabled.
- Use the Caddy/reverse-proxy layer to expose only intended public routes.
**Test environment:**
## Documentation
- `pediatric-ai-scribe` (port 3552) — your normal app
- `pediatric-ai-scribe-e2e` (port 3553) — identical image, but with
`TURNSTILE_SECRET_KEY=""` and `SMTP_HOST=""` so Playwright can log in
without a bot challenge. Shares the same Postgres + pgdata volume.
- Test user: `e2e-user@ped-ai.test` (auto-verified on first register)
- Harness page: `public/e2e-harness.html` loads the calculators component
without the auth wall for smoke tests that don't need a logged-in session.
Primary references:
**Viewing failures** — Playwright writes `e2e/test-results/<test-name>/`
with:
- `docs/ARCHITECTURE.md` for the current system map and service boundaries.
- `docs/DEVELOPMENT.md` for day-to-day code-change workflow.
- `docs/SCALING.md` for scaling priorities and readiness work.
- `docs/CLINICAL_ASSISTANT.md` for MCP-backed assistant behavior and safety rules.
- `docs/MODULE_CONVENTIONS.md` for CommonJS, ESM, globals, and rendering rules.
- `docs/architecture.md` for high-level architecture.
- `docs/api-reference.md` for API routes.
- `docs/authentication.md` for auth, OIDC, and security configuration.
- `docs/ai-providers.md` for model/provider setup.
- `docs/speech.md` for server-side STT/TTS setup.
- `docs/learning-hub.md` for the CMS and education workflow.
- `docs/configuration.md` for environment variables.
- `docs/deployment.md` for production deployment.
- `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes.
- `docs/logic/README.md` for the deeper code walkthrough.
- `test-failed-1.png` — screenshot at the point of failure
- `trace.zip` — full action trace (replay with `npx playwright show-trace`)
- `error-context.md` — DOM snapshot and console logs
Some deep `docs/logic/` files still describe historical implementation details. Prefer runtime code and tests when documentation conflicts with current behavior.
Everything but the specs and config is gitignored under `e2e/`.
## Clinical Safety
**Files:**
Ped-AI is documentation and education support software. It does not replace clinical judgment, local policy, medication verification, or attending review. Validate generated notes, calculations, and recommendations before use in patient care.
- `e2e/tests/bedside-smoke.spec.js` — 26 tests for the Bedside module
- `e2e/tests/top-calculators.spec.js` — 27 tests for BP / BMI / Growth /
Bili / Vitals / BSA / Dose / Resus / GCS / Equipment
- `e2e/tests/auth-gated-smoke.spec.js` — 11 tests for the auth-gated tabs
- `e2e/playwright.config.js` — runs all the above under both `chromium`
(Desktop Chrome) and `mobile-chrome` (Pixel 5) projects
**Writing a new test:**
```js
const { test, expect } = require('@playwright/test');
test('my new smoke test', async ({ page }) => {
await page.goto('/e2e-harness.html'); // bypasses auth for calculators
await page.waitForFunction(() => window.__harnessReady === true);
await page.click('button.calc-nav-pill[data-calc="bedside"]');
await expect(page.locator('#calc-bedside')).toBeVisible();
});
```
For auth-gated routes, use the login fixture in `auth-gated-smoke.spec.js`
as a template — it caches the token at module scope so you don't hit the
login rate-limit.

279
TRANSCRIPTION_OPTIONS.md Normal file
View file

@ -0,0 +1,279 @@
# Transcription Options Guide
## Overview
Pediatric AI Scribe v2+ offers **three transcription methods**, allowing you to choose between **privacy**, **speed**, and **real-time feedback**.
---
## 📊 Comparison Table
| Feature | Browser Whisper | Server Transcription | Web Speech API |
|---------|----------------|---------------------|----------------|
| **Privacy** | ⭐⭐⭐⭐⭐ 100% offline | ⭐⭐⭐⭐ (with BAA) | ⭐ Sends to cloud |
| **Accuracy** | ⭐⭐⭐⭐⭐ Whisper | ⭐⭐⭐⭐⭐ Gemini/AWS | ⭐⭐⭐ Browser-dependent |
| **Speed** | ⭐⭐⭐ 2-10s | ⭐⭐⭐⭐⭐ ~1s | ⭐⭐⭐⭐⭐ Instant |
| **Real-time** | ❌ Batch mode | ❌ Batch mode | ✅ Live streaming |
| **HIPAA** | ✅ Yes | ✅ (Vertex/AWS) | ❌ No |
| **Cost** | Free | ~$0.005/min | Free |
| **Internet** | ❌ Not required | ✅ Required | ✅ Required |
| **Setup** | None (bundled) | API keys | None (built-in) |
---
## Option 1: Browser Whisper (Offline, Private) ⭐ RECOMMENDED
### What It Is
- Runs **OpenAI Whisper** entirely in your browser using WebAssembly
- Audio **never leaves your device** - 100% offline after initial page load
- Models bundled in Docker image (self-hosted, no CDN)
### When to Use
- ✅ Clinical documentation (HIPAA-compliant)
- ✅ Maximum privacy required
- ✅ Offline/air-gapped environments
- ✅ No API costs
- ✅ Zero vendor dependency
### How to Enable
1. Settings → Browser Transcription
2. Toggle "Enable browser transcription" ON
3. (Optional) Click "Pre-download model" if you want to cache it first
4. Start recording - transcription happens automatically after recording
### Models Available
- **Tiny** (~39MB) - Fast, good for short clips (2-3 seconds)
- **Base** (~74MB) - Balanced accuracy and speed (3-5 seconds)
- **Small** (~244MB) - Best quality, slower (6-10 seconds)
### Performance
- Transcribes ~30-second clip in 2-10 seconds (depending on model)
- First run may be slower (model loading)
- Subsequent runs are instant (cached)
### Privacy
- ✅ Audio never transmitted
- ✅ Models run locally in WASM
- ✅ No network calls during transcription
- ✅ HIPAA-compliant
---
## Option 2: Server Transcription (Cloud, Fast)
### What It Is
- Sends audio to your configured AI provider
- Uses Google Gemini, AWS Transcribe, OpenAI Whisper, or LiteLLM
### When to Use
- ✅ Maximum speed (~1 second for 30-second clip)
- ✅ Best accuracy (cloud models)
- ✅ Long recordings (Browser Whisper can be slow for 5+ minutes)
- ✅ HIPAA-compliant with BAA providers
### HIPAA-Eligible Providers
- **Google Vertex AI** (with BAA) ✅
- **AWS Transcribe** (with BAA) ✅
- **Azure OpenAI** (with BAA) ✅
- **OpenAI Whisper Direct** ❌ Not HIPAA-eligible
### How to Enable
- Configured via environment variables (`.env`)
- No user action needed - just works if API keys present
- Falls back automatically if Browser Whisper fails
### Cost
- Google Gemini: ~$0.005/minute
- AWS Transcribe: ~$0.024/minute
- OpenAI: $0.006/minute
---
## Option 3: Web Speech API (Real-Time, Experimental) ⚠️
### What It Is
- Uses your browser's built-in speech recognition
- Shows transcription **in real-time** as you speak (streaming)
- Chrome/Edge → Google Cloud Speech
- Safari → Apple Speech Recognition
### ⚠️ PRIVACY WARNING
- **Audio IS sent to cloud servers** (Google, Apple, etc.)
- **NOT HIPAA-compliant**
- Only use for non-clinical, personal use
### When to Use
- ✅ Personal notes (non-clinical)
- ✅ Want real-time feedback while speaking
- ✅ Demonstration/testing
- ❌ **NEVER for patient data**
### How to Enable
1. Settings → Real-Time Streaming Transcription
2. Read privacy warning carefully
3. Toggle "Enable real-time streaming" ON
4. Confirm warning dialog
5. Grants microphone permission
6. Start recording - see words appear live
### Limitations
- Not available in all browsers (requires Web Speech API)
- Accuracy varies by browser
- Requires internet connection
- May have usage limits
---
## Choosing the Right Option
### For Clinical Use (HIPAA Required)
**Use:** Browser Whisper (offline) OR Server (Vertex AI/AWS with BAA)
- Browser Whisper: Maximum privacy, no costs
- Server: Faster, better for long recordings
### For Personal Use (Non-HIPAA)
**Use:** Any option
- Browser Whisper: Best balance of privacy and accuracy
- Server: Fastest
- Web Speech: Real-time feedback
### Decision Tree
```
Is this clinical/patient data?
├─ YES → Use Browser Whisper or Server (Vertex/AWS)
│ ├─ Need offline? → Browser Whisper
│ ├─ Need speed? → Server (Vertex AI)
│ └─ Want free? → Browser Whisper
└─ NO → Any option
├─ Want real-time? → Web Speech API
├─ Want privacy? → Browser Whisper
└─ Want speed? → Server
```
---
## Configuration
### Browser Whisper
```bash
# No configuration needed - bundled in Docker image
# Models at: /app/public/models/Xenova/whisper-tiny.en/
```
### Server Transcription
```bash
# .env file
TRANSCRIBE_PROVIDER=google # google, aws, openai, litellm
# Google Vertex AI
GOOGLE_VERTEX_PROJECT=your-project-id
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
# AWS Transcribe
AWS_BEDROCK_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
# OpenAI
OPENAI_API_KEY=sk-...
# LiteLLM (proxy)
LITELLM_API_BASE=http://localhost:4000
LITELLM_API_KEY=optional
```
### Web Speech API
```bash
# No configuration - uses browser built-in
# Privacy warning shown in Settings UI
```
---
## FAQ
### Q: Which is most accurate?
**A:** Browser Whisper and Server (Gemini/Whisper) are equally accurate. Web Speech is slightly less accurate.
### Q: Which is fastest?
**A:** Server transcription (~1s) > Web Speech (real-time) > Browser Whisper (2-10s)
### Q: Which is most private?
**A:** Browser Whisper (100% offline) > Server (with BAA) > Web Speech (not private)
### Q: Can I use multiple at once?
**A:** No. Priority: Web Speech > Browser Whisper > Server (whichever is enabled first)
### Q: What if transcription fails?
**A:** Automatic fallback chain:
1. Browser Whisper (if enabled)
2. Falls back to Server (if configured)
3. Falls back to live transcript (if available)
### Q: Is Browser Whisper really offline?
**A:** Yes! Models are bundled in the Docker image. After the page loads once, transcription works with zero network access.
### Q: Does Web Speech work offline?
**A:** No. It requires internet to send audio to cloud servers.
### Q: Can I train/customize the models?
**A:** No. Browser Whisper uses pre-trained models. Server transcription uses cloud models. No custom training available.
---
## Troubleshooting
### Browser Whisper stuck at "Initializing"
- **Cause:** Models not loaded or network blocked during initial download
- **Fix:** See BROWSER_WHISPER_TROUBLESHOOTING.md
### Server transcription returns "No provider"
- **Cause:** API keys not configured
- **Fix:** Set environment variables in `.env`
### Web Speech says "Not supported"
- **Cause:** Browser doesn't support Web Speech API
- **Fix:** Use Chrome, Edge, or Safari
### Transcription is slow
- **Browser Whisper:** Try switching to "Tiny" model
- **Server:** Check API provider status
- **Web Speech:** Check internet connection
---
## Best Practices
### Clinical Documentation
1. Use Browser Whisper for all patient data
2. Enable audio backups (automatic in v2)
3. Keep recordings under 5 minutes for faster processing
4. Use "Tiny" model for quick notes, "Base" for detailed documentation
### Personal Use
1. Web Speech for quick, informal notes
2. Browser Whisper for anything you want private
3. Server for long recordings
### Performance Optimization
1. Pre-download Browser Whisper model before first use
2. Use shorter clips (30-60 seconds) for fastest results
3. Clear browser cache if models seem corrupted
---
## Summary
| Need | Recommendation |
|------|---------------|
| Clinical/HIPAA | Browser Whisper (offline) |
| Fast transcription | Server (Vertex AI) |
| Real-time feedback | Web Speech (non-clinical only) |
| Maximum privacy | Browser Whisper |
| Zero cost | Browser Whisper |
| Long recordings | Server (faster for 5+ min clips) |
| Offline use | Browser Whisper |
**Default recommendation:** Browser Whisper for 95% of use cases. It's private, accurate, free, and offline. Only use alternatives when you have specific needs for speed or real-time feedback.

24
client/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
client/README.md Normal file
View file

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

21
client/components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

22
client/eslint.config.js Normal file
View file

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
client/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3723
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

47
client/package.json Normal file
View file

@ -0,0 +1,47 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.1",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.9.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
client/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

101
client/src/App.tsx Normal file
View file

@ -0,0 +1,101 @@
import { lazy, Suspense } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import Layout from '@/components/Layout';
import AuthGuard from '@/components/AuthGuard';
// Lightweight pages stay in the main chunk.
import Extensions from '@/pages/Extensions';
import Faq from '@/pages/Faq';
// Heavy pages lazy-load — keeps the initial bundle small.
const Auth = lazy(() => import('@/pages/Auth'));
const ResetPassword = lazy(() => import('@/pages/ResetPassword'));
const Dictation = lazy(() => import('@/pages/Dictation'));
const Encounter = lazy(() => import('@/pages/Encounter'));
const Soap = lazy(() => import('@/pages/Soap'));
const SickVisit = lazy(() => import('@/pages/SickVisit'));
const HospitalCourse = lazy(() => import('@/pages/HospitalCourse'));
const ChartReview = lazy(() => import('@/pages/ChartReview'));
const WellVisit = lazy(() => import('@/pages/WellVisit'));
const VaxSchedule = lazy(() => import('@/pages/VaxSchedule'));
const Catchup = lazy(() => import('@/pages/Catchup'));
const Settings = lazy(() => import('@/pages/Settings'));
const Learning = lazy(() => import('@/pages/Learning'));
const PeGuide = lazy(() => import('@/pages/PeGuide'));
const Bedside = lazy(() => import('@/pages/Bedside'));
const Calculators = lazy(() => import('@/pages/Calculators'));
const Admin = lazy(() => import('@/pages/Admin'));
const Cms = lazy(() => import('@/pages/Cms'));
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
function RouteFallback() {
return (
<div className="max-w-3xl mx-auto p-6 text-sm text-muted-foreground">Loading</div>
);
}
function Home() {
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
<h1 className="text-2xl font-semibold">Pediatric AI Scribe</h1>
<p className="text-sm text-muted-foreground">Pick a tool from the sidebar.</p>
<ul className="list-disc pl-6 text-sm space-y-1">
<li><Link to="/encounter" className="underline">Encounter HPI</Link></li>
<li><Link to="/dictation" className="underline">Dictation HPI</Link></li>
<li><Link to="/soap" className="underline">SOAP Note</Link></li>
<li><Link to="/sickvisit" className="underline">Sick Visit</Link></li>
<li><Link to="/wellvisit" className="underline">Well Visit</Link></li>
<li><Link to="/peguide" className="underline">Physical Exam Guide</Link></li>
<li><Link to="/bedside" className="underline">Bedside</Link></li>
<li><Link to="/calculators" className="underline">Calculators</Link></li>
<li><Link to="/learning" className="underline">Learning Hub</Link></li>
</ul>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Public routes */}
<Route path="/auth" element={<Auth />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Private routes — AuthGuard + Layout wrap everything */}
<Route element={<AuthGuard />}>
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/encounter" element={<Encounter />} />
<Route path="/dictation" element={<Dictation />} />
<Route path="/soap" element={<Soap />} />
<Route path="/sickvisit" element={<SickVisit />} />
<Route path="/hospital" element={<HospitalCourse />} />
<Route path="/chart" element={<ChartReview />} />
<Route path="/wellvisit" element={<WellVisit />} />
<Route path="/vaxschedule" element={<VaxSchedule />} />
<Route path="/catchup" element={<Catchup />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/settings" element={<Settings />} />
<Route path="/learning" element={<Learning />} />
<Route path="/peguide" element={<PeGuide />} />
<Route path="/bedside" element={<Bedside />} />
<Route path="/calculators" element={<Calculators />} />
<Route path="/admin" element={<Admin />} />
<Route path="/cms" element={<Cms />} />
<Route path="/faq" element={<Faq />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,30 @@
// ============================================================
// AUTH GUARD — redirects to /auth if /api/auth/me returns 401.
// Wraps every private route so unauthenticated users land on
// the login screen automatically.
// ============================================================
import { Navigate, useLocation, Outlet } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { MeOk } from '@/shared/types';
export default function AuthGuard() {
const loc = useLocation();
const { data, isLoading, isError } = useQuery<MeOk>({
queryKey: ['auth-me'],
queryFn: () => api.get<MeOk>('/api/auth/me'),
retry: false,
staleTime: 5 * 60_000,
});
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center text-sm text-muted-foreground">Loading</div>;
}
if (isError || !data?.user) {
// Preserve deep link so we can bounce the user back after sign-in.
const next = encodeURIComponent(loc.pathname + loc.search);
return <Navigate to={`/auth?next=${next}`} replace />;
}
return <Outlet />;
}

View file

@ -0,0 +1,111 @@
// ============================================================
// CONFIRM MODAL — React replacement for the vanilla showConfirm()
// helper. Daniel's feedback is explicit: never call window.confirm()
// in the frontend — use a styled modal that matches the app's design
// language. Supports a danger variant (destructive actions like
// revoke) and an optional password-input variant (e.g. "confirm by
// entering your password" for 2FA backup-code regen).
// ============================================================
import { useEffect, useState } from 'react';
interface ConfirmModalProps {
open: boolean;
title: string;
body?: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
// When true, a password field is shown and the value is passed to onConfirm.
requirePassword?: boolean;
passwordPlaceholder?: string;
onConfirm: (password?: string) => void;
onCancel: () => void;
busy?: boolean;
}
export default function ConfirmModal({
open,
title,
body,
confirmText = 'Confirm',
cancelText = 'Cancel',
danger = false,
requirePassword = false,
passwordPlaceholder = 'Password',
onConfirm,
onCancel,
busy = false,
}: ConfirmModalProps) {
const [password, setPassword] = useState('');
useEffect(() => {
if (!open) setPassword('');
}, [open]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') onCancel();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onCancel]);
if (!open) return null;
const confirmDisabled = busy || (requirePassword && !password);
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm rounded-lg border border-border bg-background p-5 shadow-lg space-y-3"
onClick={(e) => e.stopPropagation()}
>
<h3 id="confirm-modal-title" className="text-base font-semibold">{title}</h3>
{body && <p className="text-sm text-muted-foreground">{body}</p>}
{requirePassword && (
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={passwordPlaceholder}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && !confirmDisabled) onConfirm(password);
}}
/>
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-border px-3 py-2 text-sm"
data-testid="confirm-modal-cancel"
>
{cancelText}
</button>
<button
type="button"
disabled={confirmDisabled}
onClick={() => onConfirm(requirePassword ? password : undefined)}
className={
'rounded-md px-3 py-2 text-sm font-medium text-white disabled:opacity-50 ' +
(danger ? 'bg-destructive' : 'bg-primary')
}
data-testid="confirm-modal-ok"
>
{busy ? 'Working…' : confirmText}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
// ============================================================
// DxPicker — ICD-10 diagnosis picker. Live search via NLM Clinical
// Tables API (free, no auth, CORS-enabled) + a grid of common
// pediatric diagnoses for one-click add. Mirrors the vanilla
// renderDxComponent / searchIcd10 in public/js/shadess.js (@be14578).
//
// Selected diagnoses render as removable chips. Consumers pass the
// current array + a setter; the component owns search state only.
// ============================================================
import { useEffect, useRef, useState } from 'react';
import { COMMON_DX, type DxEntry } from '@shared/clinical/ros-pe-dx';
interface Props {
value: DxEntry[];
onChange: (next: DxEntry[]) => void;
testIdPrefix?: string;
}
const sm = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
async function searchIcd10(q: string, signal: AbortSignal): Promise<DxEntry[]> {
const url = 'https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?sf=code,name&terms='
+ encodeURIComponent(q) + '&maxList=12';
const r = await fetch(url, { signal });
const data = await r.json();
// NLM returns [count, [codes], null, [[code, name], …]]
const rows = (data[3] || []) as Array<[string, string]>;
return rows.map(([code, name]) => ({ code, name }));
}
export default function DxPicker({ value, onChange, testIdPrefix = 'dx' }: Props) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<DxEntry[]>([]);
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (!query.trim() || query.trim().length < 2) {
setResults([]); setOpen(false);
return;
}
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
if (abortRef.current) abortRef.current.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
searchIcd10(query.trim(), ctrl.signal)
.then((rows) => { setResults(rows); setOpen(rows.length > 0); })
.catch(() => { /* fetch aborted or failed — leave previous state */ });
}, 280);
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [query]);
function add(dx: DxEntry) {
const already = value.some((d) => d.code === dx.code && d.name === dx.name);
if (already) return;
onChange([...value, dx]);
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
return (
<div className="space-y-2" data-testid={testIdPrefix + '-picker'}>
<div className="relative">
<input
type="text"
placeholder='Search ICD-10 (e.g. "otitis", "J06")…'
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => results.length && setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false);
if (e.key === 'Enter' && results[0]) {
e.preventDefault();
add(results[0]);
setQuery('');
setOpen(false);
}
}}
className={sm + ' w-full'}
data-testid={testIdPrefix + '-search'}
/>
{open && results.length > 0 && (
<div className="absolute z-30 left-0 right-0 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg">
{results.map((r) => (
<button
key={r.code}
type="button"
onMouseDown={(e) => {
e.preventDefault();
add(r);
setQuery('');
setOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-muted border-b border-border last:border-0"
data-testid={testIdPrefix + '-result-' + r.code}
>
<span className="inline-block w-20 text-xs font-mono text-muted-foreground">{r.code}</span>
<span>{r.name}</span>
</button>
))}
</div>
)}
</div>
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5" data-testid={testIdPrefix + '-tags'}>
{value.map((d, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-full bg-primary/10 border border-primary/30 text-primary px-2 py-0.5 text-xs">
{d.code && <strong className="font-semibold">{d.code}</strong>}
<span>{d.name}</span>
<button type="button" onClick={() => remove(i)} className="text-xs text-muted-foreground hover:text-destructive" title="Remove">×</button>
</span>
))}
</div>
)}
<div>
<div className="text-[11px] text-muted-foreground mb-1">Common pediatric diagnoses:</div>
<div className="flex flex-wrap gap-1">
{COMMON_DX.map((dx) => (
<button
key={dx.code}
type="button"
onClick={() => add(dx)}
className="text-[11px] rounded border border-border bg-background px-2 py-0.5 hover:bg-muted"
title={dx.name}
data-testid={testIdPrefix + '-chip-' + dx.code}
>
<span className="font-mono text-muted-foreground mr-1">{dx.code}</span>
{dx.name}
</button>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,117 @@
// ============================================================
// EditableResult — editable AI-output box + correction tracking +
// OutputActions toolbar. Restores two pieces of vanilla behavior
// the React migration silently dropped:
//
// 1. The output divs were `contenteditable="true"` in vanilla.
// Users routinely fix AI mistakes inline before saving the
// encounter or copying out. The early React port rendered the
// output as a read-only div — copy was the only path.
//
// 2. correctionTracker.js (public/js/correctionTracker.js) saved
// every meaningful inline edit to user_memories under category
// `correction_<section>` so the AI learns user preferences.
// Settings → Corrections still shows them, but nothing in React
// was *writing* them. This component restores that loop.
//
// On blur, if the text differs from the captured "original AI
// output" by more than the noise threshold, POST /api/memories/
// correction. Mirrors the vanilla heuristic exactly so we don't
// flood memories with whitespace-only edits.
// ============================================================
import { useEffect, useRef } from 'react';
import OutputActions from '@/components/OutputActions';
import { api } from '@/lib/api';
import { isMeaningfulChange } from '@shared/clinical/correction-tracker';
// Server enum — must match VALID_CATEGORIES in src/routes/memories.ts.
export type CorrectionSection =
| 'encounter'
| 'hpi'
| 'soap'
| 'wellvisit'
| 'sickvisit';
interface Props {
text: string;
onChange: (next: string) => void;
/**
* Which note type this is selects the correction memory bucket.
* Pass `null` to skip correction tracking (Hospital Course and Chart
* Review aren't in the server's VALID_CATEGORIES enum).
*/
section: CorrectionSection | null;
/** Filename prefix for Nextcloud export (passed through to OutputActions). */
exportLabel: string;
/** docType field for Nextcloud export. */
exportType: string;
/** Optional source material — refine reads this for context. */
sourceContext?: string;
/** Title shown in the section header. */
title: string;
}
export default function EditableResult({
text, onChange, section, exportLabel, exportType, sourceContext, title,
}: Props) {
// The "AI baseline" — what the model produced last. Updated when refine
// or shorten replaces the body (handled in onUpdate below) so the next
// user edit is measured against the new baseline, not the original.
const originalRef = useRef<string>(text);
// Re-baseline whenever the text grows/changes from a non-edit source —
// i.e. when generation produced new output (parent flipped from null→text).
// We can't perfectly distinguish a parent-driven change from a user typing,
// but the typical generation flow is null → full text, so a length jump
// back to a different value is treated as a new baseline.
useEffect(() => {
if (!originalRef.current && text) originalRef.current = text;
}, [text]);
async function maybeSaveCorrection() {
if (!section) return;
if (!isMeaningfulChange(originalRef.current, text)) return;
try {
await api.post('/api/memories/correction', {
section,
original_snippet: originalRef.current,
corrected_snippet: text,
});
// Successful save → make the new text the baseline so successive
// edits get tracked against the most-recently-saved version.
originalRef.current = text;
} catch {
// Fail silently — correction tracking is best-effort, never blocks
// the user's primary copy/refine/save flow.
}
}
return (
<section className="rounded-lg border border-border bg-card" data-testid={'editable-result-' + exportType}>
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">{title}</h2>
</header>
<textarea
className="w-full p-4 text-sm bg-transparent resize-y min-h-[200px] focus:outline-none focus:ring-0 border-0 whitespace-pre-wrap"
value={text}
onChange={(e) => onChange(e.target.value)}
onBlur={maybeSaveCorrection}
data-testid={'editable-result-body-' + exportType}
spellCheck
/>
<div className="px-4 pb-4">
<OutputActions
text={text}
onUpdate={(t) => {
// Refine / shorten output replaces the body — re-baseline.
originalRef.current = t;
onChange(t);
}}
sourceContext={sourceContext}
exportLabel={exportLabel}
exportType={exportType}
/>
</div>
</section>
);
}

View file

@ -0,0 +1,159 @@
// ============================================================
// Encounter Toolbar — Save / New Patient / Load + label input.
// Mirrors the vanilla saveFromTab / clearTab flow from
// public/js/encounters.js. Each note page gets one of these so
// transcripts + generated notes persist across sign-outs.
// ============================================================
import { useEffect, useState } from 'react';
import {
saveEncounter, loadEncounter, listSavedEncounters,
getSavedEncId, clearTabState,
type EncType, type SavedEncounterListEntry, type LoadedEncounter,
} from '@/lib/encounter-persistence';
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
interface Props {
type: EncType;
label: string;
setLabel: (s: string) => void;
transcript: string;
generatedNote: string;
partialData?: unknown;
onLoad: (enc: LoadedEncounter) => void;
onClear: () => void;
}
export default function EncounterToolbar({
type, label, setLabel, transcript, generatedNote, partialData, onLoad, onClear,
}: Props) {
const [msg, setMsg] = useState<{ kind: 'ok' | 'err' | 'info'; text: string } | null>(null);
const [saving, setSaving] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [saved, setSaved] = useState<SavedEncounterListEntry[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [search, setSearch] = useState('');
const [savedId, setSavedId] = useState<number | null>(getSavedEncId(type));
// Sync the savedId chip whenever sessionStorage changes (e.g. after Load).
useEffect(() => {
const v = getSavedEncId(type);
setSavedId(v);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transcript, generatedNote]);
async function save() {
if (!label.trim()) { setMsg({ kind: 'err', text: 'Enter a patient label first' }); return; }
setMsg(null); setSaving(true);
try {
const r = await saveEncounter({ type, label, transcript, generatedNote, partialData });
setSavedId(r.id);
setMsg({ kind: 'ok', text: 'Saved (' + label + ')' });
} catch (e) {
setMsg({ kind: 'err', text: (e as Error).message });
} finally { setSaving(false); }
}
function newPatient() {
clearTabState(type);
setSavedId(null);
setLabel('');
onClear();
setMsg({ kind: 'info', text: 'New patient — fields cleared.' });
}
async function openLoad() {
if (popoverOpen) { setPopoverOpen(false); return; }
setPopoverOpen(true);
setLoadingList(true);
try {
const list = await listSavedEncounters();
setSaved(list.filter((e) => e.enc_type === type));
} finally { setLoadingList(false); }
}
async function pick(id: number) {
try {
const enc = await loadEncounter(id);
setSavedId(id);
onLoad(enc);
setPopoverOpen(false);
setMsg({ kind: 'ok', text: 'Loaded ' + (enc.label || '') });
} catch (e) {
setMsg({ kind: 'err', text: (e as Error).message });
}
}
const filtered = search
? saved.filter((e) => (e.label || '').toLowerCase().includes(search.toLowerCase()))
: saved;
return (
<div className="space-y-2" data-testid={'enc-toolbar-' + type}>
<div className="flex flex-wrap items-center gap-2">
<input
className={input + ' flex-1 min-w-[180px] max-w-md'}
placeholder="Patient label (e.g. JD-2026-04-24)"
value={label}
onChange={(e) => setLabel(e.target.value)}
data-testid="enc-label"
/>
<button type="button" onClick={save} disabled={saving} className={btnPrimary} data-testid="enc-save">
💾 {saving ? 'Saving…' : (savedId != null ? 'Save (update)' : 'Save')}
</button>
<div className="relative">
<button type="button" onClick={openLoad} className={btn} data-testid="enc-load-toggle">
📂 Load
</button>
{popoverOpen && (
<div className="absolute z-30 mt-1 right-0 w-80 max-h-96 overflow-auto rounded-md border border-border bg-card shadow-lg">
<div className="sticky top-0 bg-card p-2 border-b border-border">
<input
type="search"
placeholder="Filter saved encounters…"
className={input + ' w-full text-xs'}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
data-testid="enc-load-search"
/>
</div>
{loadingList && <div className="p-3 text-xs text-muted-foreground">Loading</div>}
{!loadingList && filtered.length === 0 && (
<div className="p-3 text-xs text-muted-foreground italic">No saved {type} encounters.</div>
)}
{filtered.map((e) => (
<button
key={e.id}
type="button"
onClick={() => pick(e.id)}
className="w-full text-left px-3 py-2 hover:bg-muted border-b border-border last:border-0"
data-testid={'enc-load-pick-' + e.id}
>
<div className="text-sm font-medium truncate">{e.label}</div>
<div className="text-xs text-muted-foreground">
Updated {new Date(e.updated_at).toLocaleString()} · expires {new Date(e.expires_at).toLocaleDateString()}
</div>
</button>
))}
</div>
)}
</div>
<button type="button" onClick={newPatient} className={btn} data-testid="enc-new">
🆕 New patient
</button>
{savedId != null && (
<span className="text-xs text-muted-foreground">Draft #{savedId}</span>
)}
</div>
{msg && (
<div className={
'text-sm ' +
(msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground')
}>{msg.text}</div>
)}
</div>
);
}

View file

@ -0,0 +1,151 @@
// ============================================================
// LAYOUT — sidebar + main content shell shared across every page.
// Structure mirrors the vanilla app so a user moving between the two
// trees during migration sees consistent navigation.
// ============================================================
import { NavLink, Outlet } from 'react-router-dom';
import type { ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { MeOk } from '@/shared/types';
interface NavItem {
to: string;
label: string;
available?: boolean; // false = rendered as "coming soon" stub
adminOnly?: boolean; // renders only when me.user.role === 'admin'
}
interface NavGroup {
label: string;
items: NavItem[];
}
const NAV: NavGroup[] = [
{
label: 'Encounters',
items: [
{ to: '/encounter', label: 'Encounter HPI', available: true },
{ to: '/dictation', label: 'Dictation HPI', available: true },
],
},
{
label: 'Notes',
items: [
{ to: '/hospital', label: 'Hospital Course', available: true },
{ to: '/chart', label: 'Chart Review', available: true },
{ to: '/soap', label: 'SOAP Note', available: true },
{ to: '/wellvisit', label: 'Well Visit', available: true },
{ to: '/sickvisit', label: 'Sick Visit', available: true },
],
},
{
label: 'Clinical Tools',
items: [
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: true },
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
{ to: '/peguide', label: 'Physical Exam Guide', available: true },
{ to: '/bedside', label: 'Bedside', available: true },
{ to: '/calculators', label: 'Calculators', available: true },
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
{ to: '/learning', label: 'Learning Hub', available: true },
],
},
{
label: 'Account',
items: [
{ to: '/settings', label: 'Settings', available: true },
{ to: '/faq', label: 'FAQ', available: true },
],
},
{
label: 'Admin',
items: [
{ to: '/admin', label: 'Admin Panel', available: true, adminOnly: true },
{ to: '/cms', label: 'Content Manager', available: true, adminOnly: true },
],
},
];
function SidebarLink({ item }: { item: NavItem }) {
if (!item.available) {
return (
<div
className="px-3 py-2 text-sm rounded-md text-muted-foreground italic cursor-not-allowed opacity-60"
title="Not yet ported to React — still available in the vanilla app at /"
>
{item.label} <span className="text-[10px]">· pending</span>
</div>
);
}
return (
<NavLink
to={item.to}
className={({ isActive }) =>
'block px-3 py-2 text-sm rounded-md transition-colors ' +
(isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-foreground')
}
>
{item.label}
</NavLink>
);
}
export default function Layout({ children }: { children?: ReactNode }) {
// One-shot /me fetch shared across the app via React Query cache.
// Settings already uses this queryKey, so the Layout gets it for free
// after the first Settings visit — and vice versa.
const { data: me } = useQuery<MeOk>({
queryKey: ['auth-me'],
queryFn: () => api.get<MeOk>('/api/auth/me'),
staleTime: 5 * 60_000,
});
const isAdmin = me?.user.role === 'admin';
return (
<div className="min-h-screen bg-background text-foreground flex">
{/* Sidebar */}
<aside className="w-64 border-r border-border bg-muted/30 flex-shrink-0 p-3 space-y-4 sticky top-0 h-screen overflow-y-auto">
<div className="px-2 py-1 border-b border-border pb-3 flex items-center justify-between gap-2">
<div className="font-semibold">Pediatric AI Scribe</div>
<a
href="/api/auth/logout"
onClick={async (e) => {
e.preventDefault();
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch { /* ignore */ }
window.location.href = '/auth';
}}
className="text-[11px] text-muted-foreground hover:text-foreground"
title="Sign out"
>
Sign out
</a>
</div>
{NAV.map((group) => {
const items = group.items.filter((i) => !i.adminOnly || isAdmin);
if (items.length === 0) return null;
return (
<div key={group.label} className="space-y-1">
<div className="px-3 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</div>
{items.map((item) => (
<SidebarLink key={item.to} item={item} />
))}
</div>
);
})}
</aside>
{/* Main */}
<main className="flex-1 min-w-0">
{children ?? <Outlet />}
</main>
</div>
);
}

View file

@ -0,0 +1,176 @@
// ============================================================
// OutputActions — Copy / Read / Export / Refine / Shorter bar
// that sits under every generated note. Port of the vanilla
// `output-actions` + `refine-bar` blocks (same buttons on every
// note page component).
//
// Copy → navigator.clipboard
// Read → POST /api/text-to-speech (MPEG bytes → Audio)
// Export → POST /api/nextcloud/export
// Refine → POST /api/refine (instructions + sourceContext)
// Shorter→ POST /api/shorten
//
// Each button is independent — if Nextcloud is unconfigured, only
// that one errors; the rest keep working.
// ============================================================
import { useRef, useState } from 'react';
import { api, ApiError } from '@/lib/api';
import type { RefineOk, ShortenOk } from '@/shared/types';
interface Props {
text: string;
// Applied after Refine / Shorter — parent updates its result state.
onUpdate: (newText: string) => void;
// Optional — passed to /api/refine as sourceContext so the model
// can reference the original transcript when following instructions.
sourceContext?: string;
// Nextcloud filename prefix (e.g. 'hpi-encounter', 'soap-note').
exportLabel: string;
// Nextcloud docType (matches vanilla's data-label / type field).
exportType: string;
}
export default function OutputActions({
text, onUpdate, sourceContext, exportLabel, exportType,
}: Props) {
const [instructions, setInstructions] = useState('');
const [busy, setBusy] = useState<null | 'refine' | 'shorten' | 'tts' | 'export'>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioUrlRef = useRef<string | null>(null);
async function copy() {
try {
await navigator.clipboard.writeText(text);
setMsg({ kind: 'ok', text: 'Copied' });
} catch {
setMsg({ kind: 'err', text: 'Copy failed' });
}
}
async function read() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to read' }); return; }
// If already playing, stop.
if (audioRef.current && !audioRef.current.paused) {
audioRef.current.pause();
audioRef.current = null;
if (audioUrlRef.current) { URL.revokeObjectURL(audioUrlRef.current); audioUrlRef.current = null; }
return;
}
setBusy('tts'); setMsg(null);
try {
const resp = await fetch('/api/text-to-speech', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!resp.ok) {
const ct = resp.headers.get('content-type') || '';
const errMsg = ct.includes('json') ? ((await resp.json()).error || 'TTS failed') : 'TTS failed';
throw new Error(errMsg);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
audioUrlRef.current = url;
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => {
URL.revokeObjectURL(url);
if (audioUrlRef.current === url) audioUrlRef.current = null;
if (audioRef.current === audio) audioRef.current = null;
};
await audio.play();
} catch (e) {
setMsg({ kind: 'err', text: (e as Error).message });
} finally { setBusy(null); }
}
async function exportToNextcloud() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to export' }); return; }
setBusy('export'); setMsg(null);
try {
const filename = exportLabel + '-' + Date.now();
const data = await api.post<{ success: true; message: string }>('/api/nextcloud/export', {
content: text, filename, type: exportType,
});
setMsg({ kind: 'ok', text: data.message || 'Exported' });
} catch (e) {
setMsg({ kind: 'err', text: (e as ApiError).message || 'Nextcloud not connected' });
} finally { setBusy(null); }
}
async function refine() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'No document to refine' }); return; }
if (!instructions.trim()) { setMsg({ kind: 'err', text: 'Enter instructions' }); return; }
setBusy('refine'); setMsg(null);
try {
const data = await api.post<RefineOk>('/api/refine', {
currentDocument: text,
instructions: instructions.trim(),
sourceContext: sourceContext || undefined,
});
onUpdate(data.refined);
setInstructions('');
setMsg({ kind: 'ok', text: 'Refined' });
} catch (e) {
setMsg({ kind: 'err', text: (e as ApiError).message || 'Refine failed' });
} finally { setBusy(null); }
}
async function shorten() {
if (!text.trim()) { setMsg({ kind: 'err', text: 'Nothing to shorten' }); return; }
setBusy('shorten'); setMsg(null);
try {
const data = await api.post<ShortenOk>('/api/shorten', { document: text });
onUpdate(data.shortened);
setMsg({ kind: 'ok', text: 'Shortened' });
} catch (e) {
setMsg({ kind: 'err', text: (e as ApiError).message || 'Shorten failed' });
} finally { setBusy(null); }
}
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
return (
<div className="space-y-2" data-testid={'output-actions-' + exportType}>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={copy} className={btnPrimary} data-testid="out-copy">
📋 Copy
</button>
<button type="button" onClick={read} disabled={busy === 'tts'} className={btn} data-testid="out-read">
{busy === 'tts' ? '⌛ Loading…' : (audioRef.current && !audioRef.current.paused ? '⏹ Stop' : '🔊 Read')}
</button>
<button type="button" onClick={exportToNextcloud} disabled={busy === 'export'} className={btn} data-testid="out-export">
{busy === 'export' ? '⌛ Exporting…' : '☁️ Export to Nextcloud'}
</button>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<textarea
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[60px]"
placeholder="Tell AI how to modify (e.g., 'make it shorter', 'add that patient has asthma history')"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
data-testid="out-refine-input"
/>
<div className="flex sm:flex-col gap-2">
<button type="button" onClick={refine} disabled={busy === 'refine' || !instructions.trim()} className={btnPrimary} data-testid="out-refine">
{busy === 'refine' ? '⌛ Refining…' : '✏️ Refine'}
</button>
<button type="button" onClick={shorten} disabled={busy === 'shorten'} className={btn} data-testid="out-shorten">
{busy === 'shorten' ? '⌛ Shortening…' : '📏 Shorter'}
</button>
</div>
</div>
{msg && (
<div className={'text-xs ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,215 @@
// ============================================================
// Recorder — shared mic-capture component for every note page.
// Faithful React port of public/js/liveEncounter.js (and the
// equivalent dictation/SOAP recording wrappers). Mirror behavior:
// • Mic permission via getUserMedia (mono / 16 kHz / EC+NS)
// • Live preview via Web Speech API (when enabled by user)
// • Pause / Resume native MediaRecorder where supported
// • On Stop → upload blob to /api/transcribe
// • If transcription unavailable, fall back to the live preview text
// • Failed uploads → /api/audio-backups (or IndexedDB) for retry
//
// The recorder is dumb about persistence — the parent note page
// owns the transcript text and uses encounter-persistence.ts to
// save/resume across sign-outs.
// ============================================================
import { useEffect, useRef, useState } from 'react';
import { AudioRecorder } from '@/lib/recorder';
import { transcribeAudio, isTranscribeAvailable, checkTranscribeStatus } from '@/lib/transcribe';
import { createSpeechSession, isSpeechRecognitionEnabled, deduplicateFinal, type SpeechHandle } from '@/lib/web-speech';
interface Props {
module: string; // 'encounter' | 'dictation' | 'soap' | …
// Called when transcription completes. The text replaces (or merges with)
// whatever the parent currently has in the transcript field.
onTranscript: (text: string, meta: { provider?: string; durationSec: number; appended: boolean }) => void;
// Called continuously with the live (interim) preview while recording.
// Parents can show a faded "interim" string concatenated to the
// confirmed transcript for instant visual feedback.
onInterim?: (text: string) => void;
// Called when transcription fails (so the parent can decide what to
// do — typically appending the live preview text instead).
onError?: (msg: string) => void;
disabled?: boolean;
}
const btnRecord = 'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-semibold border transition-colors';
export default function Recorder({ module, onTranscript, onInterim, onError, disabled }: Props) {
const recorderRef = useRef<AudioRecorder | null>(null);
const speechRef = useRef<SpeechHandle | null>(null);
const finalTextRef = useRef('');
const intervalRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const pauseAccumRef = useRef<number>(0);
const pauseStartRef = useRef<number>(0);
const [state, setState] = useState<'idle' | 'recording' | 'paused' | 'transcribing'>('idle');
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (isTranscribeAvailable() === null) checkTranscribeStatus();
}, []);
// Stop everything cleanly on unmount (page navigation while recording).
useEffect(() => {
return () => {
if (intervalRef.current) window.clearInterval(intervalRef.current);
if (speechRef.current) speechRef.current.stop();
if (recorderRef.current) recorderRef.current.stop().catch(() => { /* ignore */ });
};
}, []);
function tickStart() {
if (intervalRef.current) window.clearInterval(intervalRef.current);
intervalRef.current = window.setInterval(() => {
const now = Date.now();
const elapsed = Math.floor((now - startTimeRef.current - pauseAccumRef.current) / 1000);
setSeconds(elapsed);
}, 1000);
}
function tickStop() {
if (intervalRef.current) { window.clearInterval(intervalRef.current); intervalRef.current = null; }
}
async function start() {
if (state !== 'idle') return;
finalTextRef.current = '';
pauseAccumRef.current = 0;
setSeconds(0);
try {
const rec = new AudioRecorder();
await rec.start();
recorderRef.current = rec;
startTimeRef.current = Date.now();
tickStart();
setState('recording');
// Live preview via Web Speech (only if user enabled it in Settings).
if (isSpeechRecognitionEnabled()) {
const handle = createSpeechSession({
onFinal: (chunk) => {
const deduped = deduplicateFinal(chunk, finalTextRef.current);
finalTextRef.current += deduped;
onInterim?.(finalTextRef.current);
},
onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
onError: () => { /* swallow */ },
});
speechRef.current = handle;
handle?.start();
}
} catch {
onError?.('Microphone permission denied');
setState('idle');
}
}
function pause() {
if (state !== 'recording') return;
recorderRef.current?.pause();
speechRef.current?.stop();
pauseStartRef.current = Date.now();
tickStop();
setState('paused');
}
function resume() {
if (state !== 'paused') return;
recorderRef.current?.resume();
pauseAccumRef.current += Date.now() - pauseStartRef.current;
tickStart();
if (isSpeechRecognitionEnabled() && !speechRef.current) {
const handle = createSpeechSession({
onFinal: (chunk) => {
const deduped = deduplicateFinal(chunk, finalTextRef.current);
finalTextRef.current += deduped;
onInterim?.(finalTextRef.current);
},
onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
});
speechRef.current = handle;
}
speechRef.current?.start();
setState('recording');
}
async function stop() {
if (state !== 'recording' && state !== 'paused') return;
const liveText = finalTextRef.current.trim();
speechRef.current?.stop();
speechRef.current = null;
tickStop();
const dur = seconds;
setState('transcribing');
try {
const blob = await recorderRef.current!.stop();
recorderRef.current = null;
if (!blob || blob.size === 0) {
// Recording produced nothing — fall back to live preview if any.
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
setState('idle');
return;
}
// Server-side too-large guard mirrors the vanilla 24 MB cap.
if (blob.size > 24 * 1024 * 1024) {
onError?.('Recording too large for AI transcription — using live transcript');
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
setState('idle');
return;
}
const result = await transcribeAudio(blob, module);
if (result.success && result.text) {
onTranscript(result.text, { provider: result.provider, durationSec: dur, appended: false });
} else if (result.noProvider) {
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
else onError?.('No transcription API configured');
} else {
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
else onError?.(result.error || 'Transcription failed');
}
} catch (e) {
onError?.((e as Error).message);
} finally {
setState('idle');
}
}
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
return (
<div className="flex flex-wrap items-center gap-2" data-testid={'recorder-' + module}>
{state === 'idle' && (
<button type="button" onClick={start} disabled={disabled}
className={btnRecord + ' bg-destructive text-white border-destructive hover:bg-red-700'}
data-testid="recorder-start">
🎙 Start recording
</button>
)}
{(state === 'recording' || state === 'paused') && (
<>
<button type="button" onClick={state === 'recording' ? pause : resume}
className={btnRecord + ' bg-amber-500 text-white border-amber-500 hover:bg-amber-600'}
data-testid="recorder-pause">
{state === 'recording' ? '⏸ Pause' : '▶ Resume'}
</button>
<button type="button" onClick={stop}
className={btnRecord + ' bg-slate-800 text-white border-slate-800 hover:bg-slate-900'}
data-testid="recorder-stop">
Stop
</button>
<span className="inline-flex items-center gap-1.5 text-sm text-destructive font-mono"
data-testid="recorder-timer">
<span className={state === 'recording' ? 'animate-pulse' : 'opacity-50'}></span>
{state === 'paused' ? 'paused ' : ''}{mm}:{ss}
</span>
</>
)}
{state === 'transcribing' && (
<span className="text-sm text-muted-foreground" data-testid="recorder-transcribing">
Transcribing
</span>
)}
</div>
);
}

View file

@ -0,0 +1,212 @@
// ============================================================
// RichTextEditor — Tiptap/ProseMirror editor matching the vanilla
// tp-toolbar feature set from public/js/learningHub.js (@be14578).
//
// Toolbar: bold / italic / underline / strike | H2 / H3 |
// bulletList / orderedList / blockquote / codeBlock |
// link (with URL bar) | clear formatting
//
// Emits HTML on change — the CMS server stores body as HTML, so
// what you type here is what Learning Hub readers see.
//
// Variants:
// default — full toolbar (content body)
// mini — bold/italic/list/link only (short fields)
// option — bold/italic/link only (quiz option text)
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
type Variant = 'default' | 'mini' | 'option';
interface Props {
value: string;
onChange: (html: string) => void;
variant?: Variant;
placeholder?: string;
minHeight?: string; // tailwind arbitrary value, e.g. 'min-h-[280px]'
testId?: string;
}
const btn = 'inline-flex items-center justify-center min-w-[28px] h-7 px-2 rounded text-xs border border-transparent hover:bg-muted';
const btnActive = btn + ' bg-muted border-border';
const sep = 'inline-block w-px h-5 bg-border mx-1 align-middle';
function Tool({ on, active, title, children }: { on: () => void; active: boolean; title: string; children: React.ReactNode }) {
return (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); on(); }}
className={active ? btnActive : btn}
title={title}
>
{children}
</button>
);
}
function LinkBar({ editor, open, setOpen }: { editor: Editor; open: boolean; setOpen: (b: boolean) => void }) {
const [url, setUrl] = useState('');
useEffect(() => {
if (open) setUrl(editor.getAttributes('link').href || '');
}, [open, editor]);
if (!open) return null;
function apply() {
const trimmed = url.trim();
if (trimmed) editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run();
setOpen(false);
}
function remove() {
editor.chain().focus().unsetLink().run();
setOpen(false);
}
return (
<div className="flex items-center gap-1 px-2 py-1 border-t border-border bg-muted/30">
<input
type="url"
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); apply(); }
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
}}
placeholder="https://"
className="flex-1 rounded border border-input bg-background px-2 py-1 text-xs"
/>
<button type="button" onMouseDown={(e) => { e.preventDefault(); apply(); }}
className="rounded bg-primary text-primary-foreground px-2 py-1 text-xs">
Apply
</button>
<button type="button" onMouseDown={(e) => { e.preventDefault(); remove(); }}
className="rounded border border-border px-2 py-1 text-xs">
Remove
</button>
<button type="button" onMouseDown={(e) => { e.preventDefault(); setOpen(false); }}
className="rounded border border-border px-2 py-1 text-xs">
</button>
</div>
);
}
function Toolbar({ editor, variant }: { editor: Editor; variant: Variant }) {
const [linkOpen, setLinkOpen] = useState(false);
const toggleLink = useCallback(() => setLinkOpen((v) => !v), []);
const bold = (
<Tool on={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold"><strong>B</strong></Tool>
);
const italic = (
<Tool on={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic"><em>I</em></Tool>
);
const underline = (
<Tool on={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline"><span style={{ textDecoration: 'underline' }}>U</span></Tool>
);
const strike = (
<Tool on={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strike"><span style={{ textDecoration: 'line-through' }}>S</span></Tool>
);
const h2 = (
<Tool on={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">H2</Tool>
);
const h3 = (
<Tool on={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">H3</Tool>
);
const bullet = (
<Tool on={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list"></Tool>
);
const ordered = (
<Tool on={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">1.</Tool>
);
const quote = (
<Tool on={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote"></Tool>
);
const code = (
<Tool on={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code">{'</>'}</Tool>
);
const link = (
<Tool on={toggleLink} active={editor.isActive('link')} title="Link">🔗</Tool>
);
const clear = (
<Tool on={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} active={false} title="Clear formatting"></Tool>
);
let buttons: React.ReactNode;
if (variant === 'option') {
buttons = <>{bold}{italic}{link}</>;
} else if (variant === 'mini') {
buttons = <>{bold}{italic}{bullet}{link}</>;
} else {
buttons = (
<>
{bold}{italic}{underline}{strike}
<span className={sep} />
{h2}{h3}
<span className={sep} />
{bullet}{ordered}{quote}{code}
<span className={sep} />
{link}
<span className={sep} />
{clear}
</>
);
}
return (
<>
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1 bg-muted/40 border-b border-border">
{buttons}
</div>
<LinkBar editor={editor} open={linkOpen} setOpen={setLinkOpen} />
</>
);
}
export default function RichTextEditor({
value, onChange, variant = 'default', placeholder, minHeight = 'min-h-[240px]', testId,
}: Props) {
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Underline,
Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' } }),
],
content: value,
onUpdate: ({ editor: ed }) => onChange(ed.getHTML()),
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none dark:prose-invert px-3 py-2 ' + minHeight + ' focus:outline-none',
'data-placeholder': placeholder || '',
},
},
});
// Sync external value changes back into the editor (e.g. when the user
// switches to a different CMS item — parent updates `value`).
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
if (value !== current) editor.commands.setContent(value || '', { emitUpdate: false });
}, [value, editor]);
if (!editor) return null;
return (
<div
className="rounded-md border border-input bg-background overflow-hidden"
data-testid={testId}
>
<Toolbar editor={editor} variant={variant} />
<EditorContent editor={editor} />
</div>
);
}

View file

@ -0,0 +1,97 @@
// ============================================================
// RosPeTable — structured ROS or PE input. Per-system row with
// WNL / Abnormal / Not reviewed toggle + a note field that reveals
// when "Abnormal" is selected. Mirrors window.renderRosRows /
// wireRosContainer from public/js/shadess.js (@be14578).
//
// Consumers (WellVisit Note, Sick Visit Note) pass systems list +
// data object + label set so the same component renders both
// review-of-systems and physical-exam tables.
// ============================================================
import type { RosData, RosStatus, SystemEntry } from '@shared/clinical/ros-pe-dx';
interface BtnLabels { wnl: string; abnormal: string; notrev: string }
interface Props {
systems: ReadonlyArray<SystemEntry>;
data: RosData;
onChange: (next: RosData) => void;
btnLabels?: BtnLabels;
testIdPrefix?: string;
}
export function rosAllWnl(systems: ReadonlyArray<SystemEntry>, data: RosData): RosData {
const next: RosData = { ...data };
for (const s of systems) next[s.key] = { status: 'wnl', note: next[s.key]?.note };
return next;
}
export function rosClear(data: RosData, systems: ReadonlyArray<SystemEntry>): RosData {
const next: RosData = { ...data };
for (const s of systems) next[s.key] = {};
return next;
}
const DEFAULT_LABELS: BtnLabels = { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' };
function statusClass(active: boolean, kind: RosStatus) {
if (!active) return 'bg-background border-border hover:bg-muted';
if (kind === 'wnl') return 'bg-green-100 text-green-800 border-green-300';
if (kind === 'abnormal') return 'bg-red-100 text-red-800 border-red-300';
return 'bg-muted text-muted-foreground border-border';
}
export default function RosPeTable({
systems, data, onChange, btnLabels, testIdPrefix = 'ros',
}: Props) {
const labels = btnLabels || DEFAULT_LABELS;
function setStatus(key: string, status: RosStatus) {
const cur = data[key]?.status || '';
const nextStatus: RosStatus = cur === status ? '' : status;
onChange({ ...data, [key]: { status: nextStatus, note: data[key]?.note || '' } });
}
function setNote(key: string, note: string) {
onChange({ ...data, [key]: { status: data[key]?.status || '', note } });
}
return (
<div className="divide-y divide-border" data-testid={testIdPrefix + '-table'}>
{systems.map((sys) => {
const cell = data[sys.key] || {};
const active = cell.status || '';
return (
<div key={sys.key} className="flex flex-wrap items-center gap-2 px-2 py-1.5" data-testid={testIdPrefix + '-row-' + sys.key}>
<div className="flex-1 min-w-[180px]" title={sys.detail}>
<span className="text-sm font-medium">{sys.label}</span>
<span className="ml-1 text-[10px] text-muted-foreground">({sys.detail})</span>
</div>
<div className="flex gap-1 shrink-0">
{(['wnl', 'abnormal', 'notrev'] as const).map((kind) => (
<button
key={kind}
type="button"
onClick={() => setStatus(sys.key, kind)}
className={'text-[10px] uppercase tracking-wider px-2 py-1 rounded border ' + statusClass(active === kind, kind)}
data-testid={testIdPrefix + '-btn-' + sys.key + '-' + kind}
>
{labels[kind]}
</button>
))}
</div>
{active === 'abnormal' && (
<input
type="text"
value={cell.note || ''}
onChange={(e) => setNote(sys.key, e.target.value)}
placeholder="Describe finding…"
className="flex-1 min-w-[200px] rounded-md border border-input bg-background px-2 py-1 text-xs"
data-testid={testIdPrefix + '-note-' + sys.key}
/>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,83 @@
// ============================================================
// Turnstile widget wrapper. Loads the cloudflare challenges script
// once, renders a widget when mounted, exposes the resolved token
// via onToken. If `siteKey` is null/empty (e.g. e2e container with
// TURNSTILE_SITE_KEY=""), renders nothing and auto-reports an empty
// token so the surrounding form can submit unchanged — this mirrors
// the vanilla behaviour where an unset site key is a no-op.
// ============================================================
import { useEffect, useRef } from 'react';
declare global {
interface Window {
turnstile?: {
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
onTurnstileLoad?: () => void;
}
}
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad';
let loadedOrLoading = false;
const readyCallbacks: Array<() => void> = [];
function ensureScript(): Promise<void> {
return new Promise((resolve) => {
if (typeof window === 'undefined') { resolve(); return; }
if (window.turnstile) { resolve(); return; }
readyCallbacks.push(resolve);
if (loadedOrLoading) return;
loadedOrLoading = true;
window.onTurnstileLoad = () => {
for (const cb of readyCallbacks.splice(0)) cb();
};
const s = document.createElement('script');
s.src = SCRIPT_SRC;
s.async = true;
s.defer = true;
document.head.appendChild(s);
});
}
interface Props {
siteKey: string | null | undefined;
onToken: (token: string) => void;
action?: string;
theme?: 'light' | 'dark' | 'auto';
}
export default function Turnstile({ siteKey, onToken, action, theme = 'light' }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() => {
if (!siteKey) { onToken(''); return; }
let cancelled = false;
ensureScript().then(() => {
if (cancelled || !containerRef.current || !window.turnstile) return;
try {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme,
action,
callback: (token: string) => onToken(token),
'error-callback': () => onToken(''),
'expired-callback': () => onToken(''),
});
} catch { /* ignore render errors — e.g. repeated mount */ }
});
return () => {
cancelled = true;
if (widgetIdRef.current && window.turnstile) {
try { window.turnstile.remove(widgetIdRef.current); } catch { /* ignore */ }
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [siteKey]);
if (!siteKey) return null;
return <div ref={containerRef} className="cf-turnstile my-2" />;
}

150
client/src/data/faq.ts Normal file
View file

@ -0,0 +1,150 @@
export const FAQ_DATA = [
{
"section": "Getting Started",
"items": [
{
"q": "What is Pediatric AI Scribe?",
"a": "Pediatric AI Scribe is an AI-powered clinical documentation tool designed specifically for pediatric medicine. It helps physicians generate structured clinical notes from voice recordings or typed text, saving time on documentation so you can focus on patient care. It supports HPIs, SOAP notes, hospital courses, chart reviews, well visits, sick visits, developmental milestone assessments, and more."
},
{
"q": "How do I create my first note?",
"a": "The easiest way to start is with Live Encounter: Go to the Encounter tab Enter the patient's age and gender Click Start Recording and speak naturally during your patient encounter Click Stop when done &mdash; the audio is transcribed automatically Click Generate HPI to create a structured note Edit the note as needed, then Copy to paste into your EHR"
},
{
"q": "Can I type or paste notes instead of recording?",
"a": "Yes. Every transcript box is editable. You can type directly, paste from another source, or combine typed text with a recording. The AI works with whatever text is in the transcript area when you click Generate."
},
{
"q": "What types of notes can I generate?",
"a": "HPI &mdash; from live encounters or dictation, with OLDCARTS structure SOAP Notes &mdash; full SOAP or subjective-only from dictation Hospital Course &mdash; from progress notes, in prose, day-by-day, or organ-system format Chart Review &mdash; summarize outpatient, subspecialty, or ED visits for precharting Well Visit &mdash; complete preventive care notes with SSHADESS, milestones, and vaccines Sick Visit &mdash; quick documentation with auto-suggested ROS and PE Milestone Assessment &mdash; developmental narrative from selected milestones"
}
]
},
{
"section": "AI & Models",
"items": [
{
"q": "What AI model should I use?",
"a": "Each tab has a model selector dropdown. All available models have been tested and configured by your administrator for clinical documentation quality. They are routed through HIPAA-compliant providers with signed Business Associate Agreements (BAAs). All models are capable of generating accurate clinical notes. If you are unsure which to pick, start with the default. You can experiment with different models and see which output style you prefer &mdash; some may be faster, some more detailed, some more concise. You can choose a different model per tab depending on the task."
},
{
"q": "Does the AI learn from my edits?",
"a": "Yes. The app uses a correction tracking system inspired by Dragon Medical's adaptive learning. Here is how it works: When the AI generates a note, the original output is stored in memory You edit the note to match your preferred style &mdash; fix phrasing, add details, restructure sections When you click Save, the app detects what you changed and stores the correction On future notes, your past corrections are included as style hints so the AI adapts to your documentation preferences The more you use the app and save your edits, the better the AI gets at matching your style. You can view and manage your stored corrections in Settings &gt; AI Corrections. Note: Corrections are applied as gentle suggestions, not strict rules. The AI prioritizes clinical accuracy over style matching."
},
{
"q": "Can I customize the AI's prompts?",
"a": "Administrators can edit all AI prompts from the Admin Panel &gt; Settings &gt; Prompts section. This lets you adjust the instructions the AI follows for each note type without changing any code. Changes take effect immediately."
},
{
"q": "What does the \"Refine\" button do?",
"a": "After generating a note, you can give the AI plain-language instructions to modify it. For example: \"Make it shorter\" \"Add that the patient has a history of asthma\" \"Summarize the labs\" \"Change the assessment to include bronchiolitis\" The AI references both its current output and your original source material (transcript, pasted notes, labs) when refining, so it can look up details from the original input."
}
]
},
{
"section": "Voice & Transcription",
"items": [
{
"q": "How does voice transcription work?",
"a": "When you stop recording, the audio is sent to a speech-to-text service that converts it to text. The app supports multiple transcription providers including Whisper, Deepgram, and Google Gemini. Your administrator configures which provider is used. You will see a blue status bar at the top while transcription is in progress. You can continue working on the page while it processes."
},
{
"q": "What is Browser Whisper?",
"a": "Browser Whisper runs the Whisper AI model entirely in your browser using WebAssembly. Your audio never leaves your device, making it the most private transcription option. You can enable it in Settings &gt; Browser Whisper. It works offline and is HIPAA-safe since no data is transmitted. The tradeoff is that it is slower than cloud-based transcription and requires downloading the model (~40&ndash;240 MB) on first use."
},
{
"q": "Can I use the app on my phone?",
"a": "Yes. The app is a Progressive Web App (PWA) that works in any modern browser. On mobile: Open the app in Chrome or Safari Tap \"Add to Home Screen\" to install it as a standalone app Recording works in the foreground, but audio stops if you lock the screen or switch apps on iOS and most Android devices &mdash; this is a browser limitation, not specific to this app On desktop, recording continues normally when the browser is minimized or in the background."
},
{
"q": "What happens if transcription fails?",
"a": "If transcription fails, your audio is automatically backed up to the server for 24 hours. You can retry transcription from Settings &gt; Audio Backups. If browser speech recognition was active during recording, the live transcript is preserved as a fallback."
},
{
"q": "Can the AI read my notes aloud?",
"a": "Yes. Click the Read button on any generated note to hear it spoken aloud. This uses text-to-speech (TTS) powered by Google, OpenAI, or ElevenLabs depending on your setup. You can choose your preferred voice in Settings &gt; Voice Preferences."
}
]
},
{
"section": "Saving & Export",
"items": [
{
"q": "Are my encounters saved?",
"a": "You can save encounters using the Save button at the top of each tab. Saved encounters include the transcript, generated note, and patient label. You can reload them later using the Load button. Saved encounters are automatically deleted after 7 days (configurable by your administrator). This is intentional &mdash; the app is a documentation tool, not a medical record system. Copy your final notes to your EHR for permanent storage."
},
{
"q": "How do I export notes?",
"a": "Copy &mdash; one-click copy to clipboard, ready to paste into any EHR Nextcloud &mdash; export directly to your Nextcloud instance (configure in Settings) Documents &mdash; upload files to S3-compatible storage from Settings All generated text is plain text with no markdown formatting, designed to paste cleanly into any EHR system."
}
]
},
{
"section": "Privacy & Security",
"items": [
{
"q": "Is my patient data safe?",
"a": "The app is designed with clinical privacy in mind: All connections use HTTPS/TLS encryption Audio and encounter data are temporary &mdash; auto-deleted within hours or days No patient data is stored long-term on the server Every action is audit-logged (who accessed what, when) Two-factor authentication (2FA) and session management are available Browser Whisper keeps audio entirely on your device For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI)."
},
{
"q": "What is two-factor authentication (2FA)?",
"a": "2FA adds an extra layer of security to your account. After entering your password, you also enter a 6-digit code from an authenticator app (like Google Authenticator or Authy). Enable it in Settings &gt; Two-Factor Authentication."
},
{
"q": "How do I manage my active sessions?",
"a": "Go to Settings &gt; Active Sessions to see all devices where you are logged in. You can revoke any session individually or click Revoke All Other Sessions to log out every other device. Your current session is highlighted and cannot be revoked from this screen &mdash; use the Logout button instead."
},
{
"q": "What happens when I change my password?",
"a": "When you change your password in Settings &gt; Change Password, all other active sessions are automatically logged out for security. Only your current session remains active. The app also checks if your new password has appeared in known data breaches and warns you (but does not prevent you from using it)."
}
]
},
{
"section": "Well Visit & Sick Visit",
"items": [
{
"q": "How does the Well Visit tab work?",
"a": "The Well Visit tab follows the AAP Bright Futures periodicity schedule. It includes: Visit by Age &mdash; recommended screenings, vaccines, and anticipatory guidance for each visit age Milestones &mdash; developmental milestone tracker from birth through 11 years across multiple domains SSHADESS &mdash; adolescent psychosocial assessment (Strengths, School, Home, Activities, Drugs, Emotions, Sexuality, Safety) for ages 12+ Visit Note &mdash; generates a complete well visit note combining ROS, PE, milestones, and SSHADESS data"
},
{
"q": "What do the WNL / Abnormal / Not Reviewed buttons do?",
"a": "In the ROS and Physical Exam sections, each system has three options: WNL / Normal &mdash; within normal limits, no concerns Abnormal &mdash; a text box appears so you can describe the finding Not Reviewed / Not Examined &mdash; explicitly not assessed Use All WNL to quickly mark everything normal, then click individual systems to change specific ones. Use Clear to reset all selections."
}
]
},
{
"section": "Learning Hub",
"items": [
{
"q": "What is the Learning Hub?",
"a": "The Learning Hub is an educational platform integrated into the app. It contains articles, clinical pearls, quizzes, and slide presentations created by moderators and administrators. You can browse by category, search content, and take quizzes to test your knowledge."
},
{
"q": "How do quizzes work?",
"a": "Quizzes include multiple-choice, multi-select, and true/false questions. After submitting your answers, you see your score along with explanations for each question. Your past attempts and scores are tracked so you can monitor your progress over time."
},
{
"q": "Can I create Learning Hub content?",
"a": "Moderators and administrators can create content using the CMS tab. You can write articles manually, or use AI to generate content from a topic description, uploaded PDFs, or files from Nextcloud. The CMS also supports Marp-based slide presentations with PPTX export."
}
]
},
{
"section": "Pediatric Calculators",
"items": [
{
"q": "What calculators are available?",
"a": "The Calculators tab includes clinical tools commonly used in pediatric practice: Blood Pressure Percentile &mdash; AAP 2017 guidelines using the Rosner quantile spline regression method. Requires age, sex, height, and BP. Provides exact systolic and diastolic percentiles adjusted for height, with AAP classification (Normal, Elevated, Stage 1, Stage 2). Includes definitions of hypertension and hypotension. BMI Percentile &mdash; CDC 2000 growth reference with extended obesity classification (Class 1, 2, 3 using % of 95th percentile). Shows BMI chart with percentile curves. Growth Charts &mdash; Visual percentile curves (3rd through 97th) with your patient plotted. Includes Weight-for-Age, Length/Height-for-Age (with mid-parental height), Head Circumference, Weight-for-Length, and Fenton preterm charts. Bilirubin &mdash; AAP 2022 phototherapy threshold calculator and Bhutani hour-specific nomogram with risk zone classification. Includes Nelson Table 137.1 risk factors. Vital Signs by Age &mdash; Harriet Lane reference table for HR, RR, BP, and weight by age from preterm through 18 years. Includes quick formulas for estimated weight, minimum SBP, ETT size, and maintenance fluids. Body Surface Area &mdash; Mosteller formula for BSA calculation. Weight-Based Dosing &mdash; Dose per kg with frequency, max dose cap, and volume calculation from concentration."
},
{
"q": "How accurate is the BP calculator?",
"a": "The BP calculator uses the same Rosner quantile spline regression method as the Baylor College of Medicine reference calculator. It computes exact percentiles (1st-99th) based on your patient's age, sex, and height using published regression coefficients. This is the same methodology underlying the AAP 2017 normative tables. Results are height-adjusted and clinically accurate."
},
{
"q": "What are the growth chart curves?",
"a": "The growth charts display WHO/CDC percentile curves (3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th percentiles) with your patient's measurement plotted as a blue dot. The 50th percentile is shown as a bold green line. Shaded bands show the normal range between symmetric percentiles. For Length/Height-for-Age, you can optionally enter both parents' heights to see the mid-parental target height range plotted on the chart."
}
]
}
];

View file

@ -0,0 +1,90 @@
// ============================================================
// PE_DATA parity counts — catches the class of bug where an LLM
// silently drops entries from a long clinical array during a port.
// Numbers captured 2026-04-24 against public/js/peGuide.js commit
// 313ba7f. If the vanilla source changes, update both files in the
// same commit.
// ============================================================
import { describe, expect, it } from 'vitest';
import { PE_DATA, AGE_GROUP_ORDER, SYSTEM_ORDER } from './pe-data';
describe('PE_DATA shape', () => {
it('has the six expected age groups', () => {
expect(Object.keys(PE_DATA).sort()).toEqual([...AGE_GROUP_ORDER].sort());
});
it.each([...AGE_GROUP_ORDER])('%s has all four systems', (age) => {
const group = PE_DATA[age];
for (const s of SYSTEM_ORDER) {
expect(group[s]).toBeDefined();
expect(group[s].overview.length).toBeGreaterThan(0);
expect(Array.isArray(group[s].components)).toBe(true);
expect(group[s].components.length).toBeGreaterThan(0);
}
});
it('component + abnormalHints + pearl + significance counts match vanilla', () => {
let componentCount = 0;
let pearlCount = 0;
let significanceCount = 0;
for (const age of AGE_GROUP_ORDER) {
for (const sys of SYSTEM_ORDER) {
const comps = PE_DATA[age][sys].components;
for (const c of comps) {
componentCount++;
if (c.pearl) pearlCount++;
if (c.significance) significanceCount++;
expect(Array.isArray(c.steps)).toBe(true);
expect(c.steps.length).toBeGreaterThan(0);
expect(Array.isArray(c.abnormalHints)).toBe(true);
}
}
}
// Locked against vanilla peGuide.js (2026-04-24):
// 103 components, 27 pearl, 23 significance.
expect(componentCount).toBe(103);
expect(pearlCount).toBe(27);
expect(significanceCount).toBe(23);
});
});
// Per-age-group × per-system component counts — captured from the
// legacy file with:
// awk 'NR>=316 && NR<=1334' public/js/peGuide.js |
// awk '/^ [a-z]+: \{$/{age=$1} /^ [a-z]+: \{$/{sys=$1}
// /^ { name:/{c[age" "sys]++} END{for(k in c) print k" "c[k]}' | sort
// so any drift in the TS port surfaces as a failing test here.
describe('PE_DATA per-cell component counts', () => {
const EXPECTED: Record<string, number> = {
'newborn msk': 6,
'newborn neuro': 6,
'newborn resp': 2,
'newborn cv': 2,
'infant msk': 4,
'infant neuro': 5,
'infant resp': 2,
'infant cv': 2,
'toddler msk': 5,
'toddler neuro': 7,
'toddler resp': 2,
'toddler cv': 2,
'preschool msk': 5,
'preschool neuro': 7,
'preschool resp': 2,
'preschool cv': 2,
'school msk': 5,
'school neuro': 7,
'school resp': 3,
'school cv': 2,
'adolescent msk': 6,
'adolescent neuro': 8,
'adolescent resp': 6,
'adolescent cv': 5,
};
it.each(Object.entries(EXPECTED))('%s matches', (key, expected) => {
const [age, sys] = key.split(' ') as ['newborn', 'msk'];
expect(PE_DATA[age][sys].components.length).toBe(expected);
});
});

1082
client/src/data/pe-data.ts Normal file

File diff suppressed because it is too large Load diff

290
client/src/data/pe-guide.ts Normal file
View file

@ -0,0 +1,290 @@
// ============================================================
// PE-GUIDE DATA — ported verbatim from public/js/peGuide.js
// (lines 23-311 of the vanilla file, as of commit before this one).
//
// This file ONLY contains the stable reference data:
// • SCALES — grading scales (MRC, DTR, Levine, Beighton, …)
// • SYSTEM_SCALES — which scales belong to which body system
// • APTM_LEGEND — the 5 cardiac auscultation points
// • INNOCENT_MURMURS — benign childhood murmurs
// • RESP_SOUNDS — respiratory sounds library (audio paths)
// • CARDIAC_SOUNDS — cardiac sounds library (audio paths)
//
// PE_DATA (the full age-group × system × component × step hierarchy,
// ~1000 lines) is intentionally NOT ported here. It holds clinically
// reviewed content and the migration checkpoint explicitly warns
// "An LLM will sometimes 'simplify' a long array — don't let that
// happen." PE_DATA port belongs in its own dedicated session with
// per-entry counts + visual diff verification against the vanilla
// source. Until that session, the React PE Guide surfaces the
// reference libraries below and links to the legacy viewer for
// exam-step checklists and narrative generation.
//
// Audio files stay in public/audio/respiratory/ and public/audio/cardiac/
// and are served unchanged from Express.
// ============================================================
export interface ScaleDef {
title: string;
icon: string;
rows: Array<[string, string]>;
}
export const SCALES: Record<string, ScaleDef> = {
mrc: {
title: 'MRC strength grade (05)',
icon: 'fa-hand-fist',
rows: [
['5', 'Normal power — holds against full resistance'],
['4', 'Reduced — moves against gravity + some resistance'],
['3', 'Moves against gravity only (no added resistance)'],
['2', 'Full range with gravity eliminated (horizontal plane)'],
['1', 'Flicker / trace contraction, no joint movement'],
['0', 'No contraction'],
],
},
dtr: {
title: 'Deep-tendon reflex grade (04+)',
icon: 'fa-circle-dot',
rows: [
['0', 'Absent'],
['1+', 'Hypoactive — trace, only with reinforcement'],
['2+', 'Normal'],
['3+', 'Brisk — may still be normal in anxious patients'],
['4+', 'Hyperactive with sustained clonus — always abnormal'],
],
},
plantar: {
title: 'Plantar response (Babinski)',
icon: 'fa-shoe-prints',
rows: [
['Down-going', 'Normal in anyone ≥ 2 years'],
['Up-going', 'Normal < 2 years; abnormal after — UMN lesion'],
['Asymmetric', 'Always abnormal at any age'],
],
},
beighton: {
title: 'Beighton hypermobility score (09)',
icon: 'fa-hands',
rows: [
['≤ 3', 'Normal flexibility'],
['4', 'Borderline — consider in context'],
['≥ 5', 'Hypermobility spectrum; screen for hEDS if other features present'],
],
},
atr: {
title: 'Scoliometer — angle of trunk rotation',
icon: 'fa-ruler',
rows: [
['< 5°', 'Normal, no follow-up'],
['56°', 'Borderline — re-check at each visit'],
['≥ 7°', 'Refer for PA/lateral spine x-ray + orthopedic evaluation'],
],
},
rr: {
title: 'Respiratory rate — upper limit by age (awake)',
icon: 'fa-lungs',
rows: [
['Newborn', '≤ 60 /min'],
['< 2 months', '≤ 60 /min (WHO tachypnea cutoff)'],
['212 months', '≤ 50 /min (WHO tachypnea cutoff)'],
['15 years', '≤ 40 /min (WHO tachypnea cutoff)'],
['611 years', '≤ 30 /min'],
['≥ 12 years', '≤ 20 /min (adult pattern)'],
],
},
spo2: {
title: 'Pulse oximetry (SpO₂) — at room air',
icon: 'fa-heart-pulse',
rows: [
['≥ 95%', 'Normal'],
['9294%', 'Mild hypoxemia — investigate cause'],
['< 92%', 'Moderate hypoxemia — supplemental O₂'],
['< 88%', 'Severe — urgent intervention; target ≥ 90% acutely'],
],
},
silverman: {
title: 'SilvermanAndersen retraction score (neonatal, 010)',
icon: 'fa-baby',
rows: [
['0', 'No respiratory distress'],
['13', 'Mild — close observation'],
['46', 'Moderate distress — consider CPAP / support'],
['710', 'Severe — imminent respiratory failure, intubate'],
],
},
westley: {
title: 'Westley croup severity score',
icon: 'fa-stethoscope',
rows: [
['≤ 2', 'Mild — home management, cool mist, oral dexamethasone'],
['35', 'Moderate — nebulised epinephrine + dexamethasone'],
['611', 'Severe — admit, continuous monitoring'],
['≥ 12', 'Impending respiratory failure — ICU / airway management'],
],
},
murmurGrade: {
title: 'Heart-murmur grading (Levine 16)',
icon: 'fa-wave-square',
rows: [
['1/6', 'Very faint — heard only with concentration'],
['2/6', 'Soft but readily heard'],
['3/6', 'Moderately loud, no thrill'],
['4/6', 'Loud WITH a palpable thrill'],
['5/6', 'Very loud; audible with stethoscope just off the chest'],
['6/6', 'Audible without the stethoscope touching the chest'],
],
},
pulseAmp: {
title: 'Pulse amplitude grade (04)',
icon: 'fa-heart-pulse',
rows: [
['0', 'Absent'],
['1+', 'Diminished, thready'],
['2+', 'Normal'],
['3+', 'Bounding'],
['4+', 'Bounding with visible pulsation (e.g., aortic regurgitation)'],
],
},
capRefill: {
title: 'Capillary refill time',
icon: 'fa-hand',
rows: [
['< 2 sec', 'Normal'],
['23 sec', 'Borderline — consider hydration / perfusion'],
['≥ 3 sec', 'Delayed — dehydration, shock, low cardiac output'],
],
},
};
export const SYSTEM_SCALES: Record<string, string[]> = {
msk: ['atr', 'beighton'],
neuro: ['mrc', 'dtr', 'plantar'],
resp: ['rr', 'spo2', 'silverman', 'westley'],
cv: ['murmurGrade', 'pulseAmp', 'capRefill'],
};
// APTM — the 5 classic cardiac auscultation points
export interface AptmEntry {
letter: string;
color: string;
title: string;
location: string;
listen: string;
innocent?: string;
}
export const APTM_LEGEND: AptmEntry[] = [
{ letter: 'A', color: '#dc2626', title: 'Aortic area', location: '2nd ICS, right sternal border', listen: 'S2 (aortic component), aortic stenosis, aortic regurgitation' },
{ letter: 'P', color: '#2563eb', title: 'Pulmonic area', location: '2nd ICS, left sternal border', listen: 'S2 (pulmonic component), pulmonic stenosis, PDA, physiologic split of S2',
innocent: 'Pulmonary flow murmur (children, adolescents) — upper left sternal border' },
{ letter: 'E', color: '#059669', title: 'Erb\'s point', location: '3rd ICS, left sternal border', listen: 'Aortic regurgitation (best here), transitional zone murmurs',
innocent: 'Still\'s murmur classically radiates to Erb\'s / LLSB' },
{ letter: 'T', color: '#d97706', title: 'Tricuspid area', location: '4th5th ICS, lower left sternal border', listen: 'Tricuspid regurgitation, VSD, S3/S4, holosystolic murmurs',
innocent: 'Still\'s murmur — vibratory, musical, age 37 y (loudest between LLSB and apex)' },
{ letter: 'M', color: '#7c3aed', title: 'Mitral area (apex)', location: '5th ICS, mid-clavicular line', listen: 'S1, mitral regurgitation, mitral stenosis (with bell, left-lateral decubitus)' },
];
// Innocent (benign) childhood murmurs
export interface InnocentMurmur {
name: string;
age: string;
location: string;
character: string;
confirm: string;
}
export const INNOCENT_MURMURS: InnocentMurmur[] = [
{ name: 'Still\'s (vibratory) murmur',
age: '37 y (most common in children)',
location: 'LLSB, radiating to apex',
character: 'Low-frequency vibratory / musical systolic, grade 23/6, mid-systolic, "twanging-string" quality',
confirm: 'Louder supine, softer or disappears on standing or Valsalva. No radiation to neck/back. Normal S2.' },
{ name: 'Pulmonary flow murmur',
age: 'School-age and adolescents, thin chest',
location: 'Upper left sternal border (2nd3rd ICS)',
character: 'Soft blowing early systolic ejection, grade 12/6, higher-pitched',
confirm: 'No ejection click. Physiologic split of S2. Louder supine, softer on standing. No radiation.' },
{ name: 'Venous hum',
age: 'Ages 38, disappears by adolescence',
location: 'Supraclavicular or infraclavicular area, usually right',
character: 'Soft continuous hum, louder in diastole. Only innocent continuous murmur.',
confirm: 'Disappears when supine OR when jugular vein is gently compressed (key maneuver). Turning head to opposite side also alters it.' },
{ name: 'Carotid bruit / supraclavicular bruit',
age: 'Children and adolescents',
location: 'Supraclavicular fossa, right > left; may radiate to carotid',
character: 'Brief early systolic, grade 23/6, higher-pitched than Still\'s',
confirm: 'Softer or disappears with hyperextension of the shoulders. Normal cardiac exam otherwise. No radiation below the clavicles.' },
{ name: 'Peripheral pulmonary stenosis (PPS, neonatal)',
age: 'Newborns and infants < 612 months',
location: 'Upper LSB, radiates to BOTH axillae and the back',
character: 'Soft systolic ejection murmur, grade 12/6',
confirm: 'Typical age + radiation to back/axillae. Resolves by age 1 as branch pulmonary arteries grow. Persistence or louder grade warrants echo.' },
];
// Respiratory sounds library — real recordings served from /public/audio/respiratory/
export interface SoundEntry {
key: string;
src: string;
title: string;
where: string;
rate?: string;
features: string;
clinical: string;
}
export const RESP_SOUNDS: SoundEntry[] = [
{ key: 'normal', src: '/audio/respiratory/normal-vesicular.ogg', title: 'Normal vesicular breath sounds',
where: 'Peripheral lung fields',
features: 'Soft, rustling. Inspiration louder and longer than expiration.',
clinical: 'Baseline — deviation elsewhere is what you listen for.' },
{ key: 'wheeze', src: '/audio/respiratory/wheeze.ogg', title: 'Wheeze',
where: 'Diffuse in asthma; localised in foreign body',
features: 'Continuous, high-pitched, musical. Usually expiratory; biphasic if severe.',
clinical: 'Lower-airway narrowing — asthma, bronchiolitis, foreign body, bronchomalacia. Silent chest in severe asthma is an ominous sign.' },
{ key: 'stridor', src: '/audio/respiratory/stridor.ogg', title: 'Stridor',
where: 'Louder over neck than chest — upper airway',
features: 'Continuous, high-pitched, harsh. Classically inspiratory (extrathoracic obstruction); biphasic if fixed.',
clinical: 'Croup, epiglottitis, foreign body, laryngomalacia (infant). Distinguish from wheeze by auscultating the neck — stridor is loudest there.' },
{ key: 'finecrackles', src: '/audio/respiratory/crackles-fine.ogg', title: 'Fine (end-inspiratory) crackles',
where: 'Bibasilar in pulmonary edema/fibrosis; focal in pneumonia',
features: 'Discontinuous, brief, high-pitched. "Velcro" quality. Late inspiratory, do NOT clear with cough.',
clinical: 'Alveolar opening — pulmonary fibrosis, pulmonary edema, early pneumonia, atelectasis.' },
{ key: 'coarsecrackles', src: '/audio/respiratory/crackles-coarse.ogg', title: 'Coarse crackles',
where: 'Lower lobes; either side',
features: 'Discontinuous, longer and louder than fine crackles. Lower-pitched. Can be early or late inspiratory; often clear partly with cough.',
clinical: 'Secretions in larger airways — bronchitis, later pneumonia, bronchiectasis, aspiration.' },
{ key: 'rhonchi', src: '/audio/respiratory/rhonchi.ogg', title: 'Rhonchi',
where: 'Central or anywhere with airway secretions',
features: 'Continuous, low-pitched, snore-like. Typically expiratory. Clear or change with cough.',
clinical: 'Large-airway secretions — bronchitis, pneumonia with large-airway involvement, cystic fibrosis, bronchiectasis.' },
{ key: 'pleuralrub', src: '/audio/respiratory/pleural-rub.ogg', title: 'Pleural friction rub',
where: 'Focal, often lateral or posterior lower chest',
features: 'Grating, creaky — "leather on leather". Biphasic (heard in inspiration and expiration). Does NOT clear with cough.',
clinical: 'Pleural inflammation — pleuritis, pulmonary embolism, pneumonia with pleural involvement, viral pleurisy.' },
];
// Cardiac sounds library — real recordings from Wikimedia Commons
export const CARDIAC_SOUNDS: SoundEntry[] = [
{ key: 'normal', src: '/audio/cardiac/normal.ogg', title: 'Normal heart sounds (S1, S2)',
where: 'All four classic auscultation points', rate: '~61 bpm reference',
features: '"lub-dub": S1 (closure of mitral + tricuspid) louder at apex; S2 (closure of aortic + pulmonic) louder at base. Physiologic S2 split on inspiration.',
clinical: 'Reference for rhythm, rate, and the normal S1S2 interval. Listen for what\'s changed — not just what\'s added.' },
{ key: 'infant-normal', src: '/audio/cardiac/infant-normal.ogg', title: 'Infant normal heart sounds',
where: 'Infant chest — rate will be higher than adult', rate: 'Pediatric reference (120160 bpm range)',
features: 'Same S1S2 pattern, faster rate. Short diastole makes murmurs easier to miss — careful auscultation needed.',
clinical: 'Reference for neonatal/infant rhythm. Any murmur in the first 72 h should prompt pre/postductal sat screening.' },
{ key: 'vsd', src: '/audio/cardiac/vsd.wav', title: 'Ventricular septal defect (VSD)',
where: 'Lower left sternal border (4th ICS)',
features: 'Harsh, blowing, holosystolic (pansystolic) murmur — plateau shape through all of systole. Often accompanied by a thrill if large.',
clinical: 'Most common congenital heart defect. Small VSD: loud murmur, usually asymptomatic, may close spontaneously. Large VSD: softer murmur (less pressure gradient) but signs of heart failure, pulmonary hypertension.' },
{ key: 'mvp', src: '/audio/cardiac/mitral-prolapse.wav', title: 'Mitral valve prolapse (MVP) — click + late systolic murmur',
where: 'Apex (5th ICS, mid-clavicular line)',
features: 'Mid-systolic click followed by a late-systolic crescendo murmur. Timing of click changes with maneuvers: earlier with standing or Valsalva, later with squatting.',
clinical: 'Often benign, especially in thin young women. Features suggesting need for echo: thickened/redundant leaflets, associated MR, symptoms (palpitations, chest pain), arrhythmias.' },
{ key: 'stills', src: '/audio/cardiac/stills-murmur.ogg', title: 'Still\'s murmur (innocent)',
where: 'LLSB, radiating to apex', rate: 'Classic age 37 y (this recording is a toddler)',
features: 'Low-frequency vibratory / musical systolic, grade 23/6, mid-systolic, "twanging-string" quality.',
clinical: 'The most common innocent murmur of childhood. Louder supine, softer or disappears on standing or Valsalva. Normal S2. No radiation to neck or back. No workup needed when classic.' },
{ key: 'functional', src: '/audio/cardiac/functional-murmur.wav', title: 'Functional (innocent) murmur — adult female',
where: 'Left sternal border, soft systolic',
features: 'Soft systolic murmur in a structurally normal heart — often from increased cardiac output, thin chest wall, anemia, hyperthyroidism, or pregnancy.',
clinical: 'Benign if it meets the 7 S criteria. Investigate if loud (≥3/6), holosystolic, diastolic, radiating, or with thrill / symptoms.' },
];

44
client/src/index.css Normal file
View file

@ -0,0 +1,44 @@
@import "tailwindcss";
/* Tailwind v4 uses @theme to declare custom color tokens that then
expose the matching utility classes (bg-background, text-foreground,
border-border, etc.). Values tuned to the shadcn/ui 'new-york' palette;
adjust later to match the existing vanilla app's blue / g100 colors. */
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 47.4% 11.2%);
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222.2 47.4% 11.2%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 47.4% 11.2%);
--color-primary: hsl(222.2 47.4% 11.2%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
--color-destructive: hsl(0 84% 60%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
--color-ring: hsl(215 20.2% 65.1%);
--radius: 0.5rem;
}
body {
background: var(--color-background);
color: var(--color-foreground);
margin: 0;
}

45
client/src/lib/api.ts Normal file
View file

@ -0,0 +1,45 @@
// Thin fetch wrapper used by every React page. Centralises auth
// header handling (cookie-based, credentials: 'include'), JSON
// parsing, and typed success-vs-error narrowing via shared/types.
import type { ApiResponse } from '@/shared/types';
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export async function apiFetch<TOk>(
path: string,
init: RequestInit = {},
): Promise<TOk> {
const resp = await fetch(path, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init.headers || {}),
},
...init,
});
// Non-JSON responses (e.g. audio blobs) — caller must handle.
const ct = resp.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
if (!resp.ok) throw new ApiError(resp.status, resp.statusText);
return (await resp.blob()) as unknown as TOk;
}
const body = (await resp.json()) as ApiResponse<TOk>;
if (!resp.ok || body.success === false) {
throw new ApiError(resp.status, (body as { error?: string }).error || resp.statusText);
}
return body as unknown as TOk;
}
// Shortcuts for common verbs
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
};

View file

@ -0,0 +1,124 @@
// ============================================================
// Encounter persistence — port of public/js/encounters.js save/load
// flow. sessionStorage keys survive page refresh + sign-out so a
// resumed session lands on the same DB row instead of creating a
// duplicate. Optimistic-locking via expected_version preserved.
// ============================================================
export type EncType = 'encounter' | 'dictation' | 'hospital' | 'chart' | 'wellvisit' | 'sickvisit' | 'soap';
const SAVED_KEY = (t: EncType) => '_savedEncId_' + t;
const IDEMP_KEY = (t: EncType) => '_idempKey_' + t;
export function getSavedEncId(t: EncType): number | null {
try {
const v = sessionStorage.getItem(SAVED_KEY(t));
return v ? Number(v) : null;
} catch { return null; }
}
export function setSavedEncId(t: EncType, id: number | null) {
try {
if (id == null) sessionStorage.removeItem(SAVED_KEY(t));
else sessionStorage.setItem(SAVED_KEY(t), String(id));
} catch { /* ignore */ }
}
export function getIdempotencyKey(t: EncType): string {
try {
const existing = sessionStorage.getItem(IDEMP_KEY(t));
if (existing) return existing;
} catch { /* ignore */ }
const fresh = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
try { sessionStorage.setItem(IDEMP_KEY(t), fresh); } catch { /* ignore */ }
return fresh;
}
export function resetIdempotencyKey(t: EncType) {
try { sessionStorage.removeItem(IDEMP_KEY(t)); } catch { /* ignore */ }
}
const _versions = new Map<number, number>();
export interface SaveEncounterInput {
type: EncType;
label: string;
transcript: string;
generatedNote: string;
partialData?: unknown; // serialized to JSON
}
export interface SaveEncounterResult {
id: number;
version: number;
}
export async function saveEncounter(input: SaveEncounterInput): Promise<SaveEncounterResult> {
if (!input.label.trim()) throw new Error('Enter a patient label first');
const id = getSavedEncId(input.type);
const body: Record<string, unknown> = {
label: input.label.trim(),
enc_type: input.type,
transcript: input.transcript,
generated_note: input.generatedNote,
partial_data: input.partialData != null ? JSON.stringify(input.partialData) : undefined,
idempotency_key: getIdempotencyKey(input.type),
};
if (id != null) {
body.id = id;
const expected = _versions.get(id);
if (expected != null) body.expected_version = expected;
}
const r = await fetch('/api/encounters/saved', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await r.json();
if (r.status === 409) throw new Error('Someone else edited this encounter. Reload to see the latest version.');
if (!data.success) throw new Error(data.error || 'Save failed');
if (data.id != null) {
setSavedEncId(input.type, data.id);
if (data.version != null) _versions.set(data.id, data.version);
}
return { id: data.id, version: data.version };
}
export interface LoadedEncounter {
id: number;
label: string;
enc_type: string;
transcript: string;
generated_note: string;
partial_data: string | null;
version?: number;
}
export async function loadEncounter(id: number): Promise<LoadedEncounter> {
const r = await fetch('/api/encounters/saved/' + id, { credentials: 'include' });
const data = await r.json();
if (!data.success) throw new Error(data.error || 'Load failed');
if (data.encounter && data.encounter.version != null) _versions.set(id, data.encounter.version);
return data.encounter as LoadedEncounter;
}
export interface SavedEncounterListEntry {
id: number;
label: string;
enc_type: EncType;
status?: string;
updated_at: string;
expires_at: string;
}
export async function listSavedEncounters(): Promise<SavedEncounterListEntry[]> {
const r = await fetch('/api/encounters/saved', { credentials: 'include' });
const data = await r.json();
return (data.encounters || []) as SavedEncounterListEntry[];
}
export function clearTabState(t: EncType) {
setSavedEncId(t, null);
resetIdempotencyKey(t);
}

View file

@ -0,0 +1,59 @@
// ============================================================
// AudioRecorder — verbatim port of public/js/app.js:660-687.
// Same constraints (mono, 16kHz, EC+NS), same opus 32kbps target.
// Exposes the underlying mediaRecorder + stream so the React
// Recorder component can pause/resume + restart on the same stream
// (matches vanilla liveEncounter.js fallback behavior).
// ============================================================
export class AudioRecorder {
mediaRecorder: MediaRecorder | null = null;
chunks: Blob[] = [];
stream: MediaStream | null = null;
start(): Promise<void> {
this.chunks = [];
return navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true },
}).then((stream) => {
this.stream = stream;
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
this.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
this.mediaRecorder.start(1000);
});
}
stop(): Promise<Blob | null> {
return new Promise((resolve) => {
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') { resolve(null); return; }
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.mediaRecorder!.mimeType });
if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
resolve(blob);
};
this.mediaRecorder.stop();
});
}
pause() {
if (this.mediaRecorder && typeof this.mediaRecorder.pause === 'function' && this.mediaRecorder.state === 'recording') {
try { this.mediaRecorder.pause(); } catch { /* ignore */ }
}
}
resume() {
if (!this.mediaRecorder) return;
try {
if (typeof this.mediaRecorder.resume === 'function' && this.mediaRecorder.state === 'paused') {
this.mediaRecorder.resume();
} else if (this.mediaRecorder.state === 'inactive' && this.stream && this.stream.active) {
// Browser killed it — restart on the same stream (matches vanilla fallback).
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
this.mediaRecorder.start(1000);
}
} catch { /* ignore */ }
}
}

View file

@ -0,0 +1,57 @@
// ============================================================
// Minimal inline HTML sanitizer. Not as thorough as DOMPurify but
// covers the main XSS vectors for CMS-authored Learning Hub
// content (admin-authored, so the trust model is higher than
// user-generated content anyway):
//
// • Strips <script>, <style>, <iframe>, <object>, <embed>, <link>
// • Strips every on* event-handler attribute
// • Strips javascript:/data:(text/html) URL schemes on href/src
// • Strips any attribute whose value contains "javascript:"
// • Preserves standard formatting tags (p, h1-6, ul/ol/li, strong,
// em, code, pre, blockquote, table, a, img, br, hr, span, div…)
//
// Parses via DOMParser (sandboxed — no scripts run) so the output is
// a DOM tree we can walk + clean safely before serializing back.
// ============================================================
const BLOCKED_TAGS = new Set([
'script', 'style', 'iframe', 'object', 'embed', 'link',
'meta', 'base', 'form', 'input', 'button', 'select', 'textarea',
]);
function stripNode(node: Element) {
// Blocked tags — remove entirely.
if (BLOCKED_TAGS.has(node.tagName.toLowerCase())) {
node.remove();
return;
}
// Strip dangerous attributes.
const toRemove: string[] = [];
for (const attr of Array.from(node.attributes)) {
const name = attr.name.toLowerCase();
const val = (attr.value || '').trim().toLowerCase();
if (name.startsWith('on')) toRemove.push(attr.name);
else if ((name === 'href' || name === 'src' || name === 'xlink:href') &&
(val.startsWith('javascript:') || val.startsWith('data:text/html'))) {
toRemove.push(attr.name);
}
else if (val.includes('javascript:')) toRemove.push(attr.name);
}
toRemove.forEach((a) => node.removeAttribute(a));
// Recurse into children.
for (const child of Array.from(node.children)) stripNode(child);
}
export function sanitizeHtml(html: string): string {
if (!html) return '';
try {
const parser = new DOMParser();
const doc = parser.parseFromString('<!DOCTYPE html><html><body>' + html + '</body></html>', 'text/html');
for (const child of Array.from(doc.body.children)) stripNode(child);
return doc.body.innerHTML;
} catch {
// Fall back to plain text if parsing fails — safer than returning raw HTML.
return html.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c] || c));
}
}

View file

@ -0,0 +1,99 @@
// ============================================================
// Transcribe + audio-backup helpers — port of public/js/app.js
// transcribeAudio() / _serverTranscribe() / saveAudioBackup().
//
// Server-first via /api/transcribe (cookie auth — same-origin).
// On failure, blob is saved to /api/audio-backups so the user can
// retry from Settings → Audio Backups (or vanilla Audio Backups page).
// IndexedDB fallback preserved from vanilla audioBackup.js.
// ============================================================
export interface TranscribeResult {
success: boolean;
text?: string;
provider?: string;
duration?: number;
noProvider?: boolean;
error?: string;
}
let transcribeAvailable: boolean | null = null;
let transcribeProvider = 'none';
export async function checkTranscribeStatus(): Promise<void> {
try {
const r = await fetch('/api/transcribe/status', { credentials: 'include' });
const data = await r.json();
transcribeAvailable = !!data.available;
transcribeProvider = data.provider || 'none';
} catch {
transcribeAvailable = false;
}
}
export function isTranscribeAvailable(): boolean | null { return transcribeAvailable; }
export function getTranscribeProvider(): string { return transcribeProvider; }
export async function transcribeAudio(blob: Blob, module = 'encounter'): Promise<TranscribeResult> {
if (transcribeAvailable === null) await checkTranscribeStatus();
if (transcribeAvailable === false) {
return { success: false, noProvider: true, error: 'No transcription API configured — using live transcript' };
}
const form = new FormData();
form.append('audio', blob, 'audio.webm');
try {
const r = await fetch('/api/transcribe', { method: 'POST', credentials: 'include', body: form });
const data: TranscribeResult = await r.json();
if (!data.success && blob.size > 0) {
// Best-effort: save the blob so the user can retry later.
saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
}
return data;
} catch (e) {
saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
return { success: false, error: (e as Error).message };
}
}
// ── Audio backup (server-first, IndexedDB fallback) ──
export async function saveAudioBackup(blob: Blob, module: string): Promise<number | null> {
// Server first.
try {
const form = new FormData();
form.append('audio', blob, 'audio.webm');
form.append('module', module);
const r = await fetch('/api/audio-backups', { method: 'POST', credentials: 'include', body: form });
const data = await r.json();
if (data.success && data.id) return data.id;
} catch { /* fall through */ }
// IndexedDB fallback.
return saveToIndexedDB(blob, module);
}
const DB_NAME = 'PedScribeAudioBackup';
const STORE = 'recordings';
let _db: IDBDatabase | null = null;
function openDB(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
req.onsuccess = () => { _db = req.result; resolve(_db); };
req.onerror = () => reject(new Error('IndexedDB open failed'));
});
}
function saveToIndexedDB(blob: Blob, module: string): Promise<number | null> {
return openDB().then((db) => new Promise<number>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
const req = store.add({ blob, module, timestamp: Date.now(), size: blob.size, mimeType: blob.type });
req.onsuccess = () => resolve(req.result as number);
req.onerror = () => reject(new Error('Failed to save audio backup'));
})).catch(() => null);
}

8
client/src/lib/utils.ts Normal file
View file

@ -0,0 +1,8 @@
// shadcn/ui classname-merge helper — combines clsx + tailwind-merge so
// conditional class merging doesn't clobber earlier class values.
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,117 @@
// ============================================================
// Web Speech API wrapper — verbatim port of
// public/js/speechRecognition.js. Browser-side live transcription
// for showing words as the user speaks. Falls back to no-op when
// the API is unsupported. Privacy warning preserved.
// ============================================================
const STORAGE_ENABLED = 'ped_web_speech_enabled';
interface SpeechRecognitionResult {
isFinal: boolean;
[index: number]: { transcript: string };
}
interface SpeechRecognitionResults {
length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionEvent {
resultIndex: number;
results: SpeechRecognitionResults;
}
interface SpeechRecognitionErrorEvent { error: string }
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
maxAlternatives: number;
onresult: ((e: SpeechRecognitionEvent) => void) | null;
onerror: ((e: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
start(): void;
stop(): void;
}
type SRCtor = new () => SpeechRecognitionLike;
function getCtor(): SRCtor | null {
if (typeof window === 'undefined') return null;
const w = window as unknown as { SpeechRecognition?: SRCtor; webkitSpeechRecognition?: SRCtor };
return (w.SpeechRecognition || w.webkitSpeechRecognition) || null;
}
export function isSpeechRecognitionSupported(): boolean {
return !!getCtor();
}
export function isSpeechRecognitionEnabled(): boolean {
try { return localStorage.getItem(STORAGE_ENABLED) === '1' || localStorage.getItem(STORAGE_ENABLED) === 'true'; }
catch { return false; }
}
export function setSpeechRecognitionEnabled(v: boolean) {
try { localStorage.setItem(STORAGE_ENABLED, v ? 'true' : 'false'); } catch { /* ignore */ }
}
export interface SpeechHandle {
start: () => void;
stop: () => void;
}
// Creates a continuous-listening session that calls handlers as words
// come in. Mirrors the liveEncounter.js wiring (final + interim results).
export function createSpeechSession(handlers: {
onFinal: (text: string) => void;
onInterim: (text: string) => void;
onError?: (err: string) => void;
}): SpeechHandle | null {
const Ctor = getCtor();
if (!Ctor) return null;
let rec: SpeechRecognitionLike | null = null;
let active = false;
function build(): SpeechRecognitionLike {
const r = new (Ctor as SRCtor)();
r.continuous = true;
r.interimResults = true;
r.lang = 'en-US';
r.maxAlternatives = 1;
r.onresult = (e: SpeechRecognitionEvent) => {
let interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) handlers.onFinal(t + ' ');
else interim = t;
}
handlers.onInterim(interim);
};
r.onerror = (e: SpeechRecognitionErrorEvent) => {
if (e.error === 'no-speech' || e.error === 'aborted') return;
handlers.onError?.(e.error);
};
r.onend = () => { if (active) try { r.start(); } catch { /* ignore */ } };
return r;
}
return {
start: () => { active = true; rec = build(); try { rec.start(); } catch { /* ignore */ } },
stop: () => { active = false; if (rec) try { rec.stop(); } catch { /* ignore */ } },
};
}
// Verbatim port of deduplicateFinal from public/js/app.js:964-984.
export function deduplicateFinal(newText: string, existingText: string): string {
if (!newText || !existingText) return newText;
const trimmed = newText.trim();
if (!trimmed) return '';
if (existingText.trimEnd().endsWith(trimmed)) return '';
const words = trimmed.split(/\s+/);
if (words.length >= 3) {
const tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
if (tail === trimmed) return '';
const half = Math.ceil(words.length / 2);
const firstHalf = words.slice(0, half).join(' ');
if (existingText.trimEnd().endsWith(firstHalf)) {
return words.slice(half).join(' ') + ' ';
}
}
return newText;
}

10
client/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

103
client/src/pages/Admin.tsx Normal file
View file

@ -0,0 +1,103 @@
// ============================================================
// ADMIN — sub-tab shell for the admin panel. Tabs live in
// AdminPanels.tsx (batch 1: Users, Settings, Announcement) +
// AdminPanels2.tsx (batch 2: SMTP, Email, Prompts, Models,
// TTS, STT, Logs). Role check + query cache shared with Layout.
// ============================================================
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { MeOk } from '@/shared/types';
import { AdminUsersTab, AdminSettingsTab, AdminAnnouncementTab } from './AdminPanels';
import {
AdminSmtpTab, AdminEmailTab, AdminPromptsTab,
AdminModelsTab, AdminTtsTab, AdminSttTab, AdminLogsTab,
} from './AdminPanels2';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
type TabId =
| 'users' | 'settings' | 'announcement'
| 'models' | 'tts' | 'stt'
| 'smtp' | 'email' | 'prompts'
| 'logs';
const TABS: Array<{ id: TabId; label: string }> = [
{ id: 'users', label: 'Users' },
{ id: 'settings', label: 'Site settings' },
{ id: 'announcement', label: 'Announcement' },
{ id: 'models', label: 'AI models' },
{ id: 'tts', label: 'TTS provider' },
{ id: 'stt', label: 'STT provider' },
{ id: 'smtp', label: 'SMTP' },
{ id: 'email', label: 'Email templates' },
{ id: 'prompts', label: 'AI prompts' },
{ id: 'logs', label: 'Audit logs' },
];
export default function Admin() {
const { data: me, isLoading } = useQuery<MeOk>({
queryKey: ['auth-me'],
queryFn: () => api.get<MeOk>('/api/auth/me'),
staleTime: 5 * 60_000,
});
const [active, setActive] = useState<TabId>('users');
if (isLoading) {
return <div className="max-w-3xl mx-auto p-6 text-sm text-muted-foreground">Checking permissions</div>;
}
if (me?.user.role !== 'admin') {
return (
<div className="max-w-3xl mx-auto p-6">
<section className={card} data-testid="admin-access-denied">
<h1 className="text-xl font-semibold">Admin only</h1>
<p className="text-sm text-muted-foreground">
This page is restricted to users with the admin role. If you believe this is a mistake, contact your site administrator.
</p>
</section>
</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-4" data-testid="admin-shell">
<header>
<h1 className="text-2xl font-semibold">Admin Panel</h1>
<p className="text-sm text-muted-foreground">
Users, site settings, announcement banner, AI model management, TTS/STT provider, SMTP, email templates, and audit logs.
</p>
</header>
<div className="flex flex-wrap gap-2" data-testid="admin-subnav">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setActive(t.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
(active === t.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'admin-tab-' + t.id}
>
{t.label}
</button>
))}
</div>
{active === 'users' && <AdminUsersTab />}
{active === 'settings' && <AdminSettingsTab />}
{active === 'announcement' && <AdminAnnouncementTab />}
{active === 'models' && <AdminModelsTab />}
{active === 'tts' && <AdminTtsTab />}
{active === 'stt' && <AdminSttTab />}
{active === 'smtp' && <AdminSmtpTab />}
{active === 'email' && <AdminEmailTab />}
{active === 'prompts' && <AdminPromptsTab />}
{active === 'logs' && <AdminLogsTab />}
</div>
);
}

View file

@ -0,0 +1,337 @@
// ============================================================
// ADMIN PANELS — real React components for each admin sub-tab.
// Batch 1: Users / Settings / Announcement. Remaining tabs
// (SMTP, Email Templates, AI Prompts, AI Models, TTS/STT, Logs)
// ship in follow-up commits.
// ============================================================
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type {
AdminUser,
AdminUsersOk,
AdminSettingsOk,
AdminAnnouncementOk,
} from '@/shared/types';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-medium text-muted-foreground';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnDanger = 'rounded-md bg-destructive text-white px-3 py-2 text-xs font-medium disabled:opacity-50';
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
type Msg = { text: string; kind: 'ok' | 'err' | 'info' } | null;
function StatusLine({ msg }: { msg: Msg }) {
if (!msg) return null;
const c = msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground';
return <div className={'text-sm ' + c}>{msg.text}</div>;
}
// ── Users ───────────────────────────────────────────────────
export function AdminUsersTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [query, setQuery] = useState('');
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
const [resetTarget, setResetTarget] = useState<AdminUser | null>(null);
const [resetPw, setResetPw] = useState('');
const { data, isLoading, error } = useQuery<AdminUsersOk>({
queryKey: ['admin-users'],
queryFn: () => api.get<AdminUsersOk>('/api/admin/users'),
});
const verify = useMutation({
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/verify`, {}),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const disable = useMutation({
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/disable`, {}),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const enable = useMutation({
mutationFn: (id: number) => api.post<{ message: string }>(`/api/admin/users/${id}/enable`, {}),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const setRole = useMutation({
mutationFn: (body: { id: number; role: string }) =>
api.post<{ message: string }>(`/api/admin/users/${body.id}/role`, { role: body.role }),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ message: string }>(`/api/admin/users/${id}`),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-users'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const resetPwMutation = useMutation({
mutationFn: (body: { id: number; newPassword: string }) =>
api.post<{ message: string }>(`/api/admin/users/${body.id}/reset-password`, { newPassword: body.newPassword }),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); setResetTarget(null); setResetPw(''); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const users = (data?.users || []).filter((u) =>
!query || u.email.toLowerCase().includes(query.toLowerCase()) || u.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<section className={card} data-testid="admin-users-tab">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h2 className="text-lg font-semibold">Users</h2>
<input
type="search"
className={input + ' max-w-xs'}
placeholder="Search by name or email…"
value={query}
onChange={(e) => setQuery(e.target.value)}
data-testid="admin-users-search"
/>
</div>
{isLoading && <div className="text-sm text-muted-foreground">Loading</div>}
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="admin-users-table">
<thead>
<tr>
<th className={th}>Email</th>
<th className={th}>Name</th>
<th className={th}>Role</th>
<th className={th}>Verified</th>
<th className={th}>2FA</th>
<th className={th}>Status</th>
<th className={th}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} data-testid={`admin-user-row-${u.id}`} className={u.disabled ? 'opacity-60' : ''}>
<td className={td}>{u.email}</td>
<td className={td}>{u.name}</td>
<td className={td}>
<select
className={input + ' text-xs w-28'}
value={u.role || 'user'}
onChange={(e) => setRole.mutate({ id: u.id, role: e.target.value })}
data-testid={`admin-user-role-${u.id}`}
>
<option value="user">user</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
</td>
<td className={td + ' text-xs'}>
{u.email_verified ? '✅' : (
<button type="button" className={btnGhost} onClick={() => verify.mutate(u.id)} data-testid={`admin-user-verify-${u.id}`}>Verify</button>
)}
</td>
<td className={td + ' text-xs'}>{u.totp_enabled ? '✅' : '—'}</td>
<td className={td + ' text-xs'}>
{u.disabled ? (
<button type="button" className={btnGhost} onClick={() => enable.mutate(u.id)} data-testid={`admin-user-enable-${u.id}`}>Enable</button>
) : (
<button type="button" className={btnGhost} onClick={() => disable.mutate(u.id)} data-testid={`admin-user-disable-${u.id}`}>Disable</button>
)}
</td>
<td className={td + ' text-xs'}>
<div className="flex gap-1">
<button type="button" className={btnGhost} onClick={() => setResetTarget(u)} data-testid={`admin-user-reset-${u.id}`}>Reset pw</button>
<button type="button" className={btnDanger} onClick={() => setDeleteTarget(u)} data-testid={`admin-user-delete-${u.id}`}>Delete</button>
</div>
</td>
</tr>
))}
{users.length === 0 && data && (
<tr><td className={td + ' text-muted-foreground italic'} colSpan={7}>No users match "{query}".</td></tr>
)}
</tbody>
</table>
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title={`Delete ${deleteTarget?.email}?`}
body="This deletes the user account. Audit log entries are preserved (user_id set to NULL)."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => { if (deleteTarget) del.mutate(deleteTarget.id); setDeleteTarget(null); }}
onCancel={() => setDeleteTarget(null)}
/>
{resetTarget && (
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setResetTarget(null)}>
<div className="w-full max-w-sm rounded-lg border border-border bg-background p-5 shadow-lg space-y-3" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold">Reset password for {resetTarget.email}</h3>
<input
type="text"
className={input}
placeholder="New password (8+ chars)"
value={resetPw}
onChange={(e) => setResetPw(e.target.value)}
autoFocus
minLength={8}
data-testid="admin-user-reset-input"
/>
<div className="flex justify-end gap-2">
<button type="button" className={btnGhost} onClick={() => { setResetTarget(null); setResetPw(''); }}>Cancel</button>
<button
type="button"
className={btnPrimary}
disabled={resetPw.length < 8 || resetPwMutation.isPending}
onClick={() => resetPwMutation.mutate({ id: resetTarget.id, newPassword: resetPw })}
data-testid="admin-user-reset-submit"
>
{resetPwMutation.isPending ? 'Saving…' : 'Reset'}
</button>
</div>
</div>
</div>
)}
</section>
);
}
// ── Settings (registration + stats) ────────────────────────
export function AdminSettingsTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const { data } = useQuery<AdminSettingsOk>({
queryKey: ['admin-settings'],
queryFn: () => api.get<AdminSettingsOk>('/api/admin/settings'),
});
const toggle = useMutation({
mutationFn: (enabled: boolean) =>
api.post<{ message: string; registrationEnabled: boolean }>('/api/admin/settings/registration', { enabled }),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-settings'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
return (
<section className={card} data-testid="admin-settings-tab">
<h2 className="text-lg font-semibold">Site settings</h2>
{data && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Total users</div><div className="text-xl font-bold">{data.stats.totalUsers}</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">API calls (all time)</div><div className="text-xl font-bold">{data.stats.totalApiCalls}</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">API calls (today)</div><div className="text-xl font-bold">{data.stats.todayApiCalls}</div></div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-primary size-4"
checked={data.settings.registrationEnabled}
onChange={(e) => toggle.mutate(e.target.checked)}
data-testid="admin-registration-toggle"
/>
<span className="text-sm font-medium">Allow new user registration</span>
</label>
</div>
</>
)}
<StatusLine msg={msg} />
</section>
);
}
// ── Announcement banner ────────────────────────────────────
export function AdminAnnouncementTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [enabled, setEnabled] = useState(false);
const [type, setType] = useState<'info' | 'warning' | 'critical'>('info');
const [text, setText] = useState('');
const [hydrated, setHydrated] = useState(false);
const { data } = useQuery<AdminAnnouncementOk>({
queryKey: ['admin-announcement'],
queryFn: () => api.get<AdminAnnouncementOk>('/api/admin/config/announcement'),
});
// one-time hydrate from server
if (!hydrated && data) {
setEnabled(data.enabled);
setType(((data as unknown as { type?: string }).type as typeof type) || 'info');
setText((data as unknown as { text?: string }).text || '');
setHydrated(true);
}
const putConfig = useMutation({
mutationFn: async (body: { key: string; value: string }) =>
api.put<{ success: true }>(`/api/admin/config/${encodeURIComponent(body.key)}`, { value: body.value }),
});
async function save() {
setMsg(null);
try {
await Promise.all([
putConfig.mutateAsync({ key: 'announcement.enabled', value: enabled ? 'true' : 'false' }),
putConfig.mutateAsync({ key: 'announcement.type', value: type }),
putConfig.mutateAsync({ key: 'announcement.text', value: text }),
]);
setMsg({ text: 'Announcement saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['admin-announcement'] });
} catch (e) {
setMsg({ text: (e as Error).message, kind: 'err' });
}
}
return (
<section className={card} data-testid="admin-announcement-tab">
<h2 className="text-lg font-semibold">Announcement banner</h2>
<p className="text-sm text-muted-foreground">
Shown at the top of every page when enabled. Use for scheduled maintenance, outage notices, or release notes.
</p>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-primary size-4"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
data-testid="admin-announcement-enabled"
/>
<span className="text-sm">Show banner</span>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="sm:col-span-1">
<label className={label}>Severity</label>
<select className={input} value={type} onChange={(e) => setType(e.target.value as typeof type)} data-testid="admin-announcement-type">
<option value="info">Info (blue)</option>
<option value="warning">Warning (amber)</option>
<option value="critical">Critical (red)</option>
</select>
</div>
<div className="sm:col-span-2">
<label className={label}>Message</label>
<textarea
rows={3}
className={input + ' resize-y'}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="e.g. Scheduled maintenance Thursday 02:00 UTC — expect 10 minutes of downtime."
data-testid="admin-announcement-text"
/>
</div>
</div>
<button type="button" onClick={save} disabled={putConfig.isPending} className={btnPrimary} data-testid="admin-announcement-save">
{putConfig.isPending ? 'Saving…' : 'Save announcement'}
</button>
<StatusLine msg={msg} />
</section>
);
}

View file

@ -0,0 +1,499 @@
// ============================================================
// ADMIN PANELS (batch 2) — SMTP, Email Templates, AI Prompts,
// AI Models, TTS provider, STT provider, Audit Logs.
// All endpoints live at /api/admin/config/*.
// ============================================================
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type {
AdminConfigOk,
AdminSmtpStatusOk,
AdminPromptsOk,
AdminModelsOk,
AdminLogsOk,
} from '@/shared/types';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-medium text-muted-foreground';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnDanger = 'rounded-md bg-destructive text-white px-3 py-2 text-xs font-medium disabled:opacity-50';
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
type Msg = { text: string; kind: 'ok' | 'err' | 'info' } | null;
function StatusLine({ msg }: { msg: Msg }) {
if (!msg) return null;
const c = msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground';
return <div className={'text-sm ' + c}>{msg.text}</div>;
}
// Shared putConfig — PUT /api/admin/config/:key with {value}.
function useConfigPut() {
return useMutation({
mutationFn: (body: { key: string; value: string }) =>
api.put<{ success: true }>(`/api/admin/config/${encodeURIComponent(body.key)}`, { value: body.value }),
});
}
// ── SMTP ────────────────────────────────────────────────────
interface SmtpStatusExt extends AdminSmtpStatusOk { source?: string }
export function AdminSmtpTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [clearConfirm, setClearConfirm] = useState(false);
const [host, setHost] = useState('');
const [port, setPort] = useState('587');
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [from, setFrom] = useState('');
const [secure, setSecure] = useState('false');
const [hydrated, setHydrated] = useState(false);
const [testTo, setTestTo] = useState('');
const [testTemplate, setTestTemplate] = useState('verify');
const { data } = useQuery<SmtpStatusExt>({
queryKey: ['admin-smtp-status'],
queryFn: () => api.get<SmtpStatusExt>('/api/admin/config/smtp/status'),
});
if (!hydrated && data) {
setHost(data.host || '');
setPort(String(data.port ?? '587'));
setUser(data.user || '');
setFrom(data.from || '');
setHydrated(true);
}
const save = useMutation({
mutationFn: (body: { host: string; port: string; user: string; pass: string; from: string; secure: boolean }) =>
api.put<{ success: true }>('/api/admin/config/smtp', body),
onSuccess: () => { setMsg({ text: 'SMTP settings saved', kind: 'ok' }); setPass(''); qc.invalidateQueries({ queryKey: ['admin-smtp-status'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const clear = useMutation({
mutationFn: () => api.delete<{ message: string }>('/api/admin/config/smtp'),
onSuccess: (d) => { setMsg({ text: d.message, kind: 'info' }); qc.invalidateQueries({ queryKey: ['admin-smtp-status'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const testEmail = useMutation({
mutationFn: (body: { to: string; template: string }) =>
api.post<{ success: true }>('/api/admin/config/test-email', body),
onSuccess: () => setMsg({ text: `Test email sent to ${testTo}`, kind: 'ok' }),
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
return (
<section className={card} data-testid="admin-smtp-tab">
<h2 className="text-lg font-semibold">SMTP</h2>
{data && (
<div className="text-xs text-muted-foreground">
Status: {data.configured ? '✅ Configured' : '❌ Not configured'}
{data.source && <> · source: <strong>{data.source}</strong></>}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div><label className={label}>Host</label><input className={input} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" data-testid="smtp-host" /></div>
<div><label className={label}>Port</label><input className={input} value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" data-testid="smtp-port" /></div>
<div><label className={label}>Username</label><input className={input} value={user} onChange={(e) => setUser(e.target.value)} data-testid="smtp-user" /></div>
<div><label className={label}>Password</label><input type="password" className={input} value={pass} onChange={(e) => setPass(e.target.value)} placeholder="Leave blank to keep existing" data-testid="smtp-pass" /></div>
<div><label className={label}>From</label><input className={input} value={from} onChange={(e) => setFrom(e.target.value)} placeholder="noreply@example.com" data-testid="smtp-from" /></div>
<div><label className={label}>Secure (TLS)</label><select className={input} value={secure} onChange={(e) => setSecure(e.target.value)} data-testid="smtp-secure"><option value="false">STARTTLS (587)</option><option value="true">SSL/TLS (465)</option></select></div>
</div>
<div className="flex gap-2 flex-wrap">
<button type="button" className={btnPrimary} disabled={save.isPending || !host}
onClick={() => save.mutate({ host, port, user, pass, from, secure: secure === 'true' })}
data-testid="smtp-save">
{save.isPending ? 'Saving…' : 'Save SMTP settings'}
</button>
<button type="button" className={btnDanger} onClick={() => setClearConfirm(true)} data-testid="smtp-clear">Clear DB override</button>
</div>
<div className="rounded-md bg-muted/40 p-3 space-y-2">
<div className="text-sm font-semibold">Send test email</div>
<div className="flex flex-wrap gap-2 items-end">
<div className="flex-1 min-w-[200px]"><label className={label}>Recipient</label><input type="email" className={input} value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="recipient@example.com" data-testid="smtp-test-to" /></div>
<div><label className={label}>Template</label><select className={input} value={testTemplate} onChange={(e) => setTestTemplate(e.target.value)} data-testid="smtp-test-template"><option value="verify">verify</option><option value="reset">reset</option><option value="password-changed">password-changed</option></select></div>
<button type="button" className={btnGhost} disabled={testEmail.isPending || !testTo} onClick={() => testEmail.mutate({ to: testTo, template: testTemplate })} data-testid="smtp-test-send">
{testEmail.isPending ? 'Sending…' : 'Send test'}
</button>
</div>
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={clearConfirm}
title="Clear DB SMTP settings?"
body="Removes smtp.* entries from the DB. Env vars will still apply if they're set (e.g. SMTP_HOST from OpenBao)."
confirmText="Clear"
danger
busy={clear.isPending}
onConfirm={() => { clear.mutate(); setClearConfirm(false); }}
onCancel={() => setClearConfirm(false)}
/>
</section>
);
}
// ── Email templates ────────────────────────────────────────
const EMAIL_TEMPLATES = ['verify', 'reset', 'password-changed'];
export function AdminEmailTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [template, setTemplate] = useState('verify');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const { data } = useQuery<AdminConfigOk>({
queryKey: ['admin-config'],
queryFn: () => api.get<AdminConfigOk>('/api/admin/config'),
});
function pick(tpl: string) {
setTemplate(tpl);
const map = new Map((data?.config || []).map((c) => [c.key, c.value || '']));
setSubject(map.get('email.' + tpl + '.subject') || '');
setBody(map.get('email.' + tpl + '.body') || '');
}
// Hydrate when data first arrives.
if (data && !subject && !body) {
pick(template);
}
const putConfig = useConfigPut();
async function save() {
setMsg(null);
try {
await Promise.all([
putConfig.mutateAsync({ key: 'email.' + template + '.subject', value: subject }),
putConfig.mutateAsync({ key: 'email.' + template + '.body', value: body }),
]);
setMsg({ text: 'Email template saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['admin-config'] });
} catch (e) {
setMsg({ text: (e as Error).message, kind: 'err' });
}
}
return (
<section className={card} data-testid="admin-email-tab">
<h2 className="text-lg font-semibold">Email templates</h2>
<div className="max-w-xs">
<label className={label}>Template</label>
<select className={input} value={template} onChange={(e) => pick(e.target.value)} data-testid="email-template">
{EMAIL_TEMPLATES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div><label className={label}>Subject</label><input className={input} value={subject} onChange={(e) => setSubject(e.target.value)} data-testid="email-subject" /></div>
<div><label className={label}>Body (HTML)</label><textarea rows={10} className={input + ' resize-y font-mono text-xs'} value={body} onChange={(e) => setBody(e.target.value)} data-testid="email-body" /></div>
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="email-save">
{putConfig.isPending ? 'Saving…' : 'Save template'}
</button>
<StatusLine msg={msg} />
</section>
);
}
// ── AI Prompts ─────────────────────────────────────────────
export function AdminPromptsTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [selected, setSelected] = useState('');
const [value, setValue] = useState('');
const [resetConfirm, setResetConfirm] = useState(false);
const { data } = useQuery<AdminPromptsOk>({
queryKey: ['admin-prompts'],
queryFn: () => api.get<AdminPromptsOk>('/api/admin/config/prompts'),
});
if (data && !selected && data.prompts.length > 0) {
setSelected(data.prompts[0].key);
setValue(data.prompts[0].value);
}
function pick(key: string) {
setSelected(key);
const p = data?.prompts.find((x) => x.key === key);
setValue(p?.value || '');
}
const putConfig = useConfigPut();
const resetMutation = useMutation({
mutationFn: (key: string) =>
api.post<{ value: string }>(`/api/admin/config/prompts/${encodeURIComponent(key)}/reset`, {}),
onSuccess: (d) => { setValue(d.value); setMsg({ text: 'Prompt reset to default', kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-prompts'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
async function save() {
setMsg(null);
try {
await putConfig.mutateAsync({ key: 'prompt.' + selected, value });
setMsg({ text: 'Prompt saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['admin-prompts'] });
} catch (e) {
setMsg({ text: (e as Error).message, kind: 'err' });
}
}
return (
<section className={card} data-testid="admin-prompts-tab">
<h2 className="text-lg font-semibold">AI Prompts</h2>
<p className="text-sm text-muted-foreground">
System prompts injected before each generation. Reset restores the hardcoded default from src/utils/prompts.ts.
</p>
<div className="max-w-md">
<label className={label}>Prompt</label>
<select className={input} value={selected} onChange={(e) => pick(e.target.value)} data-testid="prompts-select">
{(data?.prompts || []).map((p) => <option key={p.key} value={p.key}>{p.key}</option>)}
</select>
</div>
<textarea rows={14} className={input + ' resize-y font-mono text-xs'} value={value} onChange={(e) => setValue(e.target.value)} data-testid="prompts-text" />
<div className="flex gap-2">
<button type="button" className={btnPrimary} disabled={putConfig.isPending || !selected} onClick={save} data-testid="prompts-save">
{putConfig.isPending ? 'Saving…' : 'Save prompt'}
</button>
<button type="button" className={btnGhost} disabled={!selected} onClick={() => setResetConfirm(true)} data-testid="prompts-reset">
Reset to default
</button>
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={resetConfirm}
title={`Reset "${selected}"?`}
body="Restores the hardcoded default. Cannot be undone."
confirmText="Reset"
danger
busy={resetMutation.isPending}
onConfirm={() => { resetMutation.mutate(selected); setResetConfirm(false); }}
onCancel={() => setResetConfirm(false)}
/>
</section>
);
}
// ── AI Models ──────────────────────────────────────────────
interface AdminModelsExtra extends AdminModelsOk { litellmHint?: boolean; custom?: Array<{ id: string; label?: string }> }
export function AdminModelsTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const { data } = useQuery<AdminModelsExtra>({
queryKey: ['admin-models'],
queryFn: () => api.get<AdminModelsExtra>('/api/admin/config/models'),
});
const toggle = useMutation({
mutationFn: (body: { id: string; enabled: boolean }) =>
api.put<{ success: true }>('/api/admin/config/models/toggle', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-models'] }),
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const setDefault = useMutation({
mutationFn: (modelId: string) =>
api.put<{ success: true }>('/api/admin/config/models/default', { modelId }),
onSuccess: (_, id) => { setMsg({ text: `Default model set to ${id}`, kind: 'ok' }); qc.invalidateQueries({ queryKey: ['admin-models'] }); },
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
return (
<section className={card} data-testid="admin-models-tab">
<h2 className="text-lg font-semibold">AI Models</h2>
<div className="text-xs text-muted-foreground">
Active provider: <strong>{data?.provider || '—'}</strong>
{data?.defaultModel && <> · Default: <strong>{data.defaultModel}</strong></>}
</div>
{data?.litellmHint && (
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
LiteLLM provider has no built-in model list use the legacy "Discover" flow to populate.
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="admin-models-table">
<thead>
<tr>
<th className={th}>Enabled</th>
<th className={th}>Default</th>
<th className={th}>Model ID</th>
<th className={th}>Label</th>
</tr>
</thead>
<tbody>
{(data?.models || []).map((m) => (
<tr key={m.id} data-testid={`admin-model-row-${m.id}`}>
<td className={td}>
<input type="checkbox" className="accent-primary size-4" checked={m.enabled} onChange={(e) => toggle.mutate({ id: m.id, enabled: e.target.checked })} />
</td>
<td className={td}>
<input type="radio" name="default-model" checked={data?.defaultModel === m.id} onChange={() => setDefault.mutate(m.id)} disabled={!m.enabled} />
</td>
<td className={td + ' font-mono text-xs'}>{m.id}</td>
<td className={td}>{m.label || '—'}</td>
</tr>
))}
{(data?.models || []).length === 0 && <tr><td className={td + ' italic text-muted-foreground'} colSpan={4}>No models available.</td></tr>}
</tbody>
</table>
</div>
<div className="text-xs text-muted-foreground italic">
Model discovery (search + add-custom) still lives in the legacy viewer ports when the provider integration is revamped.
</div>
<StatusLine msg={msg} />
</section>
);
}
// ── TTS / STT Provider ─────────────────────────────────────
interface VoiceProviderResp {
provider: string;
defaultVoice?: string | null;
defaultModel?: string | null;
voices?: Array<{ value: string; label?: string }>;
models?: Array<{ value: string; label?: string }>;
configured?: boolean;
}
function useVoiceProvider(path: '/api/admin/config/tts' | '/api/admin/config/stt') {
return useQuery<VoiceProviderResp>({
queryKey: ['voice-provider', path],
queryFn: () => api.get<VoiceProviderResp>(path),
});
}
export function AdminTtsTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const { data } = useVoiceProvider('/api/admin/config/tts');
const putConfig = useConfigPut();
const [voice, setVoice] = useState('');
if (data && voice === '' && data.defaultVoice) setVoice(data.defaultVoice);
async function save() {
setMsg(null);
try {
await putConfig.mutateAsync({ key: 'tts.default_voice', value: voice });
setMsg({ text: 'Default TTS voice saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['voice-provider', '/api/admin/config/tts'] });
} catch (e) {
setMsg({ text: (e as Error).message, kind: 'err' });
}
}
return (
<section className={card} data-testid="admin-tts-tab">
<h2 className="text-lg font-semibold">TTS Provider</h2>
<div className="text-xs text-muted-foreground">
Active provider: <strong>{data?.provider || '—'}</strong>
</div>
<div className="max-w-md">
<label className={label}>Default voice</label>
<select className={input} value={voice} onChange={(e) => setVoice(e.target.value)} data-testid="admin-tts-voice">
<option value="">(none)</option>
{(data?.voices || []).map((v) => <option key={v.value} value={v.value}>{v.label || v.value}</option>)}
</select>
</div>
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="admin-tts-save">
{putConfig.isPending ? 'Saving…' : 'Save default voice'}
</button>
<StatusLine msg={msg} />
</section>
);
}
export function AdminSttTab() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const { data } = useVoiceProvider('/api/admin/config/stt');
const putConfig = useConfigPut();
const [model, setModel] = useState('');
if (data && model === '' && data.defaultModel) setModel(data.defaultModel);
async function save() {
setMsg(null);
try {
await putConfig.mutateAsync({ key: 'stt.default_model', value: model });
setMsg({ text: 'Default STT model saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['voice-provider', '/api/admin/config/stt'] });
} catch (e) {
setMsg({ text: (e as Error).message, kind: 'err' });
}
}
return (
<section className={card} data-testid="admin-stt-tab">
<h2 className="text-lg font-semibold">STT Provider</h2>
<div className="text-xs text-muted-foreground">
Active provider: <strong>{data?.provider || '—'}</strong>
</div>
<div className="max-w-md">
<label className={label}>Default STT model</label>
<select className={input} value={model} onChange={(e) => setModel(e.target.value)} data-testid="admin-stt-model">
<option value="">(none)</option>
{(data?.models || []).map((m) => <option key={m.value} value={m.value}>{m.label || m.value}</option>)}
</select>
</div>
<button type="button" className={btnPrimary} disabled={putConfig.isPending} onClick={save} data-testid="admin-stt-save">
{putConfig.isPending ? 'Saving…' : 'Save default model'}
</button>
<StatusLine msg={msg} />
</section>
);
}
// ── Audit logs ─────────────────────────────────────────────
const LOG_CATEGORIES = ['', 'auth', 'admin', 'clinical', 'export', 'integration', 'documents'];
export function AdminLogsTab() {
const [category, setCategory] = useState('');
const [limit, setLimit] = useState(100);
const { data, isLoading, error, refetch } = useQuery<AdminLogsOk>({
queryKey: ['admin-logs', category, limit],
queryFn: () => api.get<AdminLogsOk>(
`/api/admin/logs/all?limit=${limit}${category ? '&category=' + encodeURIComponent(category) : ''}`,
),
});
return (
<section className={card} data-testid="admin-logs-tab">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h2 className="text-lg font-semibold">Audit Logs</h2>
<div className="flex gap-2 items-center">
<label className={label}>Category</label>
<select className={input + ' w-36 text-xs'} value={category} onChange={(e) => setCategory(e.target.value)} data-testid="admin-logs-category">
{LOG_CATEGORIES.map((c) => <option key={c} value={c}>{c || '(all)'}</option>)}
</select>
<label className={label}>Limit</label>
<select className={input + ' w-24 text-xs'} value={limit} onChange={(e) => setLimit(Number(e.target.value))} data-testid="admin-logs-limit">
{[50, 100, 200, 500].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<button type="button" className={btnGhost} onClick={() => refetch()}>Refresh</button>
</div>
</div>
{isLoading && <div className="text-sm text-muted-foreground">Loading</div>}
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto">
<table className="w-full text-sm" data-testid="admin-logs-table">
<thead className="sticky top-0 bg-card">
<tr>
<th className={th}>Time</th>
<th className={th}>User</th>
<th className={th}>Category</th>
<th className={th}>Action</th>
<th className={th}>Detail</th>
<th className={th}>IP</th>
</tr>
</thead>
<tbody>
{(data?.logs || []).map((l) => (
<tr key={l.id}>
<td className={td + ' text-xs whitespace-nowrap'}>{new Date(l.timestamp).toLocaleString()}</td>
<td className={td + ' text-xs'}>{l.user_email || '—'}{l.user_name ? ` (${l.user_name})` : ''}</td>
<td className={td + ' text-xs'}>{l.category}</td>
<td className={td + ' text-xs font-mono'}>{l.action}</td>
<td className={td + ' text-xs'}>{l.detail}</td>
<td className={td + ' text-xs text-muted-foreground'}>{l.ip_address || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

310
client/src/pages/Auth.tsx Normal file
View file

@ -0,0 +1,310 @@
// ============================================================
// AUTH SCREEN — login / register / forgot-password. Mirrors the
// vanilla auth screen feature-for-feature: Turnstile, optional
// 2FA TOTP field (reveals on requires2FA response), SSO button
// (when OIDC enabled), resend-verification link, HIPAA notice,
// APK download link.
// ============================================================
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import Turnstile from '@/components/Turnstile';
import type { PublicConfigOk } from '@/shared/types';
type Mode = 'login' | 'register' | 'forgot';
const card = 'rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md';
const btnPrimary = 'w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60';
const btnSso = 'w-full rounded-md bg-slate-900 text-white px-4 py-3 text-sm font-semibold disabled:opacity-60 hover:bg-slate-800';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-semibold text-muted-foreground mb-1';
const linkBtn = 'text-sm text-primary hover:underline';
const msgOk = 'rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 p-3 text-sm text-green-800 dark:text-green-100';
const msgErr = 'rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3 text-sm text-red-800 dark:text-red-100';
const msgInfo = 'rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-sm text-amber-900 dark:text-amber-100';
function useRedirectAfterLogin() {
const nav = useNavigate();
const loc = useLocation();
return () => {
const next = new URLSearchParams(loc.search).get('next') || '/';
nav(next, { replace: true });
};
}
export default function Auth() {
const [mode, setMode] = useState<Mode>('login');
const [err, setErr] = useState('');
const [ok, setOk] = useState('');
const [info, setInfo] = useState('');
const qc = useQueryClient();
const redirect = useRedirectAfterLogin();
const { data: cfg } = useQuery<PublicConfigOk>({
queryKey: ['public-config'],
queryFn: () => api.get<PublicConfigOk>('/api/auth/public-config'),
staleTime: 60_000,
});
// If local auth is disabled (SSO-only), hide login/register and force SSO.
const localDisabled = !!cfg?.disableLocalAuth;
function switchMode(next: Mode) {
setMode(next); setErr(''); setOk(''); setInfo('');
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900 flex items-center justify-center p-4">
<div className={card}>
<header className="text-center space-y-1">
<div className="text-4xl">🩺</div>
<h1 className="text-xl font-bold">Pediatric AI Scribe</h1>
<p className="text-xs text-muted-foreground">AI-Powered Clinical Documentation</p>
</header>
{err && <div className={msgErr}>{err}</div>}
{info && <div className={msgInfo}>{info}</div>}
{ok && <div className={msgOk}>{ok}</div>}
{mode === 'login' && !localDisabled && (
<LoginForm
cfg={cfg}
onErr={setErr} onInfo={setInfo} onOk={setOk}
onLogin={() => { qc.invalidateQueries({ queryKey: ['auth-me'] }); redirect(); }}
switchMode={switchMode}
/>
)}
{mode === 'register' && !localDisabled && (cfg?.registrationEnabled ?? true) && (
<RegisterForm cfg={cfg} onErr={setErr} onOk={setOk} switchMode={switchMode} />
)}
{mode === 'forgot' && !localDisabled && (
<ForgotForm cfg={cfg} onErr={setErr} onOk={setOk} switchMode={switchMode} />
)}
{/* SSO — always visible when OIDC enabled, even in login-only mode. */}
{cfg?.oidcEnabled && (
<>
{!localDisabled && <div className="relative my-4"><div className="absolute inset-0 flex items-center"><span className="w-full border-t border-border" /></div><div className="relative flex justify-center text-xs text-muted-foreground"><span className="bg-card px-3">or</span></div></div>}
<a href="/api/auth/oidc" className={btnSso + ' text-center block no-underline'} data-testid="auth-oidc">
🛡 {cfg.ssoButtonLabel || 'Sign in with SSO'}
</a>
</>
)}
{localDisabled && !cfg?.oidcEnabled && (
<div className={msgInfo}>Single sign-on not configured. Contact your administrator.</div>
)}
<div className="pt-4 border-t border-border text-xs text-muted-foreground space-y-2">
<div className="flex gap-2 items-start">
<span></span>
<p>HIPAA-compliant AI providers available with BAA. Check your institution's guidelines. Not intended for clinical use without proper authorization.</p>
</div>
<div className="text-center">
<a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest"
target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
📱 Download Android app (APK)
</a>
</div>
</div>
</div>
</div>
);
}
// ── Login ──────────────────────────────────────────────────
function LoginForm({
cfg, onErr, onInfo, onOk, onLogin, switchMode,
}: {
cfg: PublicConfigOk | undefined;
onErr: (s: string) => void;
onInfo: (s: string) => void;
onOk: (s: string) => void;
onLogin: () => void;
switchMode: (m: Mode) => void;
}) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totp, setTotp] = useState('');
const [needs2fa, setNeeds2fa] = useState(false);
const [needsVerify, setNeedsVerify] = useState(false);
const [turnstileToken, setTurnstileToken] = useState('');
const [busy, setBusy] = useState(false);
const [resending, setResending] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
onErr(''); onInfo(''); onOk('');
if (!email || !password) { onErr('Enter email and password'); return; }
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
setBusy(true);
try {
const body: Record<string, string> = { email, password };
if (turnstileToken) body.turnstileToken = turnstileToken;
if (totp) body.totpCode = totp;
const resp = await api.post<{ token?: string; requires2FA?: boolean; needsVerification?: boolean; message?: string }>(
'/api/auth/login', body,
);
if (resp.requires2FA) {
setNeeds2fa(true);
onInfo('Enter your 2FA code');
setBusy(false);
return;
}
if (resp.needsVerification) {
setNeedsVerify(true);
onErr('Verify your email first. Check your inbox.');
setBusy(false);
return;
}
if (resp.token) {
onOk('Signed in');
onLogin();
return;
}
onErr('Login failed');
} catch (e) {
onErr((e as ApiError).message || 'Login failed');
} finally {
setBusy(false);
}
}
async function resend() {
if (!email) { onErr('Enter your email first'); return; }
setResending(true);
try {
const r = await api.post<{ message?: string }>('/api/auth/resend-verification', { email });
onOk(r.message || 'Verification email sent');
} catch (e) {
onErr((e as ApiError).message || 'Failed to resend');
} finally { setResending(false); }
}
return (
<form onSubmit={submit} className="space-y-3" data-testid="auth-login-form">
<h2 className="text-lg font-semibold text-center">Sign in</h2>
<div><label className={label}>Email</label><input type="email" autoFocus required className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-email" /></div>
<div><label className={label}>Password</label><input type="password" required className={input} value={password} onChange={(e) => setPassword(e.target.value)} data-testid="auth-password" /></div>
{needs2fa && (
<div><label className={label}>2FA code</label><input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} className={input + ' font-mono tracking-widest'} value={totp} onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))} data-testid="auth-totp" autoFocus /></div>
)}
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-submit">
{busy ? 'Signing in…' : needs2fa ? 'Verify 2FA' : 'Sign in'}
</button>
{needsVerify && (
<div className="text-center">
<button type="button" onClick={resend} className={linkBtn} disabled={resending} data-testid="auth-resend-verify">
{resending ? 'Sending…' : 'Resend verification link'}
</button>
</div>
)}
<div className="flex justify-between text-sm">
{(cfg?.registrationEnabled ?? true) && (
<button type="button" onClick={() => switchMode('register')} className={linkBtn} data-testid="auth-show-register">Create account</button>
)}
<button type="button" onClick={() => switchMode('forgot')} className={linkBtn + ' ml-auto'} data-testid="auth-show-forgot">Forgot password?</button>
</div>
</form>
);
}
// ── Register ───────────────────────────────────────────────
function RegisterForm({ cfg, onErr, onOk, switchMode }: {
cfg: PublicConfigOk | undefined;
onErr: (s: string) => void;
onOk: (s: string) => void;
switchMode: (m: Mode) => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
onErr(''); onOk('');
if (!name || !email || password.length < 8) { onErr('Fill all fields (password 8+ chars)'); return; }
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
setBusy(true);
try {
const body: Record<string, string> = { name, email, password };
if (turnstileToken) body.turnstileToken = turnstileToken;
const r = await api.post<{ message?: string; needsVerification?: boolean; token?: string }>('/api/auth/register', body);
if (r.needsVerification) {
onOk('Account created. Check your email to verify.');
} else if (r.token) {
onOk('Account created. Signing you in…');
setTimeout(() => { window.location.href = '/'; }, 800);
} else {
onOk(r.message || 'Account created');
}
switchMode('login');
} catch (e) {
onErr((e as ApiError).message || 'Registration failed');
} finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="space-y-3" data-testid="auth-register-form">
<h2 className="text-lg font-semibold text-center">Create account</h2>
<div><label className={label}>Full name</label><input type="text" required autoFocus className={input} value={name} onChange={(e) => setName(e.target.value)} data-testid="auth-reg-name" /></div>
<div><label className={label}>Email</label><input type="email" required className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-reg-email" /></div>
<div><label className={label}>Password (8+ characters)</label><input type="password" required minLength={8} className={input} value={password} onChange={(e) => setPassword(e.target.value)} data-testid="auth-reg-password" /></div>
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-reg-submit">
{busy ? 'Creating…' : 'Create account'}
</button>
<div className="text-center">
<button type="button" onClick={() => switchMode('login')} className={linkBtn}>Back to sign in</button>
</div>
</form>
);
}
// ── Forgot ─────────────────────────────────────────────────
function ForgotForm({ cfg, onErr, onOk, switchMode }: {
cfg: PublicConfigOk | undefined;
onErr: (s: string) => void;
onOk: (s: string) => void;
switchMode: (m: Mode) => void;
}) {
const [email, setEmail] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
onErr(''); onOk('');
if (!email) { onErr('Enter your email'); return; }
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
setBusy(true);
try {
const body: Record<string, string> = { email };
if (turnstileToken) body.turnstileToken = turnstileToken;
const r = await api.post<{ message?: string }>('/api/auth/forgot-password', body);
onOk(r.message || 'If an account exists, a reset link was sent.');
} catch (e) {
onErr((e as ApiError).message || 'Request failed');
} finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="space-y-3" data-testid="auth-forgot-form">
<h2 className="text-lg font-semibold text-center">Reset password</h2>
<p className="text-xs text-muted-foreground text-center">Enter your email and we'll send a reset link.</p>
<div><label className={label}>Email</label><input type="email" required autoFocus className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-forgot-email" /></div>
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-forgot-submit">
{busy ? 'Sending…' : 'Send reset link'}
</button>
<div className="text-center">
<button type="button" onClick={() => switchMode('login')} className={linkBtn}>Back to sign in</button>
</div>
</form>
);
}

View file

@ -0,0 +1,204 @@
// ============================================================
// BEDSIDE — emergency + rapid-reference pediatric tools.
// Top-level age-to-weight estimation is React + pure shared TS.
// Individual dosing modules port one at a time after parity tests.
//
// The 15 clinical sub-modules (neonatal, airway, cardiac, respiratory,
// ventilation, seizures, sepsis, anaphylaxis, sedation, agitation,
// antiemetics, antimicrobials, burns, toxicology, trauma) stay in the
// vanilla viewer for now. Each one carries weight-based dosing +
// clinical decision content the migration checkpoint explicitly
// flagged as must-not-be-"simplified" by an LLM — they belong in
// dedicated per-module commits alongside the calculators port (Rosner
// BP splines, Fenton LMS, AAP 2022 bilirubin, APLS weights) where
// test vectors can verify byte-for-byte parity.
//
// ============================================================
import { useState } from 'react';
import {
estimateWeightFromAgeMonths,
formatAgeMonths,
parseAgeMonths,
} from '@shared/clinical/calculators';
import { renderBedsideRealPanel, REAL_BEDSIDE_PANELS } from './BedsidePanels';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
const label = 'block text-xs font-medium text-muted-foreground';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
interface Pill {
id: string;
label: string;
icon?: string; // emoji stand-in; font-awesome lives in the legacy shell
summary: string;
}
// Order and labels match public/components/bedside.html exactly.
const PILLS: Pill[] = [
{ id: 'neonatal', label: 'Neonatal', icon: '👶', summary: 'GA classification, AGA/SGA/LGA, prematurity category (Fenton 2013 / WHO).' },
{ id: 'airway', label: 'Airway / RSI', icon: '💨', summary: 'ETT size + depth, RSI induction + paralytic dosing by weight.' },
{ id: 'cardiac', label: 'Cardiac Arrest', icon: '❤️', summary: 'PALS dosing (epinephrine, amiodarone, lidocaine), defibrillation J/kg.' },
{ id: 'respiratory', label: 'Respiratory', icon: '🫁', summary: 'Asthma, bronchiolitis, croup severity + dosing.' },
{ id: 'ventilation', label: 'O₂ & Ventilation', icon: '🌀', summary: 'NC / HFNC / CPAP / BiPAP flow + FiO₂ targets by age.' },
{ id: 'seizure', label: 'Seizures', icon: '🧠', summary: 'Benzodiazepine + second/third-line weight-based dosing.' },
{ id: 'sepsis', label: 'Sepsis & Fever', icon: '🦠', summary: 'Empirical antibiotics + fluid bolus dosing by weight.' },
{ id: 'anaphylaxis', label: 'Anaphylaxis', icon: '💉', summary: 'Epinephrine IM, IV infusion, steroid + antihistamine dosing.' },
{ id: 'sedation', label: 'Sedation', icon: '🛌', summary: 'Procedural sedation regimens — ketamine, propofol, midazolam.' },
{ id: 'agitation', label: 'Agitation', icon: '😤', summary: 'Weight-based haloperidol, olanzapine, lorazepam.' },
{ id: 'antiemetics', label: 'Antiemetics', icon: '💊', summary: 'Ondansetron, metoclopramide, promethazine dosing.' },
{ id: 'antimicrobials', label: 'Antimicrobials', icon: '🧫', summary: 'Common empirical regimens keyed to syndrome + weight.' },
{ id: 'burns', label: 'Burns', icon: '🔥', summary: 'TBSA % (Lund-Browder, Rule of Nines-children), Parkland fluids.' },
{ id: 'toxicology', label: 'Toxicology', icon: '☠️', summary: 'Common toxidromes + antidotes + decontamination windows.' },
{ id: 'trauma', label: 'Trauma', icon: '🩹', summary: 'PECARN, c-spine, blood-product dosing, TXA.' },
];
function BedsideWeightEstimator() {
const [age, setAge] = useState('');
const [formula, setFormula] = useState<'apls' | 'bestguess'>('apls');
const [manualWeight, setManualWeight] = useState('');
const months = parseAgeMonths(age);
const estimate = months == null ? null : estimateWeightFromAgeMonths(months);
const pickedWeight = estimate
? formula === 'bestguess'
? estimate.all.bestGuess
: estimate.all.apls
: null;
const displayedWeight = manualWeight.trim() || (pickedWeight == null ? '' : String(pickedWeight));
function clear() {
setAge('');
setFormula('apls');
setManualWeight('');
}
return (
<section className={card} data-testid="bedside-weight-estimator">
<div>
<h2 className="text-lg font-semibold">Age Weight Estimator</h2>
<p className="text-sm text-muted-foreground">
Shared starting point for Bedside dosing. Uses the same APLS and Best Guess formulas as the legacy app.
</p>
</div>
<div className="grid gap-3 md:grid-cols-[1.2fr_1fr_1fr_auto] md:items-end">
<div className="space-y-1">
<label htmlFor="bedside-react-age" className={label}>Age</label>
<input
id="bedside-react-age"
value={age}
onChange={(event) => setAge(event.target.value)}
placeholder='e.g. "18m", "3y", "2y5m"'
className={input}
data-testid="bedside-age-input"
/>
</div>
<div className="space-y-1">
<label htmlFor="bedside-react-formula" className={label}>Formula</label>
<select
id="bedside-react-formula"
value={formula}
onChange={(event) => {
setFormula(event.target.value as 'apls' | 'bestguess');
setManualWeight('');
}}
className={input}
data-testid="bedside-formula-select"
>
<option value="apls">APLS</option>
<option value="bestguess">Best Guess</option>
</select>
</div>
<div className="space-y-1">
<label htmlFor="bedside-react-weight" className={label}>Weight (kg)</label>
<input
id="bedside-react-weight"
type="number"
min="0.3"
step="0.1"
value={displayedWeight}
onChange={(event) => setManualWeight(event.target.value)}
className={input}
data-testid="bedside-weight-input"
/>
</div>
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
</div>
{age.trim() && months == null ? (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200">
Could not parse age. Try "3y", "18 months", or "15 days".
</div>
) : null}
{estimate && pickedWeight != null ? (
<div className="rounded-lg border border-border bg-muted/40 p-4 text-sm" data-testid="bedside-estimate-result">
<div className="font-semibold">{pickedWeight} kg estimated from {formatAgeMonths(months ?? 0)}</div>
<div className="text-muted-foreground">
APLS: {estimate.all.apls} kg · Best Guess: {estimate.all.bestGuess} kg. You can override the weight field.
</div>
</div>
) : null}
</section>
);
}
function LegacyPanel({ pill }: { pill: Pill }) {
return (
<section className={card} data-testid={'bedside-panel-' + pill.id}>
<div className="flex items-center gap-3">
<span className="text-2xl" aria-hidden>{pill.icon}</span>
<h2 className="text-lg font-semibold">{pill.label}</h2>
</div>
<p className="text-sm text-muted-foreground">{pill.summary}</p>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
<p className="text-amber-900 dark:text-amber-100">
Weight-based calculators for this module run in the legacy viewer while the clinical data is
verified for a direct React port. Open the legacy Bedside tab to use the full dosing flow.
</p>
</div>
<a href="/#bedside" className={btnPrimary + ' inline-block'}>
Open in legacy viewer
</a>
</section>
);
}
export default function Bedside() {
const [active, setActive] = useState<string>(PILLS[0].id);
const pill = PILLS.find((p) => p.id === active) ?? PILLS[0];
return (
<div className="max-w-5xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Bedside</h1>
<p className="text-sm text-muted-foreground">
Emergency and rapid-reference pediatric tools. Weight-based dosing throughout always verify against institutional protocols.
</p>
</header>
<div className="flex flex-wrap gap-2" data-testid="bedside-subnav">
{PILLS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setActive(p.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
(active === p.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'bedside-pill-' + p.id}
>
<span className="mr-1" aria-hidden>{p.icon}</span>
{p.label}
</button>
))}
</div>
<BedsideWeightEstimator />
{REAL_BEDSIDE_PANELS.has(pill.id) ? renderBedsideRealPanel(pill.id) : <LegacyPanel pill={pill} />}
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,641 @@
// ============================================================
// BEDSIDE PANELS (second batch) — neonatal, respiratory,
// ventilation, sepsis, burns. Completes parity with the 15
// vanilla Bedside sub-modules. Drug per-kg + max values ported
// byte-for-byte from public/js/bedside/<module>.js.
// ============================================================
import { useState } from 'react';
import { formatDose } from '@shared/clinical/calculators';
import { neonatalAssess, type Sex } from '@shared/clinical/fenton';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-medium text-muted-foreground';
const th = 'text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground';
const td = 'px-2 py-1.5 border-b border-border align-top text-sm';
function Dose({ label: l }: { label: string }) {
const i = l.indexOf('(');
if (i < 0) return <span className="font-semibold">{l}</span>;
return <span><span className="font-semibold">{l.slice(0, i).trim()}</span>{' '}<span className="text-xs text-muted-foreground">{l.slice(i)}</span></span>;
}
function DrugTable({ children, notes = true }: { children: React.ReactNode; notes?: boolean }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr><th className={th}>Drug</th><th className={th}>Dose</th><th className={th}>Route</th>{notes && <th className={th}>Notes</th>}</tr></thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
function Row({ name, dose, route, notes }: { name: string; dose: React.ReactNode; route: string; notes?: string }) {
return (
<tr>
<td className={td + ' font-semibold'} dangerouslySetInnerHTML={{ __html: name }} />
<td className={td}>{dose}</td>
<td className={td + ' text-xs'}>{route}</td>
{notes !== undefined && <td className={td + ' text-xs text-muted-foreground'} dangerouslySetInnerHTML={{ __html: notes }} />}
</tr>
);
}
// ── Neonatal ────────────────────────────────────────────────
export function NeonatalPanel() {
const [weeks, setWeeks] = useState('');
const [days, setDays] = useState('0');
const [wtG, setWtG] = useState('');
const [sex, setSex] = useState<Sex>('male');
const [kgForNrp, setKgForNrp] = useState('');
const [apgarScores, setApgarScores] = useState<Record<string, number>>({ appearance: 2, pulse: 2, grimace: 2, activity: 2, respiration: 2 });
const weeksNum = Number.parseInt(weeks, 10);
const daysNum = Number.parseInt(days, 10) || 0;
const wtNum = Number.parseFloat(wtG);
const validAssess = Number.isFinite(weeksNum) && weeksNum >= 22 && weeksNum <= 44 && Number.isFinite(wtNum) && wtNum > 0;
const assess = validAssess ? neonatalAssess(weeksNum, daysNum, wtNum, sex) : null;
const kg = Number.parseFloat(kgForNrp);
const validKg = Number.isFinite(kg) && kg > 0;
const epiIvLow = validKg ? Math.round(kg * 0.01 * 100) / 100 : 0;
const epiIvHigh = validKg ? Math.round(kg * 0.03 * 100) / 100 : 0;
const epiEtLow = validKg ? Math.round(kg * 0.05 * 100) / 100 : 0;
const epiEtHigh = validKg ? Math.round(kg * 0.1 * 100) / 100 : 0;
const ns = validKg ? Math.round(kg * 10) : 0;
const d10 = validKg ? Math.round(kg * 2 * 10) / 10 : 0;
const apgarTotal = Object.values(apgarScores).reduce((s, v) => s + v, 0);
const apgarSeverity = apgarTotal >= 7 ? 'Reassuring' : apgarTotal >= 4 ? 'Moderately depressed' : 'Severely depressed';
const apgarColor = apgarTotal >= 7 ? 'text-green-600 bg-green-50' : apgarTotal >= 4 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
const apgarGuidance = apgarTotal >= 7
? 'Routine newborn care. Continue reassessment. Repeat at 5 min.'
: apgarTotal >= 4
? 'Stimulate, clear airway, warm. Give O₂ if cyanotic. Ventilate with PPV if HR <100 or apneic/gasping. Reassess q30 sec.'
: 'Full NRP pathway — PPV immediately. Intubate if PPV ineffective. Chest compressions if HR <60. Epinephrine and volume per NRP.';
return (
<section className={card} data-testid="bedside-panel-neonatal">
<h2 className="text-lg font-semibold">Neonatal Assessment + NRP + Apgar</h2>
{/* Assessment */}
<h3 className="text-sm font-semibold">Gestational age + size assessment (Fenton 2013)</h3>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4 max-w-xl">
<div><label className={label}>GA weeks</label><input type="number" min="22" max="44" className={input} value={weeks} onChange={(e) => setWeeks(e.target.value)} data-testid="neo-weeks" /></div>
<div><label className={label}>GA days (0-6)</label><input type="number" min="0" max="6" className={input} value={days} onChange={(e) => setDays(e.target.value)} data-testid="neo-days" /></div>
<div><label className={label}>Birth wt (g)</label><input type="number" min="200" max="7000" className={input} value={wtG} onChange={(e) => setWtG(e.target.value)} data-testid="neo-weight" /></div>
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="neo-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
</div>
{assess && (
<div className="grid gap-3 sm:grid-cols-2" data-testid="neo-result">
<div className="rounded-md border p-3" style={{ borderColor: assess.gaClass.color + '55', background: assess.gaClass.color + '10' }}>
<div className="text-xs text-muted-foreground">Gestational Age</div>
<div className="text-base font-bold" style={{ color: assess.gaClass.color }}>{assess.gaClass.label}</div>
<div className="text-xs text-muted-foreground">{weeksNum} wk {daysNum} d ({assess.gaDecimal.toFixed(1)} wk)</div>
</div>
<div className="rounded-md border p-3" style={{ borderColor: assess.weightClass.color + '55', background: assess.weightClass.color + '10' }}>
<div className="text-xs text-muted-foreground">Weight for Gestational Age</div>
<div className="text-base font-bold" style={{ color: assess.weightClass.color }}>{assess.weightClass.label}</div>
<div className="text-xs text-muted-foreground">{assess.percentile.toFixed(1)}th percentile · {assess.weightClass.detail}</div>
</div>
<div className="rounded-md border p-3" style={{ borderColor: assess.bwClass.color + '55', background: assess.bwClass.color + '10' }}>
<div className="text-xs text-muted-foreground">Birth Weight Category</div>
<div className="text-base font-bold" style={{ color: assess.bwClass.color }}>{assess.bwClass.label}</div>
<div className="text-xs text-muted-foreground">{wtNum} g ({(wtNum / 1000).toFixed(2)} kg)</div>
</div>
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
<div className="text-xs text-muted-foreground uppercase tracking-wide">Fenton ({sex})</div>
<div className="space-y-0.5 mt-1">
<div><strong>Expected weight (M):</strong> {assess.expectedWeight} g</div>
<div><strong>Z-score:</strong> {assess.z.toFixed(2)}</div>
<div><strong>Percentile:</strong> {assess.percentile.toFixed(1)}%</div>
</div>
</div>
</div>
)}
{/* NRP pathway */}
<h3 className="text-sm font-semibold mt-3">NRP pathway (AHA/AAP 8th ed 2020)</h3>
<div className="space-y-2 text-sm">
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">BIRTH ASSESS (first 30 sec)</div><div className="text-xs text-muted-foreground">Term? Tone? Breathing/crying? All yes routine care. Any no warm, dry, stimulate, clear airway PRN, evaluate HR + resp.</div></div>
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">HR &lt;100 OR apneic/gasping (60 s)</div><div className="text-xs text-muted-foreground"><strong>Start PPV</strong> 40-60 breaths/min, room air for term / 21-30% for preterm. Attach SpO (right hand) ± ECG. MR SOPA if ineffective.</div></div>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">HR &lt;100 after 30 s effective PPV</div><div className="text-xs text-muted-foreground">Reassess ventilation ensure chest rise. Consider increasing FiO, intubation, or LMA. Continue PPV.</div></div>
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">HR &lt;60 after 30 s effective PPV</div><div className="text-xs text-muted-foreground"><strong>Intubate + chest compressions</strong> 3:1 ratio (90 compressions + 30 breaths/min), FiO 100%, lower 1/3 sternum, depth 1/3 AP chest.</div></div>
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">HR &lt;60 despite compressions + PPV × 60 s</div><div className="text-xs text-muted-foreground"><strong>Epinephrine 1:10,000 (0.1 mg/mL):</strong> IV/IO 0.01-0.03 mg/kg (0.1-0.3 mL/kg) preferred. ETT 0.05-0.1 mg/kg. Repeat q3-5 min. Hypovolemia: <strong>NS 10 mL/kg IV/IO over 5-10 min</strong>.</div></div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
<div className="rounded-md bg-muted/40 p-2"><strong>Target SpO (preductal):</strong><br />1 min 60-65% · 2 min 65-70% · 3 min 70-75% · 4 min 75-80% · 5 min 80-85% · 10 min 85-95%</div>
<div className="rounded-md bg-muted/40 p-2"><strong>Initial ETT size:</strong><br />&lt;1 kg / &lt;28 wk: 2.5 · 1-2 kg / 28-34 wk: 3.0 · 2-3 kg / 34-38 wk: 3.5 · &gt;3 kg / &gt;38 wk: 3.5-4.0</div>
<div className="rounded-md bg-muted/40 p-2"><strong>ETT depth (lip):</strong> ~6 + weight(kg) cm</div>
</div>
<h3 className="text-sm font-semibold mt-3">NRP drug doses</h3>
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={kgForNrp} onChange={(e) => setKgForNrp(e.target.value)} data-testid="nrp-weight" /></div>
{validKg ? (
<DrugTable>
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiIvLow}-${epiIvHigh} mg, ${Math.round(epiIvLow * 10) / 10}-${Math.round(epiIvHigh * 10) / 10} mL (0.01-0.03 mg/kg = 0.1-0.3 mL/kg)`} />} route="IV / IO" notes="Preferred route. Repeat q3-5 min." />
<Row name="Epinephrine 1:10,000" dose={<Dose label={`${epiEtLow}-${epiEtHigh} mg, ${Math.round(epiEtLow * 10) / 10}-${Math.round(epiEtHigh * 10) / 10} mL (0.05-0.1 mg/kg = 0.5-1 mL/kg)`} />} route="ETT" notes="While IV being placed." />
<Row name="Normal saline" dose={<Dose label={`${ns} mL (10 mL/kg)`} />} route="IV / IO" notes="Over 5-10 min for volume. Repeat PRN." />
<Row name="Dextrose 10%" dose={<Dose label={`${d10} mL (2 mL/kg = 0.2 g/kg)`} />} route="IV slow push" notes="For documented hypoglycemia. Then D10 infusion 4-6 mg/kg/min." />
</DrugTable>
) : <p className="text-xs text-destructive">Enter weight (kg) to see NRP doses.</p>}
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
<strong>Concentration note:</strong> NRP uses epinephrine <strong>1:10,000</strong> (0.1 mg/mL). NOT 1:1000 (1 mg/mL) that is IM for anaphylaxis / older patients.
</div>
{/* Apgar */}
<h3 className="text-sm font-semibold mt-3">Apgar score</h3>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-5 text-xs">
{[
['appearance', 'Appearance', ['Blue/pale', 'Body pink, extremities blue', 'All pink']],
['pulse', 'Pulse', ['Absent', '<100 bpm', '≥100 bpm']],
['grimace', 'Grimace', ['No response', 'Grimace', 'Cough/sneeze']],
['activity', 'Activity', ['Limp', 'Some flexion', 'Active motion']],
['respiration', 'Respiration', ['Absent', 'Slow/irregular', 'Good/crying']],
].map(([key, lab, options]) => (
<div key={key as string}>
<label className={label}>{lab}</label>
<select className={input} value={apgarScores[key as string]} onChange={(e) => setApgarScores({ ...apgarScores, [key as string]: Number(e.target.value) })} data-testid={'apgar-' + key}>
{(options as string[]).map((o, i) => <option key={i} value={i}>{i} {o}</option>)}
</select>
</div>
))}
</div>
<div className={'rounded-md p-3 ' + apgarColor} data-testid="apgar-result">
<div className="text-base font-bold">Apgar: {apgarTotal}/10 {apgarSeverity}</div>
<div className="text-xs text-muted-foreground mt-1">{apgarGuidance}</div>
</div>
<div className="text-xs text-muted-foreground italic">
Fenton TR, Kim JH. BMC Pediatr 2013;13:59 · NRP 8th ed (AHA/AAP 2020) · Apgar is a description of status <strong>never</strong> delay resuscitation while scoring.
</div>
</section>
);
}
// ── Respiratory ─────────────────────────────────────────────
export function RespiratoryPanel() {
const [mode, setMode] = useState<'asthma' | 'pram' | 'croup' | 'bronch'>('asthma');
const [weight, setWeight] = useState('');
const wt = Number.parseFloat(weight);
const valid = Number.isFinite(wt) && wt > 0;
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
const [asthmaSev, setAsthmaSev] = useState<'mild' | 'moderate' | 'severe' | null>(null);
// PRAM inputs (0-12 total)
const [pram, setPram] = useState({ spo2: 0, retractions: 0, scalene: 0, air: 0, wheeze: 0 });
const pramTotal = Object.values(pram).reduce((s, v) => s + v, 0);
const pramSev = pramTotal <= 3 ? 'Mild' : pramTotal <= 7 ? 'Moderate' : 'Severe';
const pramColor = pramTotal <= 3 ? 'text-green-600 bg-green-50' : pramTotal <= 7 ? 'text-amber-600 bg-amber-50' : 'text-destructive bg-red-50';
// Croup / Westley (0-17)
const [croup, setCroup] = useState({ conscious: 0, cyanosis: 0, stridor: 0, air: 0, retractions: 0 });
const croupTotal = Object.values(croup).reduce((s, v) => s + v, 0);
const croupSev = croupTotal <= 2 ? 'Mild' : croupTotal <= 5 ? 'Moderate' : croupTotal <= 11 ? 'Severe' : 'Impending Respiratory Failure';
const croupColor = croupTotal <= 2 ? 'text-green-600 bg-green-50' : croupTotal <= 5 ? 'text-amber-600 bg-amber-50' : croupTotal <= 11 ? 'text-destructive bg-red-50' : 'text-red-900 bg-red-100';
// Bronchiolitis inputs
const [bronch, setBronch] = useState({ age: 'gte12w', spo2: 'ok', hydration: 'ok', distress: 'mild' });
const bronchAdmit = bronch.distress === 'severe' || bronch.spo2 === 'low' || bronch.hydration === 'poor' || bronch.age === 'lt12w';
return (
<section className={card} data-testid="bedside-panel-respiratory">
<h2 className="text-lg font-semibold">Respiratory</h2>
<div className="flex gap-2 flex-wrap">
{(['asthma', 'pram', 'croup', 'bronch'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMode(m)} className={'px-3 py-1 rounded-full text-xs font-medium border ' + (mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')} data-testid={'resp-mode-' + m}>
{m === 'asthma' ? 'Asthma' : m === 'pram' ? 'PRAM' : m === 'croup' ? 'Croup (Westley)' : 'Bronchiolitis'}
</button>
))}
</div>
{mode !== 'pram' && mode !== 'bronch' && (
<div className="max-w-xs"><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resp-weight" /></div>
)}
{/* ASTHMA */}
{mode === 'asthma' && (
<>
<div className="flex gap-2">
{(['mild', 'moderate', 'severe'] as const).map((s) => (
<button key={s} type="button" onClick={() => setAsthmaSev(s)} className={'px-3 py-1 rounded text-xs font-medium border ' + (asthmaSev === s ? (s === 'mild' ? 'bg-green-600 text-white' : s === 'moderate' ? 'bg-amber-500 text-white' : 'bg-destructive text-white') : 'bg-muted')}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
{asthmaSev && !valid && <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p>}
{asthmaSev === 'mild' && valid && (
<>
<p className="text-xs text-muted-foreground">Speaks in sentences, no accessory muscle use, SpO 94%</p>
<DrugTable>
<Row name="Albuterol (MDI)" dose="4-8 puffs via spacer" route="Inhaled" notes="q20min × 3 doses, then q1-4h" />
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses" />
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV" notes="Single dose, or 2 days" />
<Row name="Prednisolone" dose={<Dose label={`${f(1, 60)!.label}/day`} />} route="PO" notes="Alternative: 3-5 day course" />
</DrugTable>
</>
)}
{asthmaSev === 'moderate' && valid && (
<>
<p className="text-xs text-muted-foreground">Speaks in phrases, some accessory muscle use, SpO 90-93%</p>
<DrugTable>
<Row name="Albuterol (neb)" dose={<Dose label={`${f(0.15, 5, 'mg')!.label} (min 2.5 mg)`} />} route="Nebulized" notes="q20min × 3 doses, then continuous if needed" />
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="PO/IV/IM" notes="Single dose" />
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="NC/mask" notes="Titrate to effect" />
</DrugTable>
</>
)}
{asthmaSev === 'severe' && valid && (
<>
<p className="text-xs text-muted-foreground">Speaks in words only, significant accessory muscle use, SpO &lt;90%. Consider ICU.</p>
<DrugTable>
<Row name="Albuterol continuous" dose={<Dose label={`${f(0.5, 20, 'mg')!.label}/hr`} />} route="Continuous neb" notes="Or 0.15-0.3 mg/kg q20min" />
<Row name="Ipratropium" dose={wt < 20 ? '250 mcg' : '500 mcg'} route="Nebulized" notes="q20min × 3 doses with albuterol" />
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route="IV" notes="Or methylprednisolone 2 mg/kg IV (max 60 mg)" />
<Row name="Magnesium sulfate" dose={<Dose label={`${f(50, 2000)!.label} IV over 20 min`} />} route="IV" notes="Single dose, monitor BP" />
<Row name="Epinephrine (IM)" dose={<Dose label={`${f(0.01, 0.5)!.label} (1:1000)`} />} route="IM" notes="If impending arrest / no IV access" />
<Row name="Terbutaline" dose={<Dose label={`${f(0.01, 0.4)!.label} SC/IV`} />} route="SC/IV" notes="Then 0.1-10 mcg/kg/min infusion" />
<Row name="O₂ supplemental" dose="Target SpO₂ ≥94%" route="High flow / NIPPV" notes="Consider BiPAP/CPAP" />
</DrugTable>
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
<strong>Continuous monitoring.</strong> Consider ICU admission. If no response to magnesium terbutaline infusion. If impending respiratory failure intubation (ketamine preferred induction agent).
</div>
</>
)}
<div className="text-xs text-muted-foreground italic">NAEPP/GINA guidelines. Always use clinical judgment.</div>
</>
)}
{/* PRAM */}
{mode === 'pram' && (
<>
<p className="text-sm text-muted-foreground">Pediatric Respiratory Assessment Measure (PRAM) for asthma exacerbation severity (0-12).</p>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
{[
['spo2', 'SpO₂', ['≥95% (0)', '92-94% (1)', '<92% (2)']],
['retractions', 'Suprasternal retractions', ['Absent (0)', 'Present (2)']],
['scalene', 'Scalene muscle use', ['Absent (0)', 'Present (2)']],
['air', 'Air entry', ['Normal (0)', 'Mild ↓ at bases (1)', 'Widespread ↓ (2)', 'Absent/minimal (3)']],
['wheeze', 'Wheezing', ['Absent (0)', 'Expiratory only (1)', 'Ins+exp (2)', 'Audible without stethoscope/silent chest (3)']],
].map(([key, lab, options]) => (
<div key={key as string}>
<label className={label}>{lab}</label>
<select className={input} value={pram[key as keyof typeof pram]} onChange={(e) => setPram({ ...pram, [key as string]: Number(e.target.value) })} data-testid={'pram-' + key}>
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
</select>
</div>
))}
</div>
<div className={'rounded-md p-3 ' + pramColor} data-testid="pram-result">
<div className="text-base font-bold">PRAM Score: {pramTotal}/12 {pramSev}</div>
<div className="text-xs text-muted-foreground mt-1">Mild (0-3): outpatient management. Moderate (4-7): consider oral steroids + frequent bronchodilators. Severe (8-12): aggressive treatment, consider ICU.</div>
</div>
</>
)}
{/* CROUP */}
{mode === 'croup' && (
<>
<p className="text-sm text-muted-foreground">Westley croup score (0-17).</p>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
{[
['conscious', 'Level of consciousness', ['Normal (0)', 'Disoriented (5)']],
['cyanosis', 'Cyanosis', ['None (0)', 'With agitation (4)', 'At rest (5)']],
['stridor', 'Stridor', ['None (0)', 'With agitation (1)', 'At rest (2)']],
['air', 'Air entry', ['Normal (0)', 'Decreased (1)', 'Severely decreased (2)']],
['retractions', 'Retractions', ['None (0)', 'Mild (1)', 'Moderate (2)', 'Severe (3)']],
].map(([key, lab, options]) => (
<div key={key as string}>
<label className={label}>{lab}</label>
<select className={input} value={croup[key as keyof typeof croup]} onChange={(e) => setCroup({ ...croup, [key as string]: Number(e.target.value) })} data-testid={'croup-' + key}>
{(options as string[]).map((o, i) => <option key={i} value={i}>{o}</option>)}
</select>
</div>
))}
</div>
<div className={'rounded-md p-3 ' + croupColor} data-testid="croup-result">
<div className="text-base font-bold">Westley: {croupTotal}/17 {croupSev}</div>
<div className="text-xs text-muted-foreground mt-1">Mild 2 · Moderate 3-5 · Severe 6-11 · Impending failure 12.</div>
</div>
{valid && (
<DrugTable>
<Row name="Dexamethasone" dose={<Dose label={f(0.6, 16)!.label} />} route={croupTotal <= 2 ? 'PO' : croupTotal <= 5 ? 'PO/IM' : 'IV/IM'} notes="Preferred corticosteroid; single dose" />
{croupTotal > 2 && <Row name="Racemic epinephrine" dose="0.5 mL of 2.25% solution" route="Nebulized" notes="May repeat q15-20min, observe 2-4 h" />}
{croupTotal > 2 && <Row name="Nebulized epinephrine" dose="0.5 mL/kg of 1:1000 (max 5 mL)" route="Nebulized" notes="Alternative to racemic" />}
{croupTotal > 5 && <Row name="Heliox" dose="70:30 or 80:20" route="Face mask" notes="Consider if not responding" />}
</DrugTable>
)}
</>
)}
{/* BRONCHIOLITIS */}
{mode === 'bronch' && (
<>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-xl">
<div><label className={label}>Age</label><select className={input} value={bronch.age} onChange={(e) => setBronch({ ...bronch, age: e.target.value })}><option value="lt12w">&lt;12 weeks (high risk)</option><option value="gte12w">12 weeks</option></select></div>
<div><label className={label}>SpO</label><select className={input} value={bronch.spo2} onChange={(e) => setBronch({ ...bronch, spo2: e.target.value })}><option value="ok">90%</option><option value="low">&lt;90%</option></select></div>
<div><label className={label}>Hydration</label><select className={input} value={bronch.hydration} onChange={(e) => setBronch({ ...bronch, hydration: e.target.value })}><option value="ok">Adequate</option><option value="poor">Poor oral intake</option></select></div>
<div><label className={label}>Distress</label><select className={input} value={bronch.distress} onChange={(e) => setBronch({ ...bronch, distress: e.target.value })}><option value="mild">Mild</option><option value="moderate">Moderate</option><option value="severe">Severe</option></select></div>
</div>
<div className={'rounded-md p-3 ' + (bronchAdmit ? 'text-destructive bg-red-50' : 'text-green-600 bg-green-50')} data-testid="bronch-result">
<div className="text-base font-bold">{bronchAdmit ? 'Admit / Observe' : 'Likely Safe for Discharge'}</div>
{bronch.age === 'lt12w' && <div className="text-xs text-destructive mt-1"> Age &lt;12 weeks high risk for apnea. Monitor closely.</div>}
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
<strong>NOT recommended (AAP 2014/2023):</strong> Albuterol/salbutamol (no benefit), epinephrine (no evidence), systemic corticosteroids (no benefit), antibiotics (unless bacterial co-infection), chest physiotherapy.
</div>
<div className="text-xs text-muted-foreground italic">AAP Clinical Practice Guideline: Management of Bronchiolitis in Infants and Children (2014, reaffirmed 2023). RSV most common (50-80%).</div>
</>
)}
</section>
);
}
// ── Ventilation (O₂ escalation + vent settings reference) ───
export function VentilationPanel() {
const [weight, setWeight] = useState('');
const [age, setAge] = useState('');
const wt = Number.parseFloat(weight);
const ageY = Number.parseFloat(age);
const validWt = Number.isFinite(wt) && wt > 0;
const hfLow = validWt ? Math.round(wt * 1 * 10) / 10 : 0;
const hfHigh = validWt ? Math.round(wt * 2 * 10) / 10 : 0;
const tvLow = validWt ? Math.round(wt * 6 * 10) / 10 : 0;
const tvHigh = validWt ? Math.round(wt * 8 * 10) / 10 : 0;
const hasAge = Number.isFinite(ageY) && ageY >= 0;
const rate = hasAge
? ageY < 0.1 ? '30-40'
: ageY < 1 ? '25-35'
: ageY < 5 ? '20-25'
: ageY < 12 ? '16-20'
: '12-16'
: '';
return (
<section className={card} data-testid="bedside-panel-ventilation">
<h2 className="text-lg font-semibold">O &amp; Ventilation</h2>
<div className="grid gap-2 grid-cols-2 max-w-md">
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="vent-weight" /></div>
<div><label className={label}>Age (years)</label><input type="number" min="0" step="0.5" className={input} value={age} onChange={(e) => setAge(e.target.value)} data-testid="vent-age" /></div>
</div>
<h3 className="text-sm font-semibold">Target SpO</h3>
<DrugTable notes>
<Row name="Most children" dose="94-98%" route="—" notes="Normal" />
<Row name="Bronchiolitis (AAP 2014/2023)" dose="≥90%" route="—" notes="Don't chase higher saturations" />
<Row name="Chronic lung disease / CF" dose="90-94%" route="—" notes="Avoid hyperoxia in CO₂ retainers" />
<Row name="Preterm neonate" dose="90-95%" route="—" notes="Minimize ROP risk" />
<Row name="Term neonate (min of life)" dose="Per NRP ladder" route="—" notes="1 min 60-65% · 10 min 85-95%" />
</DrugTable>
<h3 className="text-sm font-semibold mt-2">Escalation ladder</h3>
<div className="space-y-2 text-sm">
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">1. Nasal cannula (low-flow)</div><div className="text-xs text-muted-foreground"><strong>0.5-6 L/min</strong> · FiO ~24-40% · comfortable, no humidification. Good for mild hypoxia.</div></div>
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">2. Simple face mask</div><div className="text-xs text-muted-foreground"><strong>6-10 L/min</strong> · FiO 35-60%. Must keep flow &gt;6 L/min to flush CO.</div></div>
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">3. Non-rebreather mask</div><div className="text-xs text-muted-foreground"><strong>10-15 L/min</strong> · FiO 60-90%. Reservoir bag must stay inflated.</div></div>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">4. High-flow nasal cannula (HFNC)</div><div className="text-xs text-muted-foreground"><strong>{validWt ? `1-2 L/kg/min = ${hfLow}-${hfHigh} L/min` : '1-2 L/kg/min'}</strong> · heated + humidified · FiO 30-100% titratable · generates ~2-5 cmHO PEEP. Reassess at 1-2 h.</div></div>
<div className="rounded-md border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">5. Non-invasive (CPAP / BiPAP)</div><div className="text-xs text-muted-foreground">CPAP 5-10 cmHO · BiPAP IPAP 10-14 / EPAP 5. Needs cooperative patient, intact airway reflexes, no copious secretions.</div></div>
<div className="rounded-md border-l-4 border-destructive bg-red-100 dark:bg-red-950/40 p-3"><div className="font-semibold">6. Intubate + mechanical ventilation</div><div className="text-xs text-muted-foreground">When NIV fails, airway compromised, apnea, or GCS 8. See Airway tab for RSI drugs.</div></div>
</div>
<h3 className="text-sm font-semibold mt-2">Bag-Valve-Mask (BVM)</h3>
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs space-y-1">
<div><strong>When:</strong> apnea, bradycardia (HR &lt;60 neonate; inadequate breathing at any age), during resuscitation.</div>
<div><strong>Rate:</strong> Newborn 40-60/min · Infant-child 20-30/min · Adolescent 10-12/min (1 breath q5-6 sec).</div>
<div><strong>Tidal volume:</strong> 6-8 mL/kg gentle chest rise only. Avoid over-ventilation.</div>
<div><strong>Technique:</strong> head tilt / jaw thrust, E-C or 2-thumb mask seal, squeeze 1 sec, release fully.</div>
<div><strong>Not ventilating?</strong> MR SOPA Mask reseal, Reposition airway, Suction, Open mouth, Pressure , Alternative airway.</div>
</div>
<h3 className="text-sm font-semibold mt-2">Mechanical vent starting settings</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
<div><strong>Mode:</strong> Volume-control OR Pressure-control. PRVC / SIMV-PS hybrids.</div>
<div><strong>Tidal volume:</strong> <strong>{validWt ? `${tvLow}-${tvHigh} mL` : '6-8 mL/kg'}</strong> (6-8 mL/kg). Use 4-6 mL/kg for ARDS.</div>
<div><strong>Rate:</strong> {rate ? `${rate}/min (age ${ageY} yr)` : 'Newborn 30-40 · Infant 25-35 · Child 16-20 · Adolescent 12-16'}.</div>
<div><strong>PEEP:</strong> start 5 cmHO. Increase to 8-12+ for refractory hypoxia.</div>
<div><strong>FiO:</strong> start 100%, wean rapidly to lowest that maintains target SpO.</div>
<div><strong>I:E ratio:</strong> 1:2 normally; 1:3-4 for obstructive disease.</div>
<div><strong>Plateau pressure:</strong> keep &lt;30 cmHO (ideally &lt;28).</div>
</div>
<h3 className="text-sm font-semibold mt-2">Adjusting for gas exchange</h3>
<DrugTable notes>
<Row name="Low SpO₂ (oxygenation)" dose="↑ FiO₂" route="—" notes="Then ↑ PEEP (recruits collapsed alveoli)" />
<Row name="↑ PCO₂ (ventilation)" dose="↑ Rate" route="—" notes="Then ↑ Tidal volume" />
<Row name="↓ PCO₂ (over-ventilating)" dose="↓ Rate" route="—" notes="Then ↓ Tidal volume" />
<Row name="High peak pressure" dose="Check tube / compliance" route="—" notes="Suction, bronchodilator, lower TV" />
<Row name="Auto-PEEP (asthma, bronch)" dose="↓ Rate, ↑ Te" route="—" notes="Disconnect + bag briefly if critical" />
</DrugTable>
<div className="rounded-md bg-green-50 dark:bg-green-950/30 p-3 text-xs text-green-900 dark:text-green-100">
<strong>Mental model:</strong> Oxygenation is mostly <strong>FiO + PEEP</strong>. Ventilation (CO) is mostly <strong>rate + tidal volume</strong>. Obstructive (asthma, bronchiolitis) long expiratory time, permissive hypercapnia. Restrictive (ARDS) low TV, high PEEP, permissive hypercapnia + hypoxia.
</div>
<div className="text-xs text-muted-foreground italic">AAP / PALS / AARC guidance.</div>
</section>
);
}
// ── Sepsis ──────────────────────────────────────────────────
export function SepsisPanel() {
const [weight, setWeight] = useState('');
const [age, setAge] = useState<'neonate' | 'infant' | 'child'>('child');
const wt = Number.parseFloat(weight);
const valid = Number.isFinite(wt) && wt > 0;
const f = (perKg: number, max: number | null, unit = 'mg') => (valid ? formatDose(wt, perKg, max, unit) : null);
const ageLbl = age === 'neonate' ? 'Neonate (0-28 d)' : age === 'infant' ? 'Young infant (29 d - 3 mo)' : 'Older child / adolescent';
const bolus = valid ? Math.round(wt * 20) : null;
return (
<section className={card} data-testid="bedside-panel-sepsis">
<h2 className="text-lg font-semibold">Sepsis &amp; Fever</h2>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2 max-w-md">
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="sepsis-weight" /></div>
<div><label className={label}>Age band</label><select className={input} value={age} onChange={(e) => setAge(e.target.value as typeof age)} data-testid="sepsis-age"><option value="neonate">Neonate (0-28 d)</option><option value="infant">Infant (29 d - 3 mo)</option><option value="child">Older child / adolescent</option></select></div>
</div>
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive">
Sepsis approach {ageLbl}{valid ? `, ${wt} kg` : ''}
</div>
<h3 className="text-sm font-semibold mt-2">Definition Phoenix Sepsis Criteria (JAMA 2024)</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
<div><strong>Sepsis</strong> = suspected or confirmed infection + Phoenix Score 2 (organ dysfunction across respiratory, cardiovascular, coagulation, neurological).</div>
<div><strong>Septic shock</strong> = sepsis + cardiovascular dysfunction (vasoactive support, or lactate 5, or MAP for age).</div>
<div className="text-muted-foreground italic">Previous SIRS-based criteria (Goldstein 2005) are now superseded.</div>
</div>
<h3 className="text-sm font-semibold mt-2">Red flags</h3>
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-900 dark:text-red-100">
Abnormal behavior / mentation · Fever + ill-appearance · Tachycardia out of proportion to fever · Prolonged cap refill (&gt;3 s) · Cold/mottled extremities · Weak pulses or wide pulse pressure ("warm shock") · Hypotension is a <strong>LATE</strong> sign · Any immune compromise / indwelling line.
</div>
<h3 className="text-sm font-semibold mt-2">Empirical therapy {ageLbl}</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs">
{age === 'neonate' && <><strong>Workup (full sepsis eval):</strong> CBC+diff, CRP, blood culture, UA+urine culture (cath), <strong>LP</strong> (CSF+HSV PCR), CXR if respiratory sx, procalcitonin. <strong>Early-onset</strong> (&lt;72 h): GBS, E. coli, Listeria. <strong>Late-onset</strong> (&gt;72 h): CoNS, S. aureus, gram-negs, Candida.</>}
{age === 'infant' && <><strong>Workup:</strong> Use validated rules PECARN, Aronson, Rochester, Step-by-Step. CBC+ANC, procalcitonin/CRP, blood culture, UA+urine culture. Many warrant LP + admission + empiric abx. <strong>Coverage:</strong> GBS, E. coli, Listeria (up to ~6 wk), S. pneumo, N. meningitidis, H. flu, Salmonella.</>}
{age === 'child' && <><strong>Recognition:</strong> Phoenix score or clinical concern + suspected infection. <strong>Workup:</strong> CBC, CRP, procalcitonin, blood cx (+site-specific), lactate, blood gas, glucose, electrolytes, coags, LP if CNS concern. Source-directed imaging.</>}
</div>
{valid && (
<DrugTable>
{age === 'neonate' && <>
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="q8-12h. Covers GBS, Listeria, Enterococcus." />
<Row name="Gentamicin" dose={<Dose label={f(4, 120)!.label} />} route="IV" notes="q24-48h. Monitor levels." />
<Row name="Cefotaxime (add)" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="If meningitis or gram-neg concern." />
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="HSV risk: maternal lesions, vesicles, seizures, CSF pleocytosis." />
</>}
{age === 'infant' && <>
<Row name="Ceftriaxone" dose={<Dose label={f(75, 2000)!.label} />} route="IV / IM" notes="q24h (100 mg/kg/day divided q12h for meningitis). <strong>Avoid &lt;28 d</strong> if hyperbilirubinemia." />
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="If &lt;6 wk: add for Listeria coverage." />
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="If severe / MRSA risk / meningitis." />
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="&lt;6 wk with suspicion of HSV." />
</>}
{age === 'child' && <>
<Row name="Ceftriaxone" dose={<Dose label={f(50, 2000)!.label} />} route="IV" notes="q24h (100 mg/kg/day divided for meningitis)." />
<Row name="Vancomycin" dose={<Dose label={f(15, 1000)!.label} />} route="IV" notes="q6h. If severe, indwelling line, or MRSA prevalence &gt;10%." />
<Row name="Piperacillin-tazobactam" dose={<Dose label={f(100, 4500)!.label} />} route="IV" notes="If intra-abdominal / neutropenic." />
<Row name="Clindamycin" dose={<Dose label={f(10, 900)!.label} />} route="IV" notes="Adjunct for toxic shock syndrome (toxin suppression)." />
<Row name="Acyclovir" dose={<Dose label={f(20, 1200)!.label} />} route="IV q8h" notes="If HSV CNS concern." />
</>}
</DrugTable>
)}
<h3 className="text-sm font-semibold mt-2">First-hour bundle (SSC Peds 2020)</h3>
<div className="space-y-2 text-sm">
<div className="rounded-md border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-950/30 p-3"><div className="font-semibold">0-5 min Recognize</div><div className="text-xs text-muted-foreground">Screen, sepsis huddle/activation, ABCs, O to SpO &gt;94%, warm.</div></div>
<div className="rounded-md border-l-4 border-purple-500 bg-purple-50 dark:bg-purple-950/30 p-3"><div className="font-semibold">5-15 min Access &amp; labs</div><div className="text-xs text-muted-foreground">Two IVs or IO. Draw blood cx (ideally before abx), lactate, CBC, CMP, coags, blood gas, glucose. UA + culture. Source-specific cultures.</div></div>
<div className="rounded-md border-l-4 border-amber-500 bg-amber-50 dark:bg-amber-950/30 p-3"><div className="font-semibold">15-30 min Fluids</div><div className="text-xs text-muted-foreground">{valid ? <>NS or LR <strong>{bolus} mL</strong> bolus (20 mL/kg) over 5-10 min.</> : <>NS/LR 10-20 mL/kg bolus over 5-10 min.</>} Reassess HR, perfusion, lungs, liver. Repeat up to 40-60 mL/kg; stop if crackles/hepatomegaly.</div></div>
<div className="rounded-md border-l-4 border-green-500 bg-green-50 dark:bg-green-950/30 p-3"><div className="font-semibold">30-60 min Antibiotics + reassess</div><div className="text-xs text-muted-foreground">Broad-spectrum empiric abx within 1 hour (1 h in septic shock). Recheck lactate, perfusion.</div></div>
<div className="rounded-md border-l-4 border-destructive bg-red-50 dark:bg-red-950/30 p-3"><div className="font-semibold">&gt;60 min Fluid-refractory shock</div><div className="text-xs text-muted-foreground">Start vasoactive (<strong>epinephrine 0.05-0.3 mcg/kg/min</strong> cold / <strong>norepinephrine 0.05-0.3 mcg/kg/min</strong> warm). Central/IO access. Stress-dose hydrocortisone {valid ? <><strong>{f(2, 100)!.value} mg</strong> IV (2 mg/kg, max 100 mg)</> : '2 mg/kg IV (max 100 mg)'} if catecholamine-resistant. ICU.</div></div>
</div>
<h3 className="text-sm font-semibold mt-2">Resuscitation targets</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs">
Normal mentation · Cap refill 2 s · Warm extremities · Strong peripheral pulses · UOP 1 mL/kg/hr · MAP 5th %ile for age (&gt;65 mmHg adolescent) · SpO 94% · Lactate trending down.
</div>
<div className="text-xs text-muted-foreground italic">Phoenix Sepsis Criteria (Schlapbach et al., JAMA 2024) · Surviving Sepsis Campaign Pediatric 2020 · AAP pediatric sepsis guidance.</div>
</section>
);
}
// ── Burns ───────────────────────────────────────────────────
// Lund-Browder age-adjusted region percentages ported VERBATIM from
// public/js/bedside/burns.js:10-30.
const LUND_BROWDER: Array<{ key: string; label: string; vals: [number, number, number, number, number]; ageSensitive?: boolean }> = [
{ key: 'head', label: 'Head', vals: [18, 13, 11, 9, 7], ageSensitive: true },
{ key: 'neck', label: 'Neck', vals: [2, 2, 2, 2, 2] },
{ key: 'ant_trunk', label: 'Anterior trunk', vals: [13, 13, 13, 13, 13] },
{ key: 'post_trunk', label: 'Posterior trunk', vals: [13, 13, 13, 13, 13] },
{ key: 'r_buttock', label: 'Right buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'l_buttock', label: 'Left buttock', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'genital', label: 'Genitalia', vals: [1, 1, 1, 1, 1] },
{ key: 'r_uparm', label: 'R upper arm', vals: [4, 4, 4, 4, 4] },
{ key: 'l_uparm', label: 'L upper arm', vals: [4, 4, 4, 4, 4] },
{ key: 'r_forearm', label: 'R forearm', vals: [3, 3, 3, 3, 3] },
{ key: 'l_forearm', label: 'L forearm', vals: [3, 3, 3, 3, 3] },
{ key: 'r_hand', label: 'R hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'l_hand', label: 'L hand', vals: [2.5, 2.5, 2.5, 2.5, 2.5] },
{ key: 'r_thigh', label: 'R thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
{ key: 'l_thigh', label: 'L thigh', vals: [5.5, 8, 8.5, 9, 9.5], ageSensitive: true },
{ key: 'r_leg', label: 'R lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
{ key: 'l_leg', label: 'L lower leg', vals: [5, 5.5, 6, 6.5, 7], ageSensitive: true },
{ key: 'r_foot', label: 'R foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
{ key: 'l_foot', label: 'L foot', vals: [3.5, 3.5, 3.5, 3.5, 3.5] },
];
const AGE_BANDS: Array<{ id: 'infant' | 'young' | 'child' | 'adol' | 'adult'; label: string }> = [
{ id: 'infant', label: 'Infant (<1 y)' },
{ id: 'young', label: 'Young child (1-5 y)' },
{ id: 'child', label: 'Child (5-10 y)' },
{ id: 'adol', label: 'Adolescent (10-15 y)' },
{ id: 'adult', label: 'Adult (>15 y)' },
];
export function BurnsPanel() {
const [weight, setWeight] = useState('');
const [ageBand, setAgeBand] = useState<'infant' | 'young' | 'child' | 'adol' | 'adult'>('young');
const [override, setOverride] = useState('');
const [pct, setPct] = useState<Record<string, number>>({});
const ageIdx = AGE_BANDS.findIndex((a) => a.id === ageBand);
const computedTbsa = LUND_BROWDER.reduce(
(sum, r) => sum + r.vals[ageIdx] * Math.min(100, Math.max(0, pct[r.key] ?? 0)) / 100,
0,
);
const tbsa = override.trim() ? Number(override) : Math.round(computedTbsa * 10) / 10;
const wt = Number.parseFloat(weight);
const validWt = Number.isFinite(wt) && wt > 0;
const validTbsa = Number.isFinite(tbsa) && tbsa > 0;
const total = validWt && validTbsa ? Math.round(4 * wt * tbsa) : 0;
const first8 = Math.round(total / 2);
const rateFirst = Math.round(first8 / 8);
const next16 = total - first8;
const rateNext = Math.round(next16 / 16);
const maint = validWt
? Math.round(wt <= 10 ? wt * 4 : wt <= 20 ? 40 + (wt - 10) * 2 : 60 + (wt - 20))
: 0;
return (
<section className={card} data-testid="bedside-panel-burns">
<h2 className="text-lg font-semibold">Burns Lund-Browder + Parkland</h2>
<div className="grid gap-2 grid-cols-1 sm:grid-cols-3 max-w-xl">
<div><label className={label}>Weight (kg)</label><input type="number" min="0.3" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="burn-weight" /></div>
<div><label className={label}>Age band</label><select className={input} value={ageBand} onChange={(e) => setAgeBand(e.target.value as typeof ageBand)} data-testid="burn-age">{AGE_BANDS.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}</select></div>
<div><label className={label}>TBSA override (%)</label><input type="number" min="0" max="100" step="1" className={input} value={override} onChange={(e) => setOverride(e.target.value)} placeholder={validTbsa ? String(tbsa) : 'auto'} data-testid="burn-tbsa" /></div>
</div>
<h3 className="text-sm font-semibold">Body parts % of each region burned (2° or deeper)</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{LUND_BROWDER.map((r) => {
const max = r.vals[ageIdx];
return (
<div key={r.key} className="flex items-center gap-2 bg-muted/30 rounded p-2">
<label className="flex-1 text-xs">{r.label} <span className="text-muted-foreground">({max}%{r.ageSensitive ? '*' : ''})</span></label>
<input type="number" min="0" max="100" step="5" className="w-16 rounded border border-input bg-background px-2 py-1 text-xs text-right" value={pct[r.key] ?? 0} onChange={(e) => setPct({ ...pct, [r.key]: Number(e.target.value) })} data-testid={'burn-region-' + r.key} />
<span className="text-xs text-muted-foreground">%</span>
</div>
);
})}
</div>
{validTbsa && <div className="text-sm text-muted-foreground">Computed TBSA: <strong>{tbsa}%</strong></div>}
{!validWt && <p className="text-xs text-destructive">Enter weight (kg) to see Parkland + maintenance fluids.</p>}
{!validTbsa && validWt && <p className="text-xs text-destructive">Enter % per region or override TBSA to compute fluids.</p>}
{validWt && validTbsa && (
<>
<div className="rounded-md border-2 border-destructive bg-red-50 dark:bg-red-950/30 p-3 text-sm font-semibold text-destructive" data-testid="burn-result">
Burn fluid resuscitation {wt} kg, {tbsa}% TBSA (2° or deeper)
</div>
<div className="rounded-md bg-red-50 dark:bg-red-950/30 p-3 text-sm space-y-1">
<div><strong>Parkland formula:</strong> 4 mL × kg × %TBSA = <strong>{total} mL LR over 24 hours</strong></div>
<div><strong>First 8 h</strong> (from time of burn): {first8} mL (~<strong>{rateFirst} mL/hr</strong>)</div>
<div><strong>Next 16 h:</strong> {next16} mL (~<strong>{rateNext} mL/hr</strong>)</div>
</div>
<div className="rounded-md bg-muted/40 p-3 text-sm">
<strong>Plus maintenance (4-2-1):</strong> {maint} mL/hr (D5 ½NS ± 20 mEq KCl/L once UOP established). Consider dextrose in children &lt;30 kg.
</div>
<div className="rounded-md bg-muted/40 p-3 text-sm">
<strong>Titrate to UOP:</strong> target 1-2 mL/kg/hr (infants / children), 0.5-1 mL/kg/hr (adolescents). <strong>Clinical response trumps formula.</strong>
</div>
</>
)}
<h3 className="text-sm font-semibold">Other pearls</h3>
<div className="rounded-md bg-muted/40 p-3 text-xs space-y-1">
<div><strong>Rule of palm:</strong> Patient's palm + fingers 1% TBSA good for scattered burns.</div>
<div><strong>First-degree burns DO NOT count</strong> toward TBSA or Parkland.</div>
<div><strong>Analgesia:</strong> Morphine 0.05-0.1 mg/kg IV q2h, or fentanyl 1-2 mcg/kg IV q30-60 min.</div>
<div><strong>Tetanus</strong> prophylaxis if indicated. Tdap/Td ± TIG.</div>
</div>
<h3 className="text-sm font-semibold">Burn center referral (ABA)</h3>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
Partial-thickness &gt;10% TBSA · any full-thickness · face/hands/feet/genital/perineum/major joints · electrical/chemical/inhalation · associated trauma · significant comorbidities · pediatric burns in non-pediatric center.
</div>
<div className="text-xs text-muted-foreground italic">
ABA Advanced Burn Life Support 2018 · Parkland formula: Baxter 1968 · Lund-Browder 1944.
</div>
</section>
);
}

View file

@ -0,0 +1,366 @@
// ============================================================
// CALCULATOR PANELS — BMI / Vitals / Resus / Equipment.
// Data ported VERBATIM from public/js/calculators.js:
// • VITALS_DATA lines 1703-1831
// • RESUS_MEDS lines 1873-2050
// • EQUIP_DATA lines 2173-2228
// BMI math + LMS table live in shared/clinical/bmi.ts, verified
// byte-for-byte by calc-vectors.json (12 BMI cases).
// ============================================================
import { useState } from 'react';
import { computeBmi } from '@shared/clinical/bmi';
import type { Sex } from '@shared/clinical/fenton';
import { computeBp, type BpClassification } from '@shared/clinical/bp';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-medium text-muted-foreground';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
const errorBox = 'rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200';
// ── BP Percentile (AAP 2017 Rosner splines) ────────────────
const BP_CLASS_STYLE: Record<BpClassification, { label: string; color: string; bg: string }> = {
normal: { label: 'Normal', color: '#10b981', bg: '#d1fae5' },
elevated: { label: 'Elevated', color: '#f59e0b', bg: '#fef3c7' },
stage1: { label: 'Stage 1 Hypertension', color: '#f97316', bg: '#ffedd5' },
stage2: { label: 'Stage 2 Hypertension', color: '#ef4444', bg: '#fee2e2' },
};
export function BpPanel() {
const [ageYears, setAgeYears] = useState('');
const [sex, setSex] = useState<'female' | 'male'>('female');
const [heightCm, setHeightCm] = useState('');
const [sbp, setSbp] = useState('');
const [dbp, setDbp] = useState('');
const [error, setError] = useState('');
const [result, setResult] = useState<ReturnType<typeof computeBp> | null>(null);
function calc() {
const a = Number.parseFloat(ageYears);
const h = Number.parseFloat(heightCm);
const s = Number.parseFloat(sbp);
const d = Number.parseFloat(dbp);
if (!Number.isFinite(a) || !Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(d)) {
setError('Fill in all fields.'); setResult(null); return;
}
if (a < 1 || a > 17) { setError('Age must be 1-17 years.'); setResult(null); return; }
if (h < 50 || h > 200) { setError('Height must be 50-200 cm.'); setResult(null); return; }
setError('');
setResult(computeBp(a, sex, h, s, d));
}
const style = result ? BP_CLASS_STYLE[result.classification] : null;
return (
<section className={card} data-testid="calc-panel-bp">
<h2 className="text-lg font-semibold">BP Percentile (AAP 2017)</h2>
<div className="grid gap-3 sm:grid-cols-3">
<div><label className={label}>Age (years)</label><input type="number" min="1" max="17" step="0.1" className={input} value={ageYears} onChange={(e) => setAgeYears(e.target.value)} data-testid="bp-age" /></div>
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as typeof sex)} data-testid="bp-sex"><option value="female">Female</option><option value="male">Male</option></select></div>
<div><label className={label}>Height (cm)</label><input type="number" min="50" max="200" step="0.1" className={input} value={heightCm} onChange={(e) => setHeightCm(e.target.value)} data-testid="bp-height" /></div>
<div><label className={label}>SBP (mmHg)</label><input type="number" min="50" max="220" step="1" className={input} value={sbp} onChange={(e) => setSbp(e.target.value)} data-testid="bp-sbp" /></div>
<div><label className={label}>DBP (mmHg)</label><input type="number" min="30" max="150" step="1" className={input} value={dbp} onChange={(e) => setDbp(e.target.value)} data-testid="bp-dbp" /></div>
</div>
<div className="flex gap-2">
<button type="button" onClick={calc} className={btnPrimary} data-testid="calc-bp-calculate">Calculate</button>
<button type="button" onClick={() => { setAgeYears(''); setHeightCm(''); setSbp(''); setDbp(''); setResult(null); setError(''); }} className={btnGhost}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{result && style && (
<div
className="rounded-lg p-4 space-y-2"
style={{ background: style.bg, borderLeft: `4px solid ${style.color}` }}
data-testid="calc-bp-result"
>
<div className="text-base font-bold" style={{ color: style.color }}>{style.label}</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">Systolic</span><div className="font-semibold">{result.sysPercentile}th %ile</div><div className="text-xs text-muted-foreground">{BP_CLASS_STYLE[result.sysClass].label}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Diastolic</span><div className="font-semibold">{result.diaPercentile}th %ile</div><div className="text-xs text-muted-foreground">{BP_CLASS_STYLE[result.diaClass].label}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Height</span><div className="font-semibold">{result.heightPercentile.toFixed(0)}th %ile</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Overall</span><div className="font-semibold">{style.label}</div></div>
</div>
</div>
)}
<div className="text-xs text-muted-foreground italic">
Flynn JT et al. Clinical Practice Guideline for Screening and Management of High Blood Pressure in Children and Adolescents. Pediatrics 2017;140(3):e20171904.
</div>
</section>
);
}
// ── BMI ─────────────────────────────────────────────────────
export function BmiPanel() {
const [ageYr, setAgeYr] = useState('');
const [ageMo, setAgeMo] = useState('');
const [sex, setSex] = useState<Sex>('male');
const [weight, setWeight] = useState('');
const [height, setHeight] = useState('');
const [error, setError] = useState('');
const [result, setResult] = useState<ReturnType<typeof computeBmi> | null>(null);
function calc() {
const yr = Number.parseFloat(ageYr) || 0;
const mo = Number.parseInt(ageMo, 10) || 0;
const age = yr + mo / 12;
const w = Number.parseFloat(weight);
const h = Number.parseFloat(height);
if (!age || !Number.isFinite(w) || w <= 0 || !Number.isFinite(h) || h <= 0) {
setError('Fill in all fields.'); setResult(null); return;
}
if (age < 2 || age > 20) { setError('Age must be 2-20 years.'); setResult(null); return; }
setError('');
setResult(computeBmi(w, h, Math.round(age * 12), sex));
}
return (
<section className={card} data-testid="calc-panel-bmi">
<h2 className="text-lg font-semibold">BMI Percentile (CDC 2000)</h2>
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-2 sm:col-span-1">
<div><label className={label}>Age (yr)</label><input type="number" min="2" max="20" step="0.1" className={input} value={ageYr} onChange={(e) => setAgeYr(e.target.value)} data-testid="bmi-age-yr" /></div>
<div><label className={label}>Months</label><input type="number" min="0" max="11" className={input} value={ageMo} onChange={(e) => setAgeMo(e.target.value)} data-testid="bmi-age-mo" /></div>
</div>
<div><label className={label}>Sex</label><select className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)} data-testid="bmi-sex"><option value="male">Male</option><option value="female">Female</option></select></div>
<div><label className={label}>Weight (kg)</label><input type="number" min="1" max="200" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="bmi-weight" /></div>
<div className="sm:col-span-1"><label className={label}>Height (cm)</label><input type="number" min="50" max="220" step="0.1" className={input} value={height} onChange={(e) => setHeight(e.target.value)} data-testid="bmi-height" /></div>
</div>
<div className="flex gap-2">
<button type="button" onClick={calc} className={btnPrimary} data-testid="calc-bmi-calculate">Calculate</button>
<button type="button" onClick={() => { setAgeYr(''); setAgeMo(''); setWeight(''); setHeight(''); setResult(null); setError(''); }} className={btnGhost}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{result && (
<div
className="rounded-lg p-4 space-y-2"
style={{ background: result.classification.bg, borderLeft: `4px solid ${result.classification.color}` }}
data-testid="calc-bmi-result"
>
<div className="text-base font-bold" style={{ color: result.classification.color }}>{result.classification.label}</div>
<div className="text-sm">BMI {result.bmi.toFixed(1)} kg/m² {result.percentile}th percentile</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">BMI</span><div className="font-semibold">{result.bmi.toFixed(1)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile}th</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Z-Score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
{result.percentile >= 85 && <div><span className="text-xs uppercase text-muted-foreground">% of 95th</span><div className="font-semibold">{result.classification.pctOf95.toFixed(0)}%</div></div>}
</div>
</div>
)}
<div className="text-xs text-muted-foreground italic">CDC 2000 LMS tables · Kuczmarski et al. Vital Health Stat 11. 2002;(246).</div>
</section>
);
}
// ── Vitals ──────────────────────────────────────────────────
// Data ported verbatim from calculators.js:1703-1831.
interface VitalsEntry {
label: string;
hr: { awake: string; sleeping: string };
rr: string;
sbp: string;
dbp: string;
temp: string;
weight: string;
spo2: string;
notes: string[];
}
const VITALS_DATA: Record<string, VitalsEntry> = {
premie: { label: 'Premie', hr: { awake: '120-170', sleeping: '100-150' }, rr: '40-70', sbp: '55-75', dbp: '35-45', temp: '36.5-37.5', weight: '0.5-2.5 kg', spo2: '88-95% (target)',
notes: ['HR and RR are highly variable and depend on gestational age', 'BP increases with gestational age and postnatal age', 'Target SpO2 88-95% to reduce retinopathy of prematurity risk', 'Temperature instability is common — use servo-controlled warmers', 'Bradycardia (<100 bpm) and apnea are common in premature infants'] },
'0-3mo': { label: '0-3 Months', hr: { awake: '100-150', sleeping: '85-135' }, rr: '35-55', sbp: '65-85', dbp: '45-55', temp: '36.5-37.5', weight: '2.5-6 kg', spo2: '>95%',
notes: ['HR normally increases with crying (up to 180-190 bpm) — this is physiologic', 'Periodic breathing (pauses <10 sec) is normal in neonates', 'Acrocyanosis (blue hands/feet) is normal; central cyanosis is not', 'BP is best measured in the right arm (pre-ductal) in neonates', 'Normal weight loss of 5-7% in first 3-5 days; regain by 10-14 days'] },
'3-6mo': { label: '3-6 Months', hr: { awake: '90-120', sleeping: '75-110' }, rr: '30-45', sbp: '70-90', dbp: '50-65', temp: '36.5-37.5', weight: '5-8 kg', spo2: '>95%',
notes: ['Expected weight gain: 20-30 g/day (150-200 g/week)', 'HR gradually decreases as vagal tone matures', 'RR >60 at rest may indicate lower respiratory tract disease', 'BP should be measured with appropriate cuff size (width 40% of arm circumference)'] },
'6-12mo': { label: '6-12 Months', hr: { awake: '80-120', sleeping: '70-110' }, rr: '25-40', sbp: '80-100', dbp: '55-65', temp: '36.0-37.5', weight: '8-10 kg', spo2: '>95%',
notes: ['Expected weight: triple birth weight by 12 months (~10 kg average)', 'Weight gain slows to ~10-15 g/day', 'Sinus arrhythmia (HR varies with breathing) is normal', 'Febrile tachycardia: HR increases ~10 bpm per 1 degree C above 37'] },
'1-3yr': { label: '1-3 Years', hr: { awake: '70-110', sleeping: '60-100' }, rr: '20-30', sbp: '90-105', dbp: '55-70', temp: '36.0-37.5', weight: '10-15 kg', spo2: '>95%',
notes: ['Expected weight gain: ~200-250 g/month (2-2.5 kg/year)', 'Tachycardia: HR >110 at rest warrants evaluation', 'Tachypnea: RR >30 at rest may indicate respiratory distress', 'BP screening begins at age 3 per AAP 2017 guidelines', 'Estimated weight: 2 x (age in years) + 8'] },
'3-6yr': { label: '3-6 Years', hr: { awake: '65-110', sleeping: '55-100' }, rr: '20-25', sbp: '95-110', dbp: '60-75', temp: '36.0-37.5', weight: '14-20 kg', spo2: '>95%',
notes: ['Annual BP screening recommended from age 3', 'Normal BP <90th percentile for age, sex, and height', 'Elevated BP: 90th to <95th percentile (or 120/80 if lower)', 'Estimated weight: 2 x (age in years) + 8', 'ETT size (uncuffed): (age/4) + 4'] },
'6-12yr': { label: '6-12 Years', hr: { awake: '60-95', sleeping: '50-85' }, rr: '14-22', sbp: '100-120', dbp: '60-75', temp: '36.0-37.5', weight: '20-40 kg', spo2: '>95%',
notes: ['Resting HR >95 or <60 warrants evaluation', 'BP should be measured at every clinical encounter', 'Stage 1 HTN: >=95th percentile on 3 separate occasions', 'Estimated weight: 3 x (age in years) + 7', 'ETT size (cuffed): (age/4) + 3.5'] },
'>12yr': { label: '>12 Years', hr: { awake: '55-85', sleeping: '45-75' }, rr: '12-18', sbp: '110-135', dbp: '65-85', temp: '36.0-37.5', weight: '40-80 kg', spo2: '>95%',
notes: ['Vital signs approach adult values', 'From age 13: use adult BP thresholds (AAP 2017)', 'Normal: <120/<80 mmHg; Elevated: 120-129/<80 mmHg', 'Stage 1 HTN: 130-139/80-89 mmHg; Stage 2 HTN: >=140/>=90 mmHg', 'Orthostatic vitals: measure lying, sitting, standing if dizzy', 'Athletic bradycardia (HR 45-60) may be normal in trained adolescents'] },
};
const VITALS_ORDER = ['premie', '0-3mo', '3-6mo', '6-12mo', '1-3yr', '3-6yr', '6-12yr', '>12yr'];
export function VitalsPanel() {
const [key, setKey] = useState<string>('1-3yr');
const v = VITALS_DATA[key];
return (
<section className={card} data-testid="calc-panel-vitals">
<h2 className="text-lg font-semibold">Vital Signs by Age</h2>
<div className="max-w-xs">
<label className={label}>Age group</label>
<select className={input} value={key} onChange={(e) => setKey(e.target.value)} data-testid="vitals-age-select">
{VITALS_ORDER.map((k) => <option key={k} value={k}>{VITALS_DATA[k].label}</option>)}
</select>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm" data-testid="vitals-result">
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (awake)</div><div className="font-semibold">{v.hr.awake} bpm</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Heart rate (sleep)</div><div className="font-semibold">{v.hr.sleeping} bpm</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Respiratory rate</div><div className="font-semibold">{v.rr} /min</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SpO</div><div className="font-semibold">{v.spo2}</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">SBP</div><div className="font-semibold">{v.sbp} mmHg</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">DBP</div><div className="font-semibold">{v.dbp} mmHg</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Temperature</div><div className="font-semibold">{v.temp} °C</div></div>
<div className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">Weight</div><div className="font-semibold">{v.weight}</div></div>
</div>
<div className="rounded-md bg-blue-50 dark:bg-blue-950/30 p-3 text-xs">
<div className="font-semibold mb-1">Clinical notes</div>
<ul className="list-disc pl-5 space-y-0.5">{v.notes.map((n, i) => <li key={i}>{n}</li>)}</ul>
</div>
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook 23rd ed · PALS · AAP 2017 BP guidelines.</div>
</section>
);
}
// ── Resus Meds ──────────────────────────────────────────────
// Data + math ported verbatim from calculators.js:1873-2050.
interface ResusResult { dose: string; extra: string; max: string }
interface ResusMed { name: string; indication: string; category: 'cardiac' | 'metabolic' | 'reversal'; route: string; calc: (w: number) => ResusResult }
const RESUS_MEDS: ResusMed[] = [
{ name: 'Adenosine', indication: 'SVT', category: 'cardiac', route: 'IV/IO rapid bolus',
calc: (w) => { const d1 = +(w * 0.1).toFixed(2); const d2 = +(w * 0.2).toFixed(2); const d3 = +(w * 0.3).toFixed(2); return { dose: `${d1} mg (0.1 mg/kg)`, extra: `May repeat: ${Math.min(d2, 12)} mg (0.2 mg/kg), then ${Math.min(d3, 12)} mg (0.3 mg/kg)`, max: 'Max first dose 6 mg, max subsequent 12 mg' }; } },
{ name: 'Amiodarone', indication: 'VT / VF', category: 'cardiac', route: 'IV/IO',
calc: (w) => { const d = +(w * 5).toFixed(1); return { dose: `${Math.min(d, 300)} mg (5 mg/kg)`, extra: 'No pulse: push undiluted. Pulse: over 20-60 min. Subsequent max 150 mg.', max: 'Max first 300 mg, max total 15 mg/kg/24hr or 2200 mg' }; } },
{ name: 'Atropine', indication: 'Bradycardia', category: 'cardiac', route: 'IV/IO/IM',
calc: (w) => { const d = +(w * 0.02).toFixed(3); const ett = `${(w * 0.04).toFixed(3)}-${(w * 0.06).toFixed(3)}`; return { dose: `${Math.min(d, 0.5)} mg (0.02 mg/kg)`, extra: `ETT dose: ${ett} mg (0.04-0.06 mg/kg)`, max: 'Max single 0.5 mg, max total 1 mg' }; } },
{ name: 'Calcium Chloride 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
calc: (w) => { const d = +(w * 20).toFixed(0); return { dose: `${Math.min(d, 1000)} mg (20 mg/kg)`, extra: 'Give slowly. Central line preferred.', max: 'Max 1 g (1000 mg)' }; } },
{ name: 'Calcium Gluconate 10%', indication: 'Hypocalcemia / Hyperkalemia', category: 'metabolic', route: 'IV/IO',
calc: (w) => { const d = +(w * 60).toFixed(0); return { dose: `${Math.min(d, 3000)} mg (60 mg/kg)`, extra: 'Give slowly over 10-20 min with cardiac monitoring.', max: 'Max 3 g (3000 mg)' }; } },
{ name: 'Dextrose', indication: 'Hypoglycemia', category: 'metabolic', route: 'IV',
calc: (w) => {
const grams = `${+(w * 0.5).toFixed(1)}-${+(w * 1).toFixed(1)}`;
let detail = '';
if (w < 5) detail = `D10W: ${(w * 5).toFixed(1)}-${(w * 10).toFixed(1)} mL (5-10 mL/kg)`;
else if (w < 45) detail = `D25W: ${(w * 2).toFixed(1)}-${(w * 4).toFixed(1)} mL (2-4 mL/kg)`;
else detail = `D50W: ${(w * 1).toFixed(1)}-${(w * 2).toFixed(1)} mL (1-2 mL/kg)`;
return { dose: `${grams} g (0.5-1 g/kg)`, extra: detail, max: 'Max 25 g' };
} },
{ name: 'Epinephrine', indication: 'Pulseless arrest / Anaphylaxis', category: 'cardiac', route: 'IV/IO/IM/ETT',
calc: (w) => { const iv = +(w * 0.01).toFixed(3); const ivVol = +(w * 0.1).toFixed(2); const ett = +(w * 0.1).toFixed(2); const im = +(w * 0.01).toFixed(3);
return { dose: `${Math.min(iv, 1)} mg IV/IO (0.01 mg/kg of 0.1 mg/mL = ${Math.min(ivVol, 10)} mL) q3-5 min`, extra: `ETT: ${Math.min(ett, 2.5)} mg (0.1 mg/kg of 1 mg/mL). Anaphylaxis IM: ${Math.min(im, 0.5)} mg (0.01 mg/kg)`, max: 'Max IV 1 mg, max ETT 2.5 mg, max IM 0.5 mg' }; } },
{ name: 'Hydrocortisone', indication: 'Adrenal crisis', category: 'metabolic', route: 'IV/IM/IO',
calc: (w) => { const d = +(w * 2).toFixed(1); return { dose: `${Math.min(d, 100)} mg (2 mg/kg)`, extra: 'Stress dosing for adrenal insufficiency.', max: 'Max 100 mg' }; } },
{ name: 'Insulin (Regular)', indication: 'Hyperkalemia', category: 'metabolic', route: 'IV',
calc: (w) => { const d = +(w * 0.1).toFixed(2); const dex = +(w * 0.5).toFixed(1); return { dose: `${Math.min(d, 5)} units (0.1 units/kg)`, extra: `Give with ${dex} g/kg dextrose (0.5 g/kg). Monitor glucose closely.`, max: 'Max 5 units' }; } },
{ name: 'Lidocaine', indication: 'Antiarrhythmic', category: 'cardiac', route: 'IV/IO',
calc: (w) => { const d = +(w * 1).toFixed(1); const ett = `${(w * 2).toFixed(1)}-${(w * 3).toFixed(1)}`; return { dose: `${Math.min(d, 100)} mg (1 mg/kg)`, extra: `ETT: ${ett} mg (2-3 mg/kg). May repeat q5 min.`, max: 'Max 100 mg/dose, max total 3 mg/kg' }; } },
{ name: 'Magnesium Sulfate', indication: 'Torsades de Pointes', category: 'cardiac', route: 'IV/IO',
calc: (w) => { const d = +(w * 50).toFixed(0); return { dose: `${Math.min(d, 2000)} mg (50 mg/kg)`, extra: 'Give over 10-20 min (faster if pulseless).', max: 'Max 2 g (2000 mg)' }; } },
{ name: 'Naloxone', indication: 'Opioid overdose', category: 'reversal', route: 'IV/IO/IM/IN/ETT',
calc: (w) => { const partial = `${+(w * 0.001).toFixed(4)}-${+(w * 0.005).toFixed(4)}`; const full = +(w * 0.1).toFixed(3); return { dose: `Partial: ${partial} mg (0.001-0.005 mg/kg)`, extra: `Full reversal: ${Math.min(full, 2)} mg (0.1 mg/kg)`, max: 'Max partial first dose 0.1 mg, max full 2 mg' }; } },
{ name: 'Sodium Bicarbonate', indication: 'Metabolic acidosis', category: 'metabolic', route: 'IV/IO',
calc: (w) => { const d = +(w * 1).toFixed(1); return { dose: `${Math.min(d, 50)} mEq (1 mEq/kg)`, extra: w < 10 ? 'Dilute to 0.5 mEq/mL (use 4.2% solution) for neonates/small infants.' : 'Use 8.4% solution (1 mEq/mL).', max: 'Max 50 mEq' }; } },
];
const catColor: Record<ResusMed['category'], string> = { cardiac: '#ef4444', metabolic: '#3b82f6', reversal: '#10b981' };
const catLabel: Record<ResusMed['category'], string> = { cardiac: 'Cardiac', metabolic: 'Metabolic', reversal: 'Reversal' };
export function ResusPanel() {
const [weight, setWeight] = useState('');
const wt = Number.parseFloat(weight);
const valid = Number.isFinite(wt) && wt > 0;
return (
<section className={card} data-testid="calc-panel-resus">
<h2 className="text-lg font-semibold">Resus Medications</h2>
<div className="max-w-xs">
<label className={label}>Weight (kg)</label>
<input type="number" min="0.5" max="100" step="0.1" className={input} value={weight} onChange={(e) => setWeight(e.target.value)} data-testid="resus-weight" />
</div>
{!valid ? <p className="text-xs text-destructive">Enter weight (kg) to see doses.</p> : (
<>
<div className="text-sm font-semibold">Doses for {wt} kg patient</div>
<div className="flex gap-3 flex-wrap text-xs">
{(['cardiac', 'metabolic', 'reversal'] as const).map((c) => (
<span key={c} className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: catColor[c] }} />{catLabel[c]}</span>
))}
</div>
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3" data-testid="resus-result">
{RESUS_MEDS.map((med) => {
const r = med.calc(wt);
const color = catColor[med.category];
return (
<div key={med.name} className="rounded-lg border bg-card overflow-hidden" style={{ borderColor: color + '55' }}>
<div className="px-3 py-2 border-b" style={{ background: color + '10', borderColor: color + '22' }}>
<div className="text-sm font-bold" style={{ color }}>{med.name}</div>
<div className="text-xs text-muted-foreground">{med.indication}</div>
</div>
<div className="p-3 text-sm space-y-1">
<div><strong>Dose:</strong> {r.dose}</div>
<div className="text-xs text-muted-foreground">{r.extra}</div>
<div className="text-xs text-muted-foreground"><strong>Max:</strong> {r.max}</div>
<div className="text-xs text-muted-foreground"><strong>Route:</strong> {med.route}</div>
</div>
</div>
);
})}
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100">
<strong>Disclaimer:</strong> Always verify doses against institutional protocols and current guidelines.
</div>
</>
)}
</section>
);
}
// ── Equipment ───────────────────────────────────────────────
// Data ported verbatim from calculators.js:2173-2228.
interface EquipEntry {
label: string;
bvm: string; nasal: string; oral: string; blade: string;
ett: string; lma: string; glidescope: string;
iv: string; cvl: string; ngt: string; chest: string; foley: string;
}
const EQUIP_DATA: Record<string, EquipEntry> = {
premie: { label: 'Premie (1-3 kg)', bvm: 'Infant', nasal: '12 Fr', oral: 'Infant', blade: 'Miller 0', ett: '2.5-3.0', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3 Fr', ngt: '5 Fr', chest: '10-12 Fr', foley: '6 Fr' },
newborn: { label: 'Newborn (2-4 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 50 mm', blade: 'Miller 0', ett: '3.0-3.5', lma: '1', glidescope: '1', iv: '22-24 ga', cvl: '3-4 Fr', ngt: '5-8 Fr', chest: '10-12 Fr', foley: '6 Fr' },
'6mo': { label: '6 months (6-8 kg)', bvm: 'Infant', nasal: '14-16 Fr', oral: 'Small 60 mm', blade: 'Miller 1', ett: '3.5', lma: '1.5', glidescope: '2', iv: '20-24 ga', cvl: '4 Fr', ngt: '8 Fr', chest: '12-18 Fr', foley: '8 Fr' },
'1yr': { label: '1 year (10 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 60 mm', blade: 'Miller 1 / MAC 2', ett: '4.0', lma: '2', glidescope: '2', iv: '20-24 ga', cvl: '4-5 Fr', ngt: '10 Fr', chest: '16-20 Fr', foley: '8 Fr' },
'2-3yr': { label: '2-3 years (12-16 kg)', bvm: 'Small child', nasal: '14-18 Fr', oral: 'Small 70 mm', blade: 'Miller 1 / MAC 2', ett: '4.0-4.5', lma: '2', glidescope: '2', iv: '18-22 ga', cvl: '4-5 Fr', ngt: '10-12 Fr', chest: '16-24 Fr', foley: '8 Fr' },
'4-6yr': { label: '4-6 years (20-25 kg)', bvm: 'Child', nasal: '16-20 Fr', oral: 'Small 70-80 mm', blade: 'Miller 2 / MAC 2', ett: '4.5-5.0', lma: '2.5', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-28 Fr', foley: '8 Fr' },
'7-10yr': { label: '7-10 years (25-35 kg)', bvm: 'Child / Small adult', nasal: '18-22 Fr', oral: 'Medium 80-90 mm', blade: 'Miller 2 / MAC 2', ett: '5.5-6.0', lma: '2.5-3', glidescope: '3', iv: '18-22 ga', cvl: '5 Fr', ngt: '12-14 Fr', chest: '20-32 Fr', foley: '8 Fr' },
'11-15yr': { label: '11-15 years (40-50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '6.0-6.5', lma: '3', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-38 Fr', foley: '10 Fr' },
'16yr': { label: '16+ years (>50 kg)', bvm: 'Adult', nasal: '22-36 Fr', oral: 'Medium 90 mm', blade: 'Miller 2 / MAC 3', ett: '7.0-8.0', lma: '4', glidescope: '3 or 4', iv: '18-20 ga', cvl: '7 Fr', ngt: '14-18 Fr', chest: '28-42 Fr', foley: '12 Fr' },
};
const EQUIP_ORDER = ['premie', 'newborn', '6mo', '1yr', '2-3yr', '4-6yr', '7-10yr', '11-15yr', '16yr'];
export function EquipmentPanel() {
const [key, setKey] = useState('1yr');
const e = EQUIP_DATA[key];
const rows: Array<[string, string]> = [
['BVM', e.bvm],
['Nasopharyngeal', e.nasal],
['Oropharyngeal', e.oral],
['Laryngoscope', e.blade],
['ETT', e.ett],
['LMA', e.lma],
['Glidescope', e.glidescope],
['IV', e.iv],
['Central line', e.cvl],
['NG tube', e.ngt],
['Chest tube', e.chest],
['Foley', e.foley],
];
return (
<section className={card} data-testid="calc-panel-equipment">
<h2 className="text-lg font-semibold">Equipment Sizing</h2>
<div className="max-w-xs">
<label className={label}>Age / weight band</label>
<select className={input} value={key} onChange={(e2) => setKey(e2.target.value)} data-testid="equip-age-select">
{EQUIP_ORDER.map((k) => <option key={k} value={k}>{EQUIP_DATA[k].label}</option>)}
</select>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm" data-testid="equip-result">
{rows.map(([lbl, val]) => (
<div key={lbl} className="rounded-md bg-muted/40 p-3"><div className="text-xs uppercase text-muted-foreground">{lbl}</div><div className="font-semibold">{val}</div></div>
))}
</div>
<div className="text-xs text-muted-foreground italic">Harriet Lane Handbook · PALS · Broselow cross-reference.</div>
</section>
);
}

View file

@ -0,0 +1,616 @@
// ============================================================
// CALCULATORS — incremental React port.
// Low-risk pure formulas run here; high-risk table-driven calculators
// stay in the vanilla viewer until legacy vectors land.
//
// WHY this is gated on test vectors (from the migration checkpoint):
// • AAP 2017 BP percentile uses Rosner quantile splines with long
// hard-coded coefficient arrays.
// • Fenton 2013 LMS preterm growth carries 210 validated cases.
// • AAP 2022 bilirubin phototherapy + exchange: per-week risk
// curves, 1190 validated cases.
// • Bhutani nomogram risk zones.
// • APLS + Best Guess weight-for-age.
//
// Per the checkpoint: "An LLM will sometimes 'simplify' a long array
// of numbers and silently break it — don't let that happen." Every
// calculator needs a JSON vector file (~20 known inputs + expected
// outputs captured from public/js/calculators.js) before its React
// port lands, and the port must match every vector byte-for-byte.
//
// Pill order + labels match public/components/calculators.html.
// ============================================================
import { useState } from 'react';
import {
calculateGcs,
calculateMostellerBsa,
calculateWeightBasedDose,
} from '@shared/clinical/calculators';
import { classifyBhutani, classifyAapBili, type BiliRisk } from '@shared/clinical/bilirubin';
import { fentonWeightForAge, classifySizeForAge, type Sex } from '@shared/clinical/fenton';
import { BmiPanel, VitalsPanel, ResusPanel, EquipmentPanel, BpPanel } from './CalculatorPanels';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
const btnGhost = 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium hover:bg-muted';
const field = 'space-y-1';
const label = 'block text-xs font-medium text-muted-foreground';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const resultBox = 'rounded-lg border border-border bg-muted/40 p-4';
const errorBox = 'rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200';
interface Pill {
id: string;
label: string;
summary: string;
source: string; // where the formulas live
ported?: boolean;
}
const PILLS: Pill[] = [
{ id: 'bp', label: 'BP Percentile', summary: 'AAP 2017 age/height/sex-adjusted BP percentiles (Rosner quantile splines).', source: 'AAP 2017 (Flynn) — Rosner splines', ported: true },
{ id: 'bmi', label: 'BMI Percentile', summary: 'BMI-for-age (CDC 2000 z-score tables).', source: 'CDC 2000 LMS', ported: true },
{ id: 'growth', label: 'Growth Charts', summary: 'Fenton 2013 preterm weight-for-GA with Z-score + percentile + SGA/AGA/LGA classification.', source: 'Fenton 2013 LMS', ported: true },
{ id: 'bili', label: 'Bilirubin', summary: 'AAP 2022 phototherapy + exchange thresholds and Bhutani nomogram risk zones.', source: 'AAP 2022 (Kemper) + Bhutani 1999', ported: true },
{ id: 'vitals', label: 'Vital Signs', summary: 'Normal HR / RR / BP ranges by age.', source: 'Harriet Lane + PALS + AHA', ported: true },
{ id: 'bsa', label: 'Body Surface Area', summary: 'Mosteller body surface area formula.', source: 'Mosteller 1987', ported: true },
{ id: 'dose', label: 'Weight-Based Dosing', summary: 'Generic mg/kg dosing with optional max-dose cap and concentration conversion.', source: 'Legacy calculator formula', ported: true },
{ id: 'resus', label: 'Resus Meds', summary: 'Code-cart dosing (epinephrine, amiodarone, atropine, etc.).', source: 'PALS', ported: true },
{ id: 'gcs', label: 'GCS', summary: 'Child/adult and infant Glasgow Coma Scale variants.', source: 'Teasdale + pediatric modification', ported: true },
{ id: 'equipment', label: 'Equipment', summary: 'ETT size, blade, NG, Foley, suction by age/weight.', source: 'PALS + Broselow cross-reference', ported: true },
];
function parseOptionalNumber(value: string): number | null {
if (!value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function FormField({
id,
labelText,
value,
onChange,
min,
max,
step = '0.1',
placeholder,
}: {
id: string;
labelText: string;
value: string;
onChange: (value: string) => void;
min?: string;
max?: string;
step?: string;
placeholder?: string;
}) {
return (
<div className={field}>
<label htmlFor={id} className={label}>{labelText}</label>
<input
id={id}
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={input}
/>
</div>
);
}
function BsaPanel() {
const [weight, setWeight] = useState('');
const [height, setHeight] = useState('');
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState('');
function calculate() {
const next = calculateMostellerBsa(Number(weight), Number(height));
if (next == null) {
setError('Enter a valid weight and height.');
setResult(null);
return;
}
setError('');
setResult(next);
}
function clear() {
setWeight('');
setHeight('');
setResult(null);
setError('');
}
return (
<section className={card} data-testid="calc-panel-bsa">
<h2 className="text-lg font-semibold">Body Surface Area</h2>
<p className="text-sm text-muted-foreground">
Mosteller formula: BSA (m2) = sqrt(height(cm) x weight(kg) / 3600).
</p>
<div className="grid gap-3 sm:grid-cols-2">
<FormField id="react-bsa-weight" labelText="Weight (kg)" value={weight} onChange={setWeight} min="1" max="200" placeholder="20" />
<FormField id="react-bsa-height" labelText="Height (cm)" value={height} onChange={setHeight} min="30" max="220" placeholder="110" />
</div>
<div className="flex gap-2">
<button type="button" onClick={calculate} className={btnPrimary} data-testid="calc-bsa-calculate">Calculate</button>
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
</div>
{error ? <div className={errorBox}>{error}</div> : null}
{result == null ? null : (
<div className={resultBox} data-testid="calc-bsa-result">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Mosteller BSA</div>
<div className="text-2xl font-semibold">{result.toFixed(3)} m²</div>
<div className="text-sm text-muted-foreground">{weight} kg, {height} cm</div>
</div>
)}
</section>
);
}
function DosePanel() {
const [weight, setWeight] = useState('');
const [dosePerKg, setDosePerKg] = useState('');
const [frequency, setFrequency] = useState('1');
const [maxDose, setMaxDose] = useState('');
const [concentration, setConcentration] = useState('');
const [result, setResult] = useState<ReturnType<typeof calculateWeightBasedDose>>(null);
const [error, setError] = useState('');
function calculate() {
const next = calculateWeightBasedDose({
weightKg: Number(weight),
dosePerKg: Number(dosePerKg),
frequencyPerDay: Number(frequency),
maxSingleDoseMg: parseOptionalNumber(maxDose),
concentrationMgPerMl: parseOptionalNumber(concentration),
});
if (next == null) {
setError('Enter a valid weight, mg/kg dose, and frequency.');
setResult(null);
return;
}
setError('');
setResult(next);
}
function clear() {
setWeight('');
setDosePerKg('');
setFrequency('1');
setMaxDose('');
setConcentration('');
setResult(null);
setError('');
}
return (
<section className={card} data-testid="calc-panel-dose">
<h2 className="text-lg font-semibold">Weight-Based Dosing</h2>
<p className="text-sm text-muted-foreground">
Generic mg/kg calculator. Always verify medication-specific dosing against formulary and local policy.
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<FormField id="react-dose-weight" labelText="Patient Weight (kg)" value={weight} onChange={setWeight} min="1" max="200" placeholder="15" />
<FormField id="react-dose-per-kg" labelText="Dose (mg/kg)" value={dosePerKg} onChange={setDosePerKg} min="0.01" step="0.01" placeholder="10" />
<div className={field}>
<label htmlFor="react-dose-frequency" className={label}>Frequency</label>
<select id="react-dose-frequency" value={frequency} onChange={(event) => setFrequency(event.target.value)} className={input}>
<option value="1">Once daily</option>
<option value="2">Twice daily (BID)</option>
<option value="3">Three times daily (TID)</option>
<option value="4">Four times daily (QID)</option>
<option value="6">Every 4 hours (Q4H)</option>
</select>
</div>
<FormField id="react-dose-max" labelText="Max single dose (mg, optional)" value={maxDose} onChange={setMaxDose} min="0" step="1" placeholder="500" />
<FormField id="react-dose-concentration" labelText="Concentration (mg/mL, optional)" value={concentration} onChange={setConcentration} min="0" placeholder="40" />
</div>
<div className="flex gap-2">
<button type="button" onClick={calculate} className={btnPrimary} data-testid="calc-dose-calculate">Calculate</button>
<button type="button" onClick={clear} className={btnGhost}>Clear</button>
</div>
{error ? <div className={errorBox}>{error}</div> : null}
{result == null ? null : (
<div className={resultBox} data-testid="calc-dose-result">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Single Dose</div>
<div className="text-xl font-semibold">{result.singleDoseMg.toFixed(1)} mg</div>
{result.capped ? <div className="text-xs text-red-600">Capped at max dose</div> : null}
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Daily Total</div>
<div className="text-xl font-semibold">{result.dailyDoseMg.toFixed(1)} mg/day</div>
<div className="text-xs text-muted-foreground">x {result.frequencyPerDay}/day</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Volume</div>
<div className="text-xl font-semibold">{result.volumeMl == null ? 'n/a' : `${result.volumeMl.toFixed(1)} mL`}</div>
<div className="text-xs text-muted-foreground">per dose</div>
</div>
</div>
</div>
)}
</section>
);
}
const GCS_OPTIONS = {
child: {
eye: [
['4', '4 - Spontaneous'],
['3', '3 - To speech'],
['2', '2 - To pain'],
['1', '1 - None'],
],
verbal: [
['5', '5 - Oriented'],
['4', '4 - Confused'],
['3', '3 - Inappropriate words'],
['2', '2 - Incomprehensible sounds'],
['1', '1 - None'],
],
motor: [
['6', '6 - Obeys commands'],
['5', '5 - Localizes pain'],
['4', '4 - Withdraws to pain'],
['3', '3 - Abnormal flexion'],
['2', '2 - Abnormal extension'],
['1', '1 - None'],
],
},
infant: {
eye: [
['4', '4 - Spontaneous'],
['3', '3 - To speech/sound'],
['2', '2 - To painful stimuli'],
['1', '1 - None'],
],
verbal: [
['5', '5 - Coos/babbles'],
['4', '4 - Irritable cry'],
['3', '3 - Cries to pain'],
['2', '2 - Moans to pain'],
['1', '1 - None'],
],
motor: [
['6', '6 - Normal spontaneous movement'],
['5', '5 - Withdraws to touch'],
['4', '4 - Withdraws to pain'],
['3', '3 - Abnormal flexion'],
['2', '2 - Abnormal extension'],
['1', '1 - None'],
],
},
} as const;
function GcsSelect({
id,
labelText,
value,
options,
onChange,
}: {
id: string;
labelText: string;
value: string;
options: readonly (readonly [string, string])[];
onChange: (value: string) => void;
}) {
return (
<div className={field}>
<label htmlFor={id} className={label}>{labelText}</label>
<select id={id} value={value} onChange={(event) => onChange(event.target.value)} className={input}>
{options.map(([optionValue, text]) => (
<option key={optionValue} value={optionValue}>{text}</option>
))}
</select>
</div>
);
}
function GcsPanel() {
const [scale, setScale] = useState<'child' | 'infant'>('child');
const [eye, setEye] = useState('4');
const [verbal, setVerbal] = useState('5');
const [motor, setMotor] = useState('6');
const result = calculateGcs(Number(eye), Number(verbal), Number(motor));
const options = GCS_OPTIONS[scale];
function switchScale(next: 'child' | 'infant') {
setScale(next);
setEye('4');
setVerbal('5');
setMotor('6');
}
return (
<section className={card} data-testid="calc-panel-gcs">
<h2 className="text-lg font-semibold">Glasgow Coma Scale</h2>
<p className="text-sm text-muted-foreground">
Select responses to calculate child/adult or infant-modified GCS. Total score 3-15.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => switchScale('child')}
className={'px-3 py-1.5 rounded-full text-xs font-medium border ' + (scale === 'child' ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')}
>
Child / Adult
</button>
<button
type="button"
onClick={() => switchScale('infant')}
className={'px-3 py-1.5 rounded-full text-xs font-medium border ' + (scale === 'infant' ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted border-border')}
>
Infant
</button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<GcsSelect id="react-gcs-eye" labelText="Eye Opening" value={eye} options={options.eye} onChange={setEye} />
<GcsSelect id="react-gcs-verbal" labelText="Verbal Response" value={verbal} options={options.verbal} onChange={setVerbal} />
<GcsSelect id="react-gcs-motor" labelText="Motor Response" value={motor} options={options.motor} onChange={setMotor} />
</div>
{result == null ? null : (
<div className={resultBox} data-testid="calc-gcs-result">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{scale === 'infant' ? 'Infant-modified GCS' : 'Child / adult GCS'}</div>
<div className="text-3xl font-semibold">GCS: {result.total}/15</div>
<div className="text-sm text-muted-foreground">{result.severity}</div>
<div className="mt-2 text-xs text-muted-foreground">Interpretation: 13-15 Mild, 9-12 Moderate, 3-8 Severe/Coma.</div>
</div>
)}
</section>
);
}
function LegacyPanel({ pill }: { pill: Pill }) {
return (
<section className={card} data-testid={'calc-panel-' + pill.id}>
<h2 className="text-lg font-semibold">{pill.label}</h2>
<p className="text-sm text-muted-foreground">{pill.summary}</p>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
<p className="text-amber-900 dark:text-amber-100">
<strong>Source of truth:</strong> {pill.source}.
</p>
<p className="text-amber-900 dark:text-amber-100">
This calculator runs in the legacy viewer. A React port is gated on capturing test vectors
from the vanilla implementation so the numerical output can be verified byte-for-byte
the migration checkpoint specifically flags this class of data as the one an LLM is most
likely to silently simplify.
</p>
</div>
<a href="/#calculators" className={btnPrimary + ' inline-block'}>
Open in legacy viewer
</a>
</section>
);
}
function BiliPanel() {
const [mode, setMode] = useState<'aap' | 'bhutani'>('aap');
const [ga, setGa] = useState('38');
const [hours, setHours] = useState('');
const [tsb, setTsb] = useState('');
const [risk, setRisk] = useState<BiliRisk>('low');
const [aapResult, setAapResult] = useState<ReturnType<typeof classifyAapBili> | null>(null);
const [bhutResult, setBhutResult] = useState<ReturnType<typeof classifyBhutani> | null>(null);
const [error, setError] = useState('');
function calc() {
const hoursNum = Number(hours);
const tsbNum = Number(tsb);
if (!Number.isFinite(hoursNum) || !Number.isFinite(tsbNum) || hoursNum <= 0 || tsbNum <= 0) {
setError('Enter hours of life and TSB (mg/dL).');
setAapResult(null);
setBhutResult(null);
return;
}
setError('');
if (mode === 'aap') {
const gaNum = Number(ga);
if (!Number.isFinite(gaNum) || gaNum < 35) {
setError('AAP 2022 thresholds apply to GA ≥35 weeks.');
setAapResult(null);
return;
}
setAapResult(classifyAapBili(gaNum, hoursNum, tsbNum, risk));
setBhutResult(null);
} else {
setBhutResult(classifyBhutani(hoursNum, tsbNum));
setAapResult(null);
}
}
const statusColor = aapResult
? aapResult.status === 'Above Exchange' ? 'text-red-800 bg-red-100'
: aapResult.status === 'Above Phototherapy' ? 'text-red-700 bg-red-50'
: 'text-green-700 bg-green-50'
: '';
const zoneColor = bhutResult
? bhutResult.zone === 'High-Risk' ? 'text-red-800 bg-red-100'
: bhutResult.zone === 'High-Intermediate' ? 'text-orange-700 bg-orange-50'
: bhutResult.zone === 'Low-Intermediate' ? 'text-amber-700 bg-amber-50'
: 'text-green-700 bg-green-50'
: '';
return (
<section className={card} data-testid="calc-panel-bili">
<h2 className="text-lg font-semibold">Bilirubin</h2>
<div className="flex gap-2">
<button type="button" onClick={() => setMode('aap')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'aap' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-aap">AAP 2022 Phototherapy</button>
<button type="button" onClick={() => setMode('bhutani')} className={'px-3 py-1 rounded text-xs font-medium ' + (mode === 'bhutani' ? 'bg-primary text-primary-foreground' : 'bg-muted')} data-testid="bili-mode-bhutani">Bhutani Nomogram</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{mode === 'aap' && (
<>
<div className={field}>
<label htmlFor="bili-ga" className={label}>GA (weeks)</label>
<select id="bili-ga" className={input} value={ga} onChange={(e) => setGa(e.target.value)}>
{[35, 36, 37, 38, 39, 40].map((g) => <option key={g} value={g}>{g}{g === 40 ? '+' : ''}</option>)}
</select>
</div>
<div className={field}>
<label htmlFor="bili-risk" className={label}>Neurotoxicity risk</label>
<select id="bili-risk" className={input} value={risk} onChange={(e) => setRisk(e.target.value as BiliRisk)}>
<option value="low">No risk factors</option>
<option value="medium">With risk factors</option>
</select>
</div>
</>
)}
<FormField id="bili-hours" labelText="Age (hours)" value={hours} onChange={setHours} min="0" max="336" placeholder="48" />
<FormField id="bili-tsb" labelText="TSB (mg/dL)" value={tsb} onChange={setTsb} min="0" max="50" placeholder="15" />
</div>
<div className="flex gap-2">
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-bili-calculate">Calculate</button>
<button type="button" className={btnGhost} onClick={() => { setHours(''); setTsb(''); setAapResult(null); setBhutResult(null); setError(''); }}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{aapResult && (
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-aap-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + statusColor}>{aapResult.status}</div>
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life (GA {ga}w {risk === 'medium' ? 'with' : 'without'} risk factors)</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">Phototherapy</span><div className="font-semibold">{aapResult.photoThreshold.toFixed(1)} mg/dL</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Exchange</span><div className="font-semibold text-red-800">{aapResult.exchangeThreshold.toFixed(1)} mg/dL</div></div>
</div>
<div className="text-xs text-muted-foreground italic">AAP 2022 CPG (Kemper et al.). Always use clinical judgment.</div>
</div>
)}
{bhutResult && (
<div className={resultBox + ' space-y-2'} data-testid="calc-bili-bhutani-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + zoneColor}>{bhutResult.zone} Zone</div>
<div className="text-sm">TSB {tsb} mg/dL at {hours} hours of life</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">40th %ile</span><div className="font-semibold">{bhutResult.p40.toFixed(1)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">75th %ile</span><div className="font-semibold">{bhutResult.p75.toFixed(1)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">95th %ile</span><div className="font-semibold">{bhutResult.p95.toFixed(1)}</div></div>
</div>
<div className="text-xs text-muted-foreground italic">Bhutani 1999 hour-specific risk nomogram for infants 35 weeks GA.</div>
</div>
)}
</section>
);
}
function GrowthPanel() {
const [sex, setSex] = useState<Sex>('male');
const [ga, setGa] = useState('');
const [weight, setWeight] = useState('');
const [result, setResult] = useState<ReturnType<typeof fentonWeightForAge> | null>(null);
const [error, setError] = useState('');
function calc() {
const gaNum = Number(ga);
const wtNum = Number(weight);
if (!Number.isFinite(gaNum) || !Number.isFinite(wtNum) || gaNum < 22 || gaNum > 50 || wtNum <= 0) {
setError('Enter GA (22-50 weeks) and weight (grams).');
setResult(null);
return;
}
setError('');
setResult(fentonWeightForAge(gaNum, wtNum, sex));
}
const classification = result ? classifySizeForAge(result.percentile) : null;
const classColor = classification === 'SGA' ? 'text-orange-700 bg-orange-50'
: classification === 'LGA' ? 'text-amber-700 bg-amber-50'
: 'text-green-700 bg-green-50';
return (
<section className={card} data-testid="calc-panel-growth">
<h2 className="text-lg font-semibold">Fenton 2013 Preterm Growth</h2>
<p className="text-sm text-muted-foreground">Weight-for-gestational-age Z-score + percentile + SGA/AGA/LGA classification.</p>
<div className="grid gap-3 sm:grid-cols-3">
<div className={field}>
<label htmlFor="fenton-sex" className={label}>Sex</label>
<select id="fenton-sex" className={input} value={sex} onChange={(e) => setSex(e.target.value as Sex)}>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<FormField id="fenton-ga" labelText="GA (weeks)" value={ga} onChange={setGa} min="22" max="50" step="0.1" placeholder="32" />
<FormField id="fenton-weight" labelText="Weight (g)" value={weight} onChange={setWeight} min="200" max="7000" step="10" placeholder="1500" />
</div>
<div className="flex gap-2">
<button type="button" className={btnPrimary} onClick={calc} data-testid="calc-fenton-calculate">Calculate</button>
<button type="button" className={btnGhost} onClick={() => { setGa(''); setWeight(''); setResult(null); setError(''); }}>Clear</button>
</div>
{error && <div className={errorBox}>{error}</div>}
{result && classification && (
<div className={resultBox + ' space-y-2'} data-testid="calc-fenton-result">
<div className={'inline-block px-2 py-1 rounded text-sm font-bold ' + classColor}>{classification}</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div><span className="text-xs uppercase text-muted-foreground">Percentile</span><div className="font-semibold">{result.percentile.toFixed(1)}%</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Z-score</span><div className="font-semibold">{result.z.toFixed(2)}</div></div>
<div><span className="text-xs uppercase text-muted-foreground">Median (M)</span><div className="font-semibold">{Math.round(result.M)} g</div></div>
<div><span className="text-xs uppercase text-muted-foreground">L / S</span><div className="font-mono text-xs">{result.L.toFixed(3)} / {result.S.toFixed(3)}</div></div>
</div>
<div className="text-xs text-muted-foreground italic">Fenton TR, Kim JH. Systematic review revised Fenton growth chart for preterm infants. BMC Pediatr 2013;13:59.</div>
</div>
)}
</section>
);
}
function ActivePanel({ pill }: { pill: Pill }) {
if (pill.id === 'bsa') return <BsaPanel />;
if (pill.id === 'dose') return <DosePanel />;
if (pill.id === 'gcs') return <GcsPanel />;
if (pill.id === 'bili') return <BiliPanel />;
if (pill.id === 'growth') return <GrowthPanel />;
if (pill.id === 'bmi') return <BmiPanel />;
if (pill.id === 'vitals') return <VitalsPanel />;
if (pill.id === 'resus') return <ResusPanel />;
if (pill.id === 'equipment') return <EquipmentPanel />;
if (pill.id === 'bp') return <BpPanel />;
return <LegacyPanel pill={pill} />;
}
export default function Calculators() {
const [active, setActive] = useState<string>(PILLS[0].id);
const pill = PILLS.find((p) => p.id === active) ?? PILLS[0];
return (
<div className="max-w-5xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Calculators</h1>
<p className="text-sm text-muted-foreground">
Pediatric calculators BP percentiles, bilirubin thresholds, growth, dosing, equipment sizing.
Simple pure-formula calculators run in React now; high-risk table-driven calculators remain
legacy-gated until vectors are captured.
</p>
</header>
<div className="flex flex-wrap gap-2" data-testid="calc-subnav">
{PILLS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setActive(p.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
(active === p.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'calc-pill-' + p.id}
>
{p.label}{p.ported ? <span className="ml-1 text-[10px] opacity-80">React</span> : null}
</button>
))}
</div>
<ActivePanel pill={pill} />
</div>
);
}

View file

@ -0,0 +1,88 @@
// ============================================================
// CATCH-UP SCHEDULE — CDC catch-up immunization tables from
// GET /api/schedule-data.
// ============================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface CatchUpSeries { dose: number | string; minimumAge?: string; minimumIntervalToPrev?: string; notes?: string }
interface CatchUpEntry {
minimumAgeForDose1?: string;
series?: CatchUpSeries[];
catchUpNotes?: string | string[];
}
interface ScheduleData {
catchUpSchedule: Record<string, CatchUpEntry>;
vaccineFullNames: Record<string, string>;
}
export default function Catchup() {
const { data, isLoading, error } = useQuery<ScheduleData>({
queryKey: ['schedule-data'],
queryFn: () => api.get<ScheduleData>('/api/schedule-data'),
});
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Loading</div>;
if (error) return <div className="p-6 text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Catch-Up Schedule</h1>
<p className="text-sm text-muted-foreground">
CDC 2025 catch-up immunization schedule minimum ages and intervals per vaccine.
</p>
</header>
{Object.entries(data.catchUpSchedule).map(([key, v]) => {
const fullName = data.vaccineFullNames[key] || key;
const notes = v.catchUpNotes
? (Array.isArray(v.catchUpNotes) ? v.catchUpNotes : [v.catchUpNotes])
: [];
return (
<section key={key} className="rounded-lg border border-border bg-card overflow-hidden">
<header className="px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between">
<h2 className="text-sm font-semibold">{fullName}</h2>
{v.minimumAgeForDose1 && (
<span className="text-xs text-muted-foreground">
Min age dose 1: <strong>{v.minimumAgeForDose1}</strong>
</span>
)}
</header>
{v.series && v.series.length > 0 && (
<table className="w-full text-xs">
<thead className="bg-muted/20">
<tr>
<th className="text-left px-3 py-2">Dose</th>
<th className="text-left px-3 py-2">Min age</th>
<th className="text-left px-3 py-2">Min interval from prev</th>
<th className="text-left px-3 py-2">Notes</th>
</tr>
</thead>
<tbody>
{v.series.map((s) => (
<tr key={String(s.dose)} className="border-t border-border">
<td className="px-3 py-2 font-semibold">Dose {s.dose}</td>
<td className="px-3 py-2">{s.minimumAge || '—'}</td>
<td className="px-3 py-2">{s.minimumIntervalToPrev || '—'}</td>
<td className="px-3 py-2 text-muted-foreground">{s.notes || ''}</td>
</tr>
))}
</tbody>
</table>
)}
{notes.length > 0 && (
<ul className="list-disc pl-8 py-2 text-xs text-muted-foreground space-y-1">
{notes.map((n, i) => <li key={i}>{n}</li>)}
</ul>
)}
</section>
);
})}
</div>
);
}

View file

@ -0,0 +1,197 @@
// ============================================================
// CHART REVIEW — /api/generate-chart-review
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
const TYPE = 'chart' as const;
interface VisitInput { date: string; content: string; labs: string }
function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; }
export default function ChartReview() {
const [label, setLabel] = useState('');
const [type, setType] = useState<ReviewType>('outpatient');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [pmh, setPmh] = useState('');
const [visits, setVisits] = useState<VisitInput[]>([emptyVisit()]);
const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<string | null>(null);
const generate = useMutation<ChartReviewOk, Error, any>({
mutationFn: (body) => api.post<ChartReviewOk>('/api/generate-chart-review', body),
onSuccess: (data) => setResult(data.review),
});
function updateVisit(i: number, patch: Partial<VisitInput>) {
setVisits((vs) => vs.map((v, idx) => (idx === i ? { ...v, ...patch } : v)));
}
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
const filled = visits.filter((v) => v.content.trim());
generate.mutate({
type,
patientAge, patientGender, pmh,
visits: type === 'outpatient' ? filled : undefined,
subspecialty: type === 'subspecialty' ? filled : undefined,
edVisits: type === 'ed' ? filled : undefined,
additionalInstructions: additionalInstructions || undefined,
});
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Chart Review</h1>
<p className="text-sm text-muted-foreground">
Past visits summary for pre-charting.
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={JSON.stringify(visits)} generatedNote={result || ''}
partialData={{ type, age: patientAge, gender: patientGender, pmh, additionalInstructions }}
onLoad={(enc) => {
try {
const parsedVisits = enc.transcript ? JSON.parse(enc.transcript) as VisitInput[] : [emptyVisit()];
setVisits(parsedVisits.length ? parsedVisits : [emptyVisit()]);
} catch { setVisits([emptyVisit()]); }
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.type) setType(pd.type);
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.pmh) setPmh(pd.pmh);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setVisits([emptyVisit()]); setResult(null);
setType('outpatient'); setPatientAge(''); setPatientGender('');
setPmh(''); setAdditionalInstructions('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Review type</span>
<select className={input} value={type} onChange={(e) => setType(e.target.value as ReviewType)}>
<option value="outpatient">Outpatient</option>
<option value="subspecialty">Subspecialty</option>
<option value="ed">ED</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option><option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PMH</span>
<input className={input} value={pmh} onChange={(e) => setPmh(e.target.value)} />
</label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Visits</span>
<button
type="button"
onClick={() => setVisits((v) => [...v, emptyVisit()])}
className="text-xs rounded-md border border-border px-2 py-1"
>
+ Add visit
</button>
</div>
{visits.map((v, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card">
<div className="flex items-center gap-2">
<input
type="date"
className={input + ' max-w-xs'}
value={v.date}
onChange={(e) => updateVisit(i, { date: e.target.value })}
/>
{visits.length > 1 && (
<button
type="button"
onClick={() => setVisits(vs => vs.filter((_, idx) => idx !== i))}
className="text-xs text-destructive"
>
Remove
</button>
)}
</div>
<textarea
className={input + ' min-h-[100px] font-mono text-sm'}
placeholder="Visit note content — paste here."
value={v.content}
onChange={(e) => updateVisit(i, { content: e.target.value })}
/>
<textarea
className={input + ' min-h-[60px] font-mono text-xs'}
placeholder="Labs from this visit (optional)"
value={v.labs}
onChange={(e) => updateVisit(i, { labs: e.target.value })}
/>
</div>
))}
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions</span>
<textarea
className={input + ' min-h-[60px] text-sm'}
placeholder="e.g. 'Focus on thyroid management', 'Highlight medication changes'"
value={additionalInstructions}
onChange={(e) => setAdditionalInstructions(e.target.value)}
/>
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !visits.some((v) => v.content.trim())}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Chart Review'}
</button>
</form>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section={null}
title="Chart Review"
exportLabel="chart-review"
exportType="chart-review"
sourceContext={visits.map((v) => v.content).filter(Boolean).join('\n\n')}
/>
)}
</div>
);
}

76
client/src/pages/Cms.tsx Normal file
View file

@ -0,0 +1,76 @@
// ============================================================
// CMS — Learning Hub Content Manager. Faithful port of vanilla
// public/components/cms.html + the loadCms / loadCmsContent / etc.
// section of public/js/learningHub.js (@be14578).
//
// Server gates this with moderatorMiddleware (admin OR moderator),
// so the route is mounted unconditionally and the server returns
// 403 to non-moderators. The sidebar nav link is gated by
// `me.user.role` to keep it hidden from clinicians.
//
// Sub-components live under src/pages/cms/:
// StatsBar — 6-cell metrics summary
// CategoriesPanel — category list + add/delete + filters
// ContentList — table + toolbar (new article/quiz/pearl/presentation)
// ContentEditor — title/category/type/body + per-quiz QuestionsEditor
//
// What's intentionally NOT in this first cut (each of these is a
// follow-up commit if Daniel actually starts using them):
// • AI generation panel (vanilla `lh-ai-panel`)
// • WebDAV file picker for AI sources
// • Drag-and-drop file upload for AI ingest
// • Rich-text body editor (Quill toolbar) — body is a textarea
// • Slide editor for presentations — body holds JSON for now
// ============================================================
import { useState } from 'react';
import StatsBar from './cms/StatsBar';
import CategoriesPanel from './cms/CategoriesPanel';
import ContentList from './cms/ContentList';
import ContentEditor from './cms/ContentEditor';
import type { ContentType } from './cms/cms-types';
type View = { kind: 'list' } | { kind: 'edit'; id: number | null; type: ContentType };
export default function Cms() {
const [statusFilter, setStatusFilter] = useState<'all' | 'published' | 'draft'>('all');
const [categoryFilter, setCategoryFilter] = useState<number | 'all'>('all');
const [view, setView] = useState<View>({ kind: 'list' });
return (
<div className="max-w-6xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Content Manager</h1>
<p className="text-sm text-muted-foreground">
Create and manage Learning Hub content, quizzes, and categories.
</p>
</header>
<StatsBar />
<div className="flex flex-col lg:flex-row gap-4">
<CategoriesPanel
statusFilter={statusFilter}
onStatusFilter={setStatusFilter}
categoryFilter={categoryFilter}
onCategoryFilter={setCategoryFilter}
/>
{view.kind === 'list' ? (
<ContentList
statusFilter={statusFilter}
categoryFilter={categoryFilter}
onEdit={(id) => setView({ kind: 'edit', id, type: 'article' })}
onCreate={(type) => setView({ kind: 'edit', id: null, type })}
/>
) : (
<ContentEditor
id={view.id}
initialType={view.type}
onClose={() => setView({ kind: 'list' })}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,171 @@
// ============================================================
// DICTATION — voice dictation → HPI via /api/generate-hpi-dictation
//
// Minimum-viable port: demographics + transcript textarea + generate.
// The vanilla version also has MediaRecorder-based audio capture,
// transcription upload, save/load popover, refine, shorten, and
// Nextcloud export. Those each land in follow-up commits — this
// first pass proves the generate-HPI wire protocol works from React.
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'dictation' as const;
export default function Dictation() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [setting, setSetting] = useState<Setting>('outpatient');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-dictation', body),
onSuccess: (data) => setResult(data.hpi),
onError: () => setResult(null),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: HpiEncounterRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, setting };
const parsed = HpiEncounterRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
setResult(null);
generate.mutate(parsed.data);
}
function clear() {
setTranscript('');
setInterim('');
setResult(null);
setValidationError(null);
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Voice Dictation HPI</h1>
<p className="text-sm text-muted-foreground">
Dictate your narrative AI restructures into polished HPI.
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, setting }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.setting) setSetting(pd.setting);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => { clear(); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} placeholder="e.g. 8 months" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Setting</span>
<select className={input} value={setting} onChange={(e) => setSetting(e.target.value as Setting)}>
<option value="outpatient">Outpatient</option>
<option value="inpatient">Inpatient / Floors</option>
</select>
</label>
</div>
<Recorder
module="dictation"
onTranscript={(text, meta) => {
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Transcript / dictation
</span>
<button type="button" onClick={clear} className="text-xs text-muted-foreground underline">
Clear
</button>
</div>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Click Start recording, or type / paste your dictation here."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<div className="flex gap-2">
<button
type="submit"
disabled={generate.isPending || !displayedTranscript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate HPI'}
</button>
</div>
</form>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="hpi"
title="Generated HPI"
exportLabel="hpi-dictation"
exportType="hpi"
sourceContext={displayedTranscript}
/>
)}
</div>
);
}

View file

@ -0,0 +1,156 @@
// ============================================================
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter.
// Full port — mic recorder + Web Speech live preview + transcribe
// + save/resume across sign-outs (mirrors public/js/liveEncounter.js
// + encounters.js).
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'encounter' as const;
export default function Encounter() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [setting, setSetting] = useState<Setting>('outpatient');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-encounter', body),
onSuccess: (data) => setResult(data.hpi),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: HpiEncounterRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, setting };
const parsed = HpiEncounterRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
setResult(null);
generate.mutate(parsed.data);
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Live Encounter HPI</h1>
<p className="text-sm text-muted-foreground">
Record or paste an encounter transcript; generate a structured HPI.
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, setting }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.setting) setSetting(pd.setting);
} catch { /* ignore malformed partial */ }
setLabel(enc.label || '');
}}
onClear={() => { setTranscript(''); setInterim(''); setResult(null); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} placeholder="e.g. 5 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Setting</span>
<select className={input} value={setting} onChange={(e) => setSetting(e.target.value as Setting)}>
<option value="outpatient">Outpatient</option>
<option value="inpatient">Inpatient / Floors</option>
</select>
</label>
</div>
<Recorder
module="encounter"
onTranscript={(text, meta) => {
// appended=true means the live preview text is being kept;
// appended=false means we got a fresh transcription that
// should replace what's there.
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Transcript
</span>
<textarea
className={input + ' min-h-[220px] font-mono text-sm'}
placeholder="Click Start recording, or type / paste an encounter transcript here."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !displayedTranscript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate HPI'}
</button>
</form>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="encounter"
title="Generated HPI"
exportLabel="hpi-encounter"
exportType="hpi"
sourceContext={displayedTranscript}
/>
)}
</div>
);
}

View file

@ -0,0 +1,153 @@
// ============================================================
// EXTENSIONS — first tab ported from vanilla JS to React.
// Read-only list view with a simple add form. The old vanilla
// version has richer UI (trash, restore, purge, search) — this
// minimum-viable port proves the migration pipeline works:
// shared types + api wrapper + React Query + Tailwind shadcn.
// The full CRUD UI lands in a follow-up when polish time arrives.
// ============================================================
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { ExtensionsListOk, Extension } from '@/shared/types';
import { ExtensionCreateSchema, type ExtensionCreate } from '@/shared/schemas';
function ExtensionRow({ ext }: { ext: Extension }) {
return (
<div className="flex items-center gap-3 px-4 py-2 border-b border-border">
<div className="flex-1">
<div className="font-medium">{ext.name}</div>
<div className="text-xs text-muted-foreground">{ext.location}</div>
</div>
<div className="font-mono text-sm">{ext.number}</div>
<div className="text-xs uppercase text-muted-foreground w-20 text-right">
{ext.type}
</div>
</div>
);
}
function AddForm({ onDone }: { onDone: () => void }) {
const qc = useQueryClient();
const [form, setForm] = useState<ExtensionCreate>({
location: '',
name: '',
number: '',
type: 'extension',
notes: '',
});
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: (body: ExtensionCreate) => api.post<{ id: number }>('/api/extensions', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['extensions'] });
onDone();
},
onError: (e: Error) => setError(e.message),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const parsed = ExtensionCreateSchema.safeParse(form);
if (!parsed.success) {
setError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
createMutation.mutate(parsed.data);
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
return (
<form onSubmit={submit} className="space-y-3 p-4 bg-muted/40 rounded-lg border border-border">
<div className="grid grid-cols-2 gap-3">
<input
className={input}
placeholder="Location (e.g. Main Hospital)"
value={form.location}
onChange={e => setForm({ ...form, location: e.target.value })}
/>
<input
className={input}
placeholder="Name / department"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
/>
<input
className={input}
placeholder="Number"
value={form.number}
onChange={e => setForm({ ...form, number: e.target.value })}
/>
<select
className={input}
value={form.type}
onChange={e => setForm({ ...form, type: e.target.value as 'extension' | 'pager' })}
>
<option value="extension">Extension</option>
<option value="pager">Pager</option>
</select>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<div className="flex gap-2">
<button
type="submit"
disabled={createMutation.isPending}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{createMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={onDone} className="rounded-md border border-border px-4 py-2 text-sm">
Cancel
</button>
</div>
</form>
);
}
export default function Extensions() {
const [adding, setAdding] = useState(false);
const { data, isLoading, error } = useQuery<ExtensionsListOk>({
queryKey: ['extensions'],
queryFn: () => api.get<ExtensionsListOk>('/api/extensions'),
});
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Pagers & Extensions</h1>
<p className="text-sm text-muted-foreground">
Per-user directory. This React port is the migration proof-of-life.
</p>
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium"
>
+ Add
</button>
)}
</header>
{adding && <AddForm onDone={() => setAdding(false)} />}
{isLoading && <div className="text-sm text-muted-foreground">Loading</div>}
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
{data && data.items.length === 0 && (
<div className="text-sm text-muted-foreground italic py-8 text-center">
No extensions yet. Click Add to create the first one.
</div>
)}
{data && data.items.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
{data.items.map((ext: Extension) => <ExtensionRow key={ext.id} ext={ext} />)}
</div>
)}
</div>
);
}

57
client/src/pages/Faq.tsx Normal file
View file

@ -0,0 +1,57 @@
// ============================================================
// FAQ — ported from public/components/faq.html. Same content,
// same sectioned layout, collapsible questions. Content lives in
// data/faq.ts so adding an entry is a one-line data change.
// ============================================================
import { useState } from 'react';
import { FAQ_DATA } from '@/data/faq';
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-border last:border-0">
<button
onClick={() => setOpen(!open)}
className="w-full text-left py-3 px-4 flex items-center justify-between hover:bg-muted/40 transition-colors"
aria-expanded={open}
>
<span className="font-medium text-sm">{q}</span>
<span className="text-muted-foreground text-sm">{open ? '' : '+'}</span>
</button>
{open && (
<div className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
{a}
</div>
)}
</div>
);
}
export default function Faq() {
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<header>
<h1 className="text-2xl font-semibold">Frequently Asked Questions</h1>
<p className="text-sm text-muted-foreground">
Learn how Pediatric AI Scribe works and get the most out of it.
</p>
</header>
{FAQ_DATA.map((section) => (
<section
key={section.section}
className="rounded-lg border border-border overflow-hidden"
>
<h2 className="bg-muted/40 px-4 py-2 text-sm font-semibold">
{section.section}
</h2>
<div className="bg-card">
{section.items.map((item) => (
<FaqItem key={item.q} q={item.q} a={item.a} />
))}
</div>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,198 @@
// ============================================================
// HOSPITAL COURSE — /api/generate-hospital-course
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HospitalCourseOk } from '@/shared/types';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
const TYPE = 'hospital' as const;
interface NoteEntry { date: string; type: string; content: string }
export default function HospitalCourse() {
const [label, setLabel] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [pmh, setPmh] = useState('');
const [setting, setSetting] = useState<SettingKind>('floor');
const [los, setLos] = useState('');
const [format, setFormat] = useState<FormatKind>('auto');
const [hAndPContent, setHAndPContent] = useState('');
const [notesText, setNotesText] = useState('');
const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<{ hospitalCourse: string; format: string } | null>(null);
const generate = useMutation<HospitalCourseOk, Error, any>({
mutationFn: (body) => api.post<HospitalCourseOk>('/api/generate-hospital-course', body),
onSuccess: (data) => setResult({ hospitalCourse: data.hospitalCourse, format: data.format || 'auto' }),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
// Notes textarea: one blank-line-separated note per block. First
// line of each block is used as the date if it looks like one,
// rest becomes content.
const notes: NoteEntry[] = notesText
.split(/\n\s*\n/)
.map((block) => block.trim())
.filter(Boolean)
.map((block, i) => ({ date: `Day ${i + 1}`, type: 'Progress Note', content: block }));
generate.mutate({
notes,
hAndP: hAndPContent ? { date: 'Admission', content: hAndPContent } : undefined,
patientAge, patientGender, pmh, setting,
los: los ? parseInt(los) : undefined,
formatPreference: format,
additionalInstructions: additionalInstructions || undefined,
});
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Hospital Course</h1>
<p className="text-sm text-muted-foreground">
Progress notes + H&amp;P hospital course summary (prose, day-by-day, or organ-system format).
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={notesText} generatedNote={result?.hospitalCourse || ''}
partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, hAndPContent, additionalInstructions }}
onLoad={(enc) => {
setNotesText(enc.transcript || '');
setResult(enc.generated_note ? { hospitalCourse: enc.generated_note, format: 'auto' } : null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.pmh) setPmh(pd.pmh);
if (pd?.setting) setSetting(pd.setting);
if (pd?.los) setLos(pd.los);
if (pd?.format) setFormat(pd.format);
if (pd?.hAndPContent) setHAndPContent(pd.hAndPContent);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setNotesText(''); setResult(null);
setPatientAge(''); setPatientGender(''); setPmh('');
setSetting('floor'); setLos(''); setFormat('auto');
setHAndPContent(''); setAdditionalInstructions('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option><option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Setting</span>
<select className={input} value={setting} onChange={(e) => setSetting(e.target.value as SettingKind)}>
<option value="floor">Floor</option>
<option value="picu">PICU</option>
<option value="nicu">NICU</option>
<option value="psych">Psych</option>
</select>
</label>
</div>
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1 col-span-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PMH</span>
<input className={input} placeholder="e.g. Asthma, hypothyroidism" value={pmh} onChange={(e) => setPmh(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">LOS (days)</span>
<input className={input} type="number" value={los} onChange={(e) => setLos(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Format</span>
<select className={input} value={format} onChange={(e) => setFormat(e.target.value as FormatKind)}>
<option value="auto">Auto (infer from setting + LOS)</option>
<option value="prose">Prose summary</option>
<option value="dayByDay">Day-by-day</option>
<option value="organSystem">Organ-system (ICU)</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">H&amp;P</span>
<textarea className={input + ' min-h-[120px] font-mono text-sm'} value={hAndPContent} onChange={(e) => setHAndPContent(e.target.value)} />
</label>
<Recorder
module="hospital"
onTranscript={(text, meta) => {
if (meta.appended) {
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
} else {
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
}
setRecError(null);
}}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Progress notes <span className="normal-case font-normal text-muted-foreground">(separate each note with a blank line)</span>
</span>
<textarea className={input + ' min-h-[200px] font-mono text-sm'} value={notesText} onChange={(e) => setNotesText(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Additional instructions</span>
<textarea className={input + ' min-h-[60px] text-sm'} value={additionalInstructions} onChange={(e) => setAdditionalInstructions(e.target.value)} />
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !notesText.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Hospital Course'}
</button>
</form>
{result && (
<EditableResult
text={result.hospitalCourse}
onChange={(t) => setResult({ hospitalCourse: t, format: result.format })}
section={null}
title={'Hospital Course (' + result.format + ')'}
exportLabel="hospital-course"
exportType="hospital-course"
sourceContext={notesText}
/>
)}
</div>
);
}

View file

@ -0,0 +1,482 @@
// ============================================================
// LEARNING HUB — pediatric education, pearls, and self-assessment
// quizzes.
//
// • Search box (keyword, posts to /api/learning/search)
// • Category pills (/api/learning/categories) filter the feed
// • Feed list (/api/learning/feed or /category/:slug depending on filter)
// • Viewer — rich HTML body rendered with sanitizeHtml() wrapper
// so admin-authored content displays formatting safely.
// • Slide viewer for content_type === 'presentation' — fetches
// pre-rendered HTML from /api/learning/content/:slug/slides.
// • Quiz (single / multi / true_false) + results with explanations
// • Per-user progress list (last 5 attempts)
//
// Endpoints all live in src/routes/learningHub.ts at /api/learning/*.
// ============================================================
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { sanitizeHtml } from '@/lib/sanitize';
import type {
LearningCategoriesOk,
LearningCategory,
LearningFeedListOk,
LearningFeedRow,
LearningContentOk,
LearningContentFull,
LearningQuestion,
QuizAnswer,
QuizSubmitOk,
LearningSlidesOk,
} from '@/shared/types';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const pill = 'px-3 py-1 rounded-full text-xs font-medium border transition-colors cursor-pointer';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50';
function typeBadge(t: string) {
switch (t) {
case 'quiz': return 'Quiz';
case 'pearl': return 'Pearl';
case 'presentation': return 'Slides';
default: return 'Article';
}
}
// ── Feed ────────────────────────────────────────────────────
function FeedCard({ row, onOpen }: { row: LearningFeedRow; onOpen: () => void }) {
return (
<button
type="button"
onClick={onOpen}
className="w-full text-left rounded-lg border border-border bg-card hover:bg-muted/60 p-4 transition-colors"
data-testid={'lh-feed-item-' + row.slug}
>
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide mb-1">
<span className="font-semibold">{typeBadge(row.content_type)}</span>
{row.category_name && <span>· {row.category_name}</span>}
{row.question_count ? <span>· {row.question_count} Q</span> : null}
</div>
<div className="text-sm font-semibold">{row.title}</div>
{row.subject && <div className="text-xs text-muted-foreground mt-0.5 truncate">{row.subject}</div>}
</button>
);
}
function Feed({
filter,
query,
onOpen,
}: {
filter: string; // category slug or '' for all
query: string;
onOpen: (slug: string) => void;
}) {
const key: unknown[] =
query
? ['learning-search', query]
: filter
? ['learning-category', filter]
: ['learning-feed'];
const { data, isLoading, error } = useQuery<LearningFeedListOk>({
queryKey: key,
queryFn: () => {
if (query) return api.get<LearningFeedListOk>('/api/learning/search?q=' + encodeURIComponent(query));
if (filter)
return api.get<LearningFeedListOk & { category?: LearningCategory }>(
'/api/learning/category/' + encodeURIComponent(filter),
);
return api.get<LearningFeedListOk>('/api/learning/feed?limit=30');
},
});
if (isLoading) return <div className="text-sm text-muted-foreground">Loading</div>;
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
const rows = data?.content || [];
if (rows.length === 0)
return <div className="text-sm text-muted-foreground italic py-4">No content found.</div>;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3" data-testid="lh-feed">
{rows.map((r) => <FeedCard key={r.id} row={r} onOpen={() => onOpen(r.slug)} />)}
</div>
);
}
// ── Viewer + Quiz ───────────────────────────────────────────
type AnswerMap = Record<number, { optionId?: number; optionIds: Set<number> }>;
function emptyAnswers(questions: LearningQuestion[]): AnswerMap {
const m: AnswerMap = {};
for (const q of questions) m[q.id] = { optionIds: new Set() };
return m;
}
function Quiz({
content,
onReset,
}: {
content: LearningContentFull;
onReset: () => void;
}) {
const qc = useQueryClient();
const [answers, setAnswers] = useState<AnswerMap>(() => emptyAnswers(content.questions));
const [result, setResult] = useState<QuizSubmitOk | null>(null);
const [error, setError] = useState<string | null>(null);
const submit = useMutation({
mutationFn: (body: { contentId: number; answers: QuizAnswer[] }) =>
api.post<QuizSubmitOk>('/api/learning/submit-quiz', body),
onSuccess: (data) => {
setResult(data);
// Refresh progress list the next time the viewer opens.
qc.invalidateQueries({ queryKey: ['learning-content', content.slug] });
},
onError: (e: Error) => setError(e.message || 'Submit failed'),
});
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const payload: QuizAnswer[] = content.questions.map((q) => {
const a = answers[q.id];
if (q.question_type === 'multi') {
return { questionId: q.id, optionIds: Array.from(a?.optionIds || []) };
}
return { questionId: q.id, optionId: a?.optionId ?? null };
});
submit.mutate({ contentId: content.id, answers: payload });
}
function selectSingle(q: LearningQuestion, optionId: number) {
setAnswers((prev) => ({ ...prev, [q.id]: { optionId, optionIds: new Set() } }));
}
function toggleMulti(q: LearningQuestion, optionId: number) {
setAnswers((prev) => {
const s = new Set(prev[q.id]?.optionIds || []);
if (s.has(optionId)) s.delete(optionId);
else s.add(optionId);
return { ...prev, [q.id]: { optionIds: s } };
});
}
if (result) {
const color =
result.percentage >= 80 ? 'bg-green-600'
: result.percentage >= 50 ? 'bg-amber-500'
: 'bg-destructive';
return (
<section className={card} data-testid="lh-quiz-results">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold">Results</h3>
<span
className={'px-2 py-0.5 rounded text-xs font-semibold text-white ' + color}
data-testid="lh-quiz-score"
>
{result.score}/{result.total} ({result.percentage}%)
</span>
</div>
<div className="space-y-3">
{result.results.map((r, idx) => (
<div key={r.questionId} className="rounded-md border border-border p-3 bg-muted/30">
<div className="text-sm font-medium">
<span className={r.isCorrect ? 'text-green-600' : 'text-destructive'}>
{r.isCorrect ? '✓' : '✗'}
</span>{' '}
Q{idx + 1}: {r.questionText}
</div>
{!r.isCorrect && r.correctOptionText && (
<div className="text-xs text-green-700 mt-1">
<strong>Correct:</strong> {r.correctOptionText}
</div>
)}
{!r.isCorrect && r.selectedExplanation && (
<div className="text-xs text-destructive mt-1">
<strong>Why incorrect:</strong> {r.selectedExplanation}
</div>
)}
{r.generalExplanation && (
<div className="text-xs text-muted-foreground mt-1">{r.generalExplanation}</div>
)}
</div>
))}
</div>
<div className="flex gap-2">
<button
type="button"
className={btnGhost}
onClick={() => {
setResult(null);
setAnswers(emptyAnswers(content.questions));
}}
>
Retake
</button>
<button type="button" className={btnPrimary} onClick={onReset}>Back to Feed</button>
</div>
</section>
);
}
return (
<section className={card} data-testid="lh-quiz">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Quiz</h3>
<span className="text-xs text-muted-foreground">
{content.questions.length} question{content.questions.length === 1 ? '' : 's'}
</span>
</div>
<form onSubmit={onSubmit} className="space-y-4">
{content.questions.map((q, idx) => {
const isMulti = q.question_type === 'multi';
const typeLabel =
q.question_type === 'true_false' ? 'True / False'
: isMulti ? 'Multiple Select'
: 'Single Choice';
return (
<div key={q.id} className="rounded-md border border-border p-3 space-y-2 bg-muted/30">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-semibold">Q{idx + 1}</span>
<span>{typeLabel}</span>
</div>
<div className="text-sm font-medium">{q.question_text}</div>
{isMulti && (
<div className="text-xs text-muted-foreground italic">Select all that apply</div>
)}
<div className="space-y-1">
{q.options.map((opt) => {
const a = answers[q.id];
const checked = isMulti
? a?.optionIds.has(opt.id) === true
: a?.optionId === opt.id;
return (
<label
key={opt.id}
className="flex items-start gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-2 py-1"
>
<input
type={isMulti ? 'checkbox' : 'radio'}
name={'q-' + q.id}
checked={checked}
onChange={() =>
isMulti ? toggleMulti(q, opt.id) : selectSingle(q, opt.id)
}
className="mt-0.5"
/>
<span>{opt.option_text}</span>
</label>
);
})}
</div>
</div>
);
})}
{error && <div className="text-sm text-destructive">{error}</div>}
<button
type="submit"
className={btnPrimary}
disabled={submit.isPending}
data-testid="btn-lh-submit-quiz"
>
{submit.isPending ? 'Submitting…' : 'Submit Answers'}
</button>
</form>
</section>
);
}
// Fetches pre-rendered Marp slides from the server and renders them
// one at a time with keyboard navigation. Slides arrive as <section>…
// elements already processed by the server-side Marp instance, so we
// run them through sanitizeHtml before injecting.
function SlideViewer({ slug, title }: { slug: string; title: string }) {
const [idx, setIdx] = useState(0);
const [fullscreen, setFullscreen] = useState(false);
const { data, isLoading, error } = useQuery<LearningSlidesOk>({
queryKey: ['learning-slides', slug],
queryFn: () => api.get<LearningSlidesOk>('/api/learning/content/' + encodeURIComponent(slug) + '/slides'),
});
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!data) return;
if (e.key === 'ArrowRight' || e.key === 'PageDown') setIdx((i) => Math.min(i + 1, data.slides.length - 1));
if (e.key === 'ArrowLeft' || e.key === 'PageUp') setIdx((i) => Math.max(0, i - 1));
if (e.key === 'Escape' && fullscreen) setFullscreen(false);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [data, fullscreen]);
if (isLoading) return <div className="text-sm text-muted-foreground">Loading slides</div>;
if (error) return <div className="text-sm text-destructive">Failed to load slides: {(error as Error).message}</div>;
if (!data || data.slides.length === 0) return <div className="text-sm text-muted-foreground">No slides in this presentation.</div>;
const sanitizedCss = data.css ? sanitizeHtml('<style>' + data.css + '</style>') : '';
const slide = sanitizeHtml(data.slides[idx] || '');
const containerClass = fullscreen
? 'fixed inset-0 z-50 bg-background flex flex-col'
: 'rounded-lg border border-border bg-white dark:bg-black flex flex-col';
return (
<div className={containerClass} data-testid="lh-slides">
{sanitizedCss && <div dangerouslySetInnerHTML={{ __html: sanitizedCss }} />}
<div className="flex items-center justify-between border-b border-border px-3 py-2 text-xs">
<span className="text-muted-foreground truncate">📊 {title}</span>
<div className="flex items-center gap-2">
<span>{idx + 1} / {data.slides.length}</span>
<button type="button" onClick={() => setFullscreen(!fullscreen)} className="px-2 py-1 rounded bg-muted text-xs" data-testid="lh-slides-fullscreen">
{fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 flex items-center justify-center" style={{ minHeight: fullscreen ? undefined : '480px' }}>
<div dangerouslySetInnerHTML={{ __html: slide }} data-testid="lh-slide-current" />
</div>
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<button type="button" onClick={() => setIdx((i) => Math.max(0, i - 1))} disabled={idx === 0} className={btnGhost} data-testid="lh-slides-prev"> Previous</button>
<span className="text-xs text-muted-foreground"> to navigate</span>
<button type="button" onClick={() => setIdx((i) => Math.min(i + 1, data.slides.length - 1))} disabled={idx >= data.slides.length - 1} className={btnGhost} data-testid="lh-slides-next">Next </button>
</div>
</div>
);
}
function ContentViewer({ slug, onBack }: { slug: string; onBack: () => void }) {
const { data, isLoading, error } = useQuery<LearningContentOk>({
queryKey: ['learning-content', slug],
queryFn: () => api.get<LearningContentOk>('/api/learning/content/' + encodeURIComponent(slug)),
});
if (isLoading) return <div className="text-sm text-muted-foreground">Loading</div>;
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
const c = data.content;
return (
<div className="space-y-4">
<button type="button" className={btnGhost} onClick={onBack} data-testid="btn-lh-back">
Back to Feed
</button>
<section className={card} data-testid="lh-viewer">
<div className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold" data-testid="lh-viewer-title">{c.title}</h2>
<span className="text-xs text-muted-foreground">
{typeBadge(c.content_type)}
{c.category_name ? ' · ' + c.category_name : ''}
{c.author_name ? ' · ' + c.author_name : ''}
</span>
</div>
{c.content_type === 'presentation' ? (
<SlideViewer slug={c.slug} title={c.title} />
) : (
<div
className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none"
data-testid="lh-viewer-body"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(c.body || '') }}
/>
)}
</section>
{c.progress && c.progress.length > 0 && (
<section className={card}>
<h3 className="text-base font-semibold">Your past attempts</h3>
<div className="space-y-1 text-sm">
{c.progress.map((p, i) => {
const pct = p.total > 0 ? Math.round((p.score / p.total) * 100) : 0;
const color = pct >= 70 ? 'text-green-600' : 'text-amber-600';
return (
<div key={i} className="flex justify-between border-b border-border py-1">
<span>{new Date(p.completed_at).toLocaleDateString()}</span>
<span className={'font-semibold ' + color}>
{p.score}/{p.total} ({pct}%)
</span>
</div>
);
})}
</div>
</section>
)}
{c.questions && c.questions.length > 0 && <Quiz content={c} onReset={onBack} />}
</div>
);
}
// ── Page shell ───────────────────────────────────────────────
export default function Learning() {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<string>('');
const [activeSlug, setActiveSlug] = useState<string | null>(null);
const { data: cats } = useQuery<LearningCategoriesOk>({
queryKey: ['learning-categories'],
queryFn: () => api.get<LearningCategoriesOk>('/api/learning/categories'),
});
if (activeSlug) {
return (
<div className="max-w-4xl mx-auto p-6">
<ContentViewer slug={activeSlug} onBack={() => setActiveSlug(null)} />
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Learning Hub</h1>
<p className="text-sm text-muted-foreground">
Pediatric education, clinical pearls, and self-assessment quizzes.
</p>
</header>
<div className={card}>
<input
type="search"
className={input}
placeholder="Search topics, subjects…"
value={query}
onChange={(e) => setQuery(e.target.value)}
data-testid="lh-search"
/>
</div>
<div className="flex flex-wrap gap-2" data-testid="lh-categories">
<button
type="button"
onClick={() => setFilter('')}
className={
pill +
(filter === '' ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80')
}
>
All
</button>
{cats?.categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setFilter(cat.slug)}
className={
pill +
(filter === cat.slug
? ' bg-primary text-primary-foreground border-primary'
: ' bg-muted hover:bg-muted/80')
}
data-testid={'lh-cat-' + cat.slug}
>
{cat.name}
</button>
))}
</div>
<Feed filter={filter} query={query.trim()} onOpen={(slug) => setActiveSlug(slug)} />
</div>
);
}

View file

@ -0,0 +1,471 @@
// ============================================================
// PHYSICAL EXAM GUIDE — full React port.
//
// Renders:
// • Age-group + system pills (6 × 4 = 24 combinations)
// • System overview banner
// • CV system extras: APTM legend, cardiac sounds, innocent murmurs
// • Resp system extras: respiratory sounds library
// • Collapsible grading-scales reference (system-scoped)
// • Component checklist with per-step normal / abnormal / (unset)
// toggle, abnormal-hints hint list, pearl + significance callouts
// • Patient age / gender + model inputs
// • Generate Exam Report → POST /api/generate-pe-narrative
//
// PE_DATA is the full hierarchy ported verbatim from vanilla
// peGuide.js (see client/src/data/pe-data.ts). Clinical reference
// libraries (scales, APTM, sound files) live in pe-guide.ts.
// ============================================================
import { useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { PeNarrativeOk } from '@/shared/types';
import {
PE_DATA,
AGE_GROUP_ORDER,
SYSTEM_ORDER,
SYSTEM_LABELS,
type PeComponent,
type PeStep,
} from '@/data/pe-data';
import {
SCALES,
SYSTEM_SCALES,
APTM_LEGEND,
INNOCENT_MURMURS,
RESP_SOUNDS,
CARDIAC_SOUNDS,
type ScaleDef,
type SoundEntry,
} from '@/data/pe-guide';
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const pill = 'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
type StepStatus = 'normal' | 'abnormal' | null;
// Key used to identify a step in the status map across age-group / system.
function stepKey(age: string, sys: string, componentIdx: number, stepIdx: number) {
return `${age}/${sys}/${componentIdx}/${stepIdx}`;
}
function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
return (
<section className="rounded-md border border-border bg-background p-3" data-testid={'scale-' + id}>
<h4 className="text-sm font-semibold mb-2">{scale.title}</h4>
<table className="w-full text-xs">
<tbody>
{scale.rows.map(([labelText, desc], i) => (
<tr key={i} className="border-b border-border last:border-0">
<td className="py-1 pr-3 font-mono font-semibold whitespace-nowrap">{labelText}</td>
<td className="py-1 text-muted-foreground">{desc}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function SoundCard({ entry }: { entry: SoundEntry }) {
return (
<div className="rounded-md border border-border bg-background p-3 space-y-2" data-testid={'sound-' + entry.key}>
<div className="text-sm font-semibold">{entry.title}</div>
<audio controls preload="none" className="w-full">
<source src={entry.src} />
</audio>
<div className="text-xs space-y-0.5 text-muted-foreground">
<div><span className="font-semibold">Where:</span> {entry.where}</div>
{entry.rate && <div><span className="font-semibold">Rate:</span> {entry.rate}</div>}
<div><span className="font-semibold">Features:</span> {entry.features}</div>
<div><span className="font-semibold">Clinical:</span> {entry.clinical}</div>
</div>
</div>
);
}
function StepRow({
step,
status,
onStatus,
}: {
step: PeStep;
status: StepStatus;
onStatus: (next: StepStatus) => void;
}) {
const base = 'text-xs font-medium px-2 py-1 rounded border';
return (
<div className="flex items-start gap-2 py-2 border-b border-border last:border-0">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{step.label}</div>
<div className="text-xs text-muted-foreground mt-0.5">
<span className="font-semibold uppercase tracking-wide">Method:</span> {step.method}
</div>
<div className="text-xs text-muted-foreground">
<span className="font-semibold uppercase tracking-wide">Normal:</span> {step.normal}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-1 flex-shrink-0">
<button
type="button"
onClick={() => onStatus(status === 'normal' ? null : 'normal')}
className={
base + ' ' +
(status === 'normal'
? 'bg-green-600 text-white border-green-600'
: 'border-green-600 text-green-700 hover:bg-green-50 dark:hover:bg-green-950/30')
}
>
Normal
</button>
<button
type="button"
onClick={() => onStatus(status === 'abnormal' ? null : 'abnormal')}
className={
base + ' ' +
(status === 'abnormal'
? 'bg-destructive text-white border-destructive'
: 'border-destructive text-destructive hover:bg-red-50 dark:hover:bg-red-950/30')
}
>
Abnormal
</button>
</div>
</div>
);
}
function ComponentCard({
age,
sys,
idx,
comp,
getStatus,
setStatus,
}: {
age: string;
sys: string;
idx: number;
comp: PeComponent;
getStatus: (k: string) => StepStatus;
setStatus: (k: string, next: StepStatus) => void;
}) {
return (
<div className={card} data-testid={`pe-component-${age}-${sys}-${idx}`}>
<h3 className="text-base font-semibold">{comp.name}</h3>
<div>
{comp.steps.map((step, si) => {
const k = stepKey(age, sys, idx, si);
return (
<StepRow
key={si}
step={step}
status={getStatus(k)}
onStatus={(next) => setStatus(k, next)}
/>
);
})}
</div>
{comp.abnormalHints.length > 0 && (
<div className="rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-destructive mb-1">Watch for</div>
<ul className="list-disc pl-5 text-xs text-red-900 dark:text-red-200 space-y-0.5">
{comp.abnormalHints.map((h, hi) => <li key={hi}>{h}</li>)}
</ul>
</div>
)}
{comp.pearl && (
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-800 p-3 text-xs text-amber-900 dark:text-amber-100">
<span className="font-semibold uppercase tracking-wide">Pearl:</span> {comp.pearl}
</div>
)}
{comp.significance && (
<div className="rounded-md bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-900 p-3 text-xs text-sky-900 dark:text-sky-100">
<span className="font-semibold uppercase tracking-wide">Significance:</span> {comp.significance}
</div>
)}
</div>
);
}
export default function PeGuide() {
const [age, setAge] = useState<(typeof AGE_GROUP_ORDER)[number]>('toddler');
const [sys, setSys] = useState<(typeof SYSTEM_ORDER)[number]>('msk');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
const [statusMap, setStatusMap] = useState<Record<string, StepStatus>>({});
const [narrative, setNarrative] = useState<string | null>(null);
const group = PE_DATA[age];
const section = group[sys];
const generate = useMutation({
mutationFn: (body: unknown) => api.post<PeNarrativeOk>('/api/generate-pe-narrative', body),
onSuccess: (data) => setNarrative(data.narrative),
onError: (e: Error) => setNarrative('Generation failed: ' + e.message),
});
const summary = useMemo(() => {
let normal = 0, abnormal = 0, notAssessed = 0;
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => {
const k = stepKey(age, sys, ci, si);
const v = statusMap[k] ?? null;
if (v === 'normal') normal++;
else if (v === 'abnormal') abnormal++;
else notAssessed++;
}),
);
return { normal, abnormal, notAssessed };
}, [age, sys, section, statusMap]);
function reset() {
// Only clear the current system's entries, not all state.
setStatusMap((prev) => {
const next = { ...prev };
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => { delete next[stepKey(age, sys, ci, si)]; }),
);
return next;
});
setNarrative(null);
}
function setAllNormal() {
setStatusMap((prev) => {
const next = { ...prev };
section.components.forEach((c, ci) =>
c.steps.forEach((_, si) => { next[stepKey(age, sys, ci, si)] = 'normal'; }),
);
return next;
});
}
function onGenerate() {
setNarrative(null);
const steps: Array<{ component: string; label: string; method: string; normal: string; status: StepStatus; note?: string }> = [];
section.components.forEach((c, ci) =>
c.steps.forEach((st, si) => {
steps.push({
component: c.name,
label: st.label,
method: st.method,
normal: st.normal,
status: statusMap[stepKey(age, sys, ci, si)] ?? null,
});
}),
);
generate.mutate({
steps,
ageGroup: age,
system: sys,
patientAge: patientAge || undefined,
patientGender: patientGender || undefined,
format,
});
}
const totalAssessed = summary.normal + summary.abnormal;
return (
<div className="max-w-5xl mx-auto p-6 space-y-5">
<header>
<h1 className="text-2xl font-semibold">Physical Exam Guide</h1>
<p className="text-sm text-muted-foreground">
Age-group and system-specific exam checklist with abnormal-finding hints. Toggle normal / abnormal
on each step, then generate a narrative for your note.
</p>
</header>
{/* Age-group pills */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Age group</div>
<div className="flex flex-wrap gap-2" data-testid="pe-age-group-pills">
{AGE_GROUP_ORDER.map((g) => (
<button
key={g}
type="button"
onClick={() => setAge(g)}
className={pill + (age === g ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
data-testid={'pe-age-' + g}
>
{PE_DATA[g].label}
</button>
))}
</div>
</div>
{/* System pills */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">System</div>
<div className="flex flex-wrap gap-2" data-testid="pe-system-pills">
{SYSTEM_ORDER.map((s) => (
<button
key={s}
type="button"
onClick={() => setSys(s)}
className={pill + (sys === s ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
data-testid={'pe-system-' + s}
>
{SYSTEM_LABELS[s]}
</button>
))}
</div>
</div>
{/* Overview */}
<section className={card + ' border-l-4 border-l-primary'} data-testid="pe-overview">
<h2 className="text-lg font-semibold">{group.label} {SYSTEM_LABELS[sys]}</h2>
<p className="text-sm text-muted-foreground">{section.overview}</p>
</section>
{/* System-specific references */}
{sys === 'cv' && (
<section className={card} data-testid="pe-cv-aptm">
<h3 className="text-base font-semibold">Auscultation landmarks (APTM + Erb's)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{APTM_LEGEND.map((p) => (
<div key={p.letter} className="flex gap-3 items-start rounded-md border border-border p-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white flex-shrink-0"
style={{ background: p.color }}
>
{p.letter}
</div>
<div className="min-w-0 text-sm">
<div className="font-semibold">{p.title}</div>
<div className="text-xs text-muted-foreground">{p.location}</div>
<div className="text-xs mt-1"><strong>Listen for:</strong> {p.listen}</div>
{p.innocent && <div className="text-xs text-green-700 dark:text-green-300 mt-1"><em>Innocent:</em> {p.innocent}</div>}
</div>
</div>
))}
</div>
<h3 className="text-base font-semibold mt-3">Cardiac sounds library</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
<h3 className="text-base font-semibold mt-3">Classic innocent murmurs</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{INNOCENT_MURMURS.map((m) => (
<div key={m.name} className="rounded-md border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-3 text-sm space-y-1">
<div className="font-semibold">{m.name}</div>
<div className="text-xs text-muted-foreground">Age: {m.age} · Location: {m.location}</div>
<div className="text-xs"><strong>Sound:</strong> {m.character}</div>
<div className="text-xs"><strong>Confirm innocent:</strong> {m.confirm}</div>
</div>
))}
</div>
</section>
)}
{sys === 'resp' && (
<section className={card} data-testid="pe-resp-sounds">
<h3 className="text-base font-semibold">Respiratory sounds library</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
</section>
)}
{/* Grading scales (system-scoped, collapsible) */}
{SYSTEM_SCALES[sys] && SYSTEM_SCALES[sys].length > 0 && (
<details className={card} data-testid="pe-scales">
<summary className="cursor-pointer font-semibold text-sm">Grading scales &amp; reference</summary>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
{SYSTEM_SCALES[sys].map((sk: string) => {
const scale = SCALES[sk];
if (!scale) return null;
return <ScaleCard key={sk} id={sk} scale={scale} />;
})}
</div>
</details>
)}
{/* Checklist */}
<section className="space-y-3" data-testid="pe-checklist">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Exam checklist</h2>
<div className="text-xs text-muted-foreground flex items-center gap-3">
<span className="text-green-600">{summary.normal} normal</span>
<span className="text-destructive">{summary.abnormal} abnormal</span>
<span>{summary.notAssessed} not assessed</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={setAllNormal} className={btnGhost} data-testid="btn-pe-all-normal">
Mark all normal
</button>
<button type="button" onClick={reset} className={btnGhost} data-testid="btn-pe-reset">
Reset
</button>
</div>
<div className="grid grid-cols-1 gap-3">
{section.components.map((c, ci) => (
<ComponentCard
key={ci}
age={age}
sys={sys}
idx={ci}
comp={c}
getStatus={(k) => statusMap[k] ?? null}
setStatus={(k, next) => setStatusMap((prev) => ({ ...prev, [k]: next }))}
/>
))}
</div>
</section>
{/* Generate narrative */}
<section className={card} data-testid="pe-generate">
<h2 className="text-lg font-semibold">Generate Exam Report</h2>
<p className="text-sm text-muted-foreground">Uses the statuses above + optional patient context.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<input
className={input}
placeholder="Patient age (e.g. 3y)"
value={patientAge}
onChange={(e) => setPatientAge(e.target.value)}
/>
<input
className={input}
placeholder="Patient gender (optional)"
value={patientGender}
onChange={(e) => setPatientGender(e.target.value)}
/>
<select
className={input}
value={format}
onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}
>
<option value="narrative">Narrative</option>
<option value="list">List</option>
</select>
</div>
<div className="flex items-center gap-3">
<button
type="button"
className={btnPrimary}
onClick={onGenerate}
disabled={generate.isPending || totalAssessed === 0}
data-testid="btn-pe-generate"
>
{generate.isPending ? 'Generating…' : 'Generate Exam Report'}
</button>
{totalAssessed === 0 && (
<span className="text-xs text-muted-foreground">Mark at least one step before generating.</span>
)}
</div>
{narrative && (
<div className="rounded-md border border-border bg-muted/40 p-3 whitespace-pre-wrap text-sm" data-testid="pe-narrative">
{narrative}
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,67 @@
// ============================================================
// RESET PASSWORD — landing for the email link (?token=xxx).
// POSTs /api/auth/reset-password with {token, newPassword}.
// ============================================================
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api, ApiError } from '@/lib/api';
const card = 'rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md';
const btnPrimary = 'w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-semibold text-muted-foreground mb-1';
const msgOk = 'rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 p-3 text-sm text-green-800 dark:text-green-100';
const msgErr = 'rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3 text-sm text-red-800 dark:text-red-100';
const msgInfo = 'rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-sm text-amber-900 dark:text-amber-100';
export default function ResetPassword() {
const [params] = useSearchParams();
const nav = useNavigate();
const token = params.get('token') || '';
const [newPassword, setNewPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [err, setErr] = useState('');
const [ok, setOk] = useState('');
const [warn, setWarn] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr(''); setOk(''); setWarn('');
if (!token) { setErr('Missing reset token. Open the reset link from your email.'); return; }
if (newPassword.length < 8) { setErr('Password must be 8+ characters'); return; }
if (newPassword !== confirm) { setErr('Passwords do not match'); return; }
setBusy(true);
try {
const r = await api.post<{ passwordWarning?: string }>('/api/auth/reset-password', { token, newPassword });
setOk('Password reset. You can now sign in.');
if (r.passwordWarning) setWarn(r.passwordWarning);
setTimeout(() => nav('/auth', { replace: true }), 2500);
} catch (e) {
setErr((e as ApiError).message || 'Reset failed. The link may have expired.');
} finally { setBusy(false); }
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900 flex items-center justify-center p-4">
<div className={card}>
<header className="text-center space-y-1">
<div className="text-4xl">🔑</div>
<h1 className="text-xl font-bold">Set a new password</h1>
<p className="text-xs text-muted-foreground">Choose a password at least 8 characters long.</p>
</header>
{err && <div className={msgErr}>{err}</div>}
{ok && <div className={msgOk}>{ok}</div>}
{warn && <div className={msgInfo}>{warn}</div>}
<form onSubmit={submit} className="space-y-3" data-testid="reset-password-form">
<div><label className={label}>New password</label><input type="password" required minLength={8} className={input} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoFocus data-testid="reset-new" /></div>
<div><label className={label}>Confirm new password</label><input type="password" required minLength={8} className={input} value={confirm} onChange={(e) => setConfirm(e.target.value)} data-testid="reset-confirm" /></div>
<button type="submit" className={btnPrimary} disabled={busy || !token} data-testid="reset-submit">
{busy ? 'Resetting…' : 'Reset password'}
</button>
</form>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
// ============================================================
// SICK VISIT — /api/sick-visit/note
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
import RosPeTable, { rosAllWnl, rosClear } from '@/components/RosPeTable';
import DxPicker from '@/components/DxPicker';
import {
ROS_SYSTEMS,
PE_SYSTEMS,
formatRosForAI,
formatDxForAI,
type RosData,
type DxEntry,
} from '@shared/clinical/ros-pe-dx';
const TYPE = 'sickvisit' as const;
export default function SickVisit() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [chiefComplaint, setChiefComplaint] = useState('');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [rosData, setRosData] = useState<RosData>({});
const [peData, setPeData] = useState<RosData>({});
const [diagnoses, setDiagnoses] = useState<DxEntry[]>([]);
const [dxFreetext, setDxFreetext] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<VisitNoteOk, Error, SickVisitRequest>({
mutationFn: (body) => api.post<VisitNoteOk>('/api/sick-visit/note', body),
onSuccess: (data) => setResult(data.note),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const rosText = formatRosForAI(ROS_SYSTEMS, rosData, 'Review of Systems');
const peText = formatRosForAI(PE_SYSTEMS, peData, 'Physical Examination');
const dxText = formatDxForAI(diagnoses, dxFreetext);
const body: SickVisitRequest = {
patientAge, patientGender, chiefComplaint,
transcript: (interim || transcript).trim(),
ros: rosText || undefined,
physicalExam: peText || undefined,
diagnoses: dxText || undefined,
};
const parsed = SickVisitRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
setResult(null);
generate.mutate(parsed.data);
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Sick Visit</h1>
<p className="text-sm text-muted-foreground">
Chief complaint + transcript structured sick-visit note.
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, chiefComplaint, rosData, peData, diagnoses, dxFreetext }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.chiefComplaint) setChiefComplaint(pd.chiefComplaint);
if (pd?.rosData) setRosData(pd.rosData);
if (pd?.peData) setPeData(pd.peData);
if (pd?.diagnoses) setDiagnoses(pd.diagnoses);
if (pd?.dxFreetext) setDxFreetext(pd.dxFreetext);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
setPatientAge(''); setPatientGender(''); setChiefComplaint('');
setRosData({}); setPeData({}); setDiagnoses([]); setDxFreetext('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} placeholder="e.g. 4 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1 col-span-3 md:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Chief complaint</span>
<input className={input} placeholder="e.g. Fever x 2 days" value={chiefComplaint} onChange={(e) => setChiefComplaint(e.target.value)} />
</label>
</div>
<Recorder
module="sickvisit"
onTranscript={(text, meta) => {
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Click Start recording, or type / paste encounter narrative."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Review of Systems</span>
<div className="flex gap-1">
<button type="button" onClick={() => setRosData(rosAllWnl(ROS_SYSTEMS, rosData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All WNL</button>
<button type="button" onClick={() => setRosData(rosClear(rosData, ROS_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={ROS_SYSTEMS}
data={rosData}
onChange={setRosData}
btnLabels={{ wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }}
testIdPrefix="sv-ros"
/>
</div>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Physical Examination</span>
<div className="flex gap-1">
<button type="button" onClick={() => setPeData(rosAllWnl(PE_SYSTEMS, peData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All Normal</button>
<button type="button" onClick={() => setPeData(rosClear(peData, PE_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={PE_SYSTEMS}
data={peData}
onChange={setPeData}
btnLabels={{ wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }}
testIdPrefix="sv-pe"
/>
</div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider">Diagnoses (ICD-10)</span>
<DxPicker value={diagnoses} onChange={setDiagnoses} testIdPrefix="sv-dx" />
<label className="block">
<span className="text-[11px] text-muted-foreground">Additional free-text diagnosis / note (optional)</span>
<input
type="text"
value={dxFreetext}
onChange={(e) => setDxFreetext(e.target.value)}
className={input + ' text-sm'}
placeholder="e.g. Follow up in 48 hours if not improving"
/>
</label>
</div>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !chiefComplaint.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Note'}
</button>
</form>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="sickvisit"
title="Sick Visit Note"
exportLabel="sick-visit-note"
exportType="sick-visit"
sourceContext={displayedTranscript}
/>
)}
</div>
);
}

165
client/src/pages/Soap.tsx Normal file
View file

@ -0,0 +1,165 @@
// ============================================================
// SOAP — transcript → SOAP note via /api/generate-soap
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { SoapOk } from '@/shared/types';
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
type SoapType = 'full' | 'subjective';
const TYPE = 'soap' as const;
export default function Soap() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [type, setType] = useState<SoapType>('full');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<SoapOk, Error, SoapRequest>({
mutationFn: (body) => api.post<SoapOk>('/api/generate-soap', body),
onSuccess: (data) => setResult(data.soap),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: SoapRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, type, additionalInstructions };
const parsed = SoapRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
return;
}
setResult(null);
generate.mutate(parsed.data);
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">SOAP Note</h1>
<p className="text-sm text-muted-foreground">
Encounter transcript full SOAP or subjective-only narrative.
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, type, additionalInstructions }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.type) setType(pd.type);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
setPatientAge(''); setPatientGender(''); setType('full'); setAdditionalInstructions('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} placeholder="e.g. 3 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Output type</span>
<select className={input} value={type} onChange={(e) => setType(e.target.value as SoapType)}>
<option value="full">Full SOAP</option>
<option value="subjective">Subjective only</option>
</select>
</label>
</div>
<Recorder
module="soap"
onTranscript={(text, meta) => {
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript</span>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Click Start recording, or type / paste the encounter transcript."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Additional instructions <span className="text-muted-foreground normal-case font-normal">(optional)</span>
</span>
<textarea
className={input + ' min-h-[60px] text-sm'}
placeholder="e.g., 'Include return precautions', 'Add differential for otitis media'"
value={additionalInstructions}
onChange={(e) => setAdditionalInstructions(e.target.value)}
/>
</label>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || !displayedTranscript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate SOAP'}
</button>
</form>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="soap"
title="Generated SOAP"
exportLabel="soap-note"
exportType="soap"
sourceContext={displayedTranscript}
/>
)}
</div>
);
}

View file

@ -0,0 +1,91 @@
// ============================================================
// VACCINE SCHEDULE — full AAP/ACIP table, sourced live from
// GET /api/schedule-data.
// ============================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface VisitAge { id: string; label: string; era: string }
interface VaccineDose { vaccine: string; dose?: number | string; notes?: string }
interface ScheduleData {
visitAges: VisitAge[];
periodicity: Record<string, { vaccines?: VaccineDose[] }>;
vaccineFullNames: Record<string, string>;
}
export default function VaxSchedule() {
const { data, isLoading, error } = useQuery<ScheduleData>({
queryKey: ['schedule-data'],
queryFn: () => api.get<ScheduleData>('/api/schedule-data'),
});
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Loading schedule</div>;
if (error) return <div className="p-6 text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
const visitsWithVax = data.visitAges.filter((v) => data.periodicity[v.id]?.vaccines?.length);
const vaxKeys: string[] = [];
const seen = new Set<string>();
visitsWithVax.forEach((v) => {
data.periodicity[v.id].vaccines!.forEach((dose) => {
if (!seen.has(dose.vaccine)) { seen.add(dose.vaccine); vaxKeys.push(dose.vaccine); }
});
});
return (
<div className="max-w-full mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Vaccine Schedule</h1>
<p className="text-sm text-muted-foreground">
AAP/ACIP 2025 complete immunization schedule (018 years).
</p>
</header>
<div className="rounded-lg border border-border overflow-auto bg-card">
<table className="text-xs">
<thead className="sticky top-0 bg-muted">
<tr>
<th className="text-left font-semibold px-3 py-2 border-b border-border min-w-[180px] sticky left-0 bg-muted">
Vaccine
</th>
{visitsWithVax.map((v) => (
<th key={v.id} className="px-2 py-2 border-b border-border text-center whitespace-nowrap">
{v.label}
</th>
))}
</tr>
</thead>
<tbody>
{vaxKeys.map((key) => (
<tr key={key} className="even:bg-muted/20">
<td className="px-3 py-2 border-b border-border font-medium sticky left-0 bg-card">
{data.vaccineFullNames[key] || key}
</td>
{visitsWithVax.map((v) => {
const vaxList = data.periodicity[v.id].vaccines || [];
const match = vaxList.find((d) => d.vaccine === key);
if (!match) return <td key={v.id} className="border-b border-border" />;
const label = typeof match.dose === 'number' ? '#' + match.dose : (match.dose || '•');
return (
<td
key={v.id}
className="border-b border-border text-center bg-primary/10 font-mono text-[11px]"
title={match.notes || `${key} dose ${match.dose}`}
>
{label}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
Hover any filled cell for notes. Sources: AAP/Bright Futures (Feb 2025), CDC Child &amp; Adolescent Immunization Schedule (2025).
</p>
</div>
);
}

View file

@ -0,0 +1,83 @@
// ============================================================
// WELL VISIT — sub-tab shell. Mirrors public/components/wellvisit.html:
// • By Visit Age — AAP Bright Futures recs per visit
// • Milestones — developmental checklist → AI narrative
// • SSHADESS — psychosocial screening (12+ only)
// • Visit Note — final preventive-care note generator
//
// SSHADESS pill is hidden for under-12 visits to match vanilla.
// Each panel lazy-loads so the initial WellVisit bundle stays small.
// ============================================================
import { Suspense, lazy, useState } from 'react';
const ByVisitAge = lazy(() => import('./wellvisit/ByVisitAge'));
const Milestones = lazy(() => import('./wellvisit/Milestones'));
const Shadess = lazy(() => import('./wellvisit/Shadess'));
const VisitNote = lazy(() => import('./wellvisit/VisitNote'));
type SubTab = 'byvisit' | 'milestones' | 'shadess' | 'note';
const TABS: { id: SubTab; icon: string; label: string }[] = [
{ id: 'byvisit', icon: '👶', label: 'By Visit Age' },
{ id: 'milestones', icon: '🍼', label: 'Milestones' },
{ id: 'shadess', icon: '🧠', label: 'SSHADESS (12+)' },
{ id: 'note', icon: '📄', label: 'Visit Note' },
];
const STORAGE_KEY = 'ped_wellvisit_subtab';
function loadSubTab(): SubTab {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === 'byvisit' || v === 'milestones' || v === 'shadess' || v === 'note') return v;
} catch { /* ignore */ }
return 'byvisit';
}
export default function WellVisit() {
const [active, setActive] = useState<SubTab>(loadSubTab);
function pick(tab: SubTab) {
setActive(tab);
try { localStorage.setItem(STORAGE_KEY, tab); } catch { /* ignore */ }
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Well Visit / Preventive Care</h1>
<p className="text-sm text-muted-foreground">
AAP 2025 Bright Futures periodicity vaccines, screenings, billing codes, milestones, SSHADESS, and the encounter note.
</p>
</header>
<div className="flex flex-wrap gap-2" data-testid="wellvisit-subnav">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => pick(t.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
(active === t.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'wellvisit-pill-' + t.id}
>
<span className="mr-1" aria-hidden>{t.icon}</span>
{t.label}
</button>
))}
</div>
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
{active === 'byvisit' && <ByVisitAge />}
{active === 'milestones' && <Milestones />}
{active === 'shadess' && <Shadess />}
{active === 'note' && <VisitNote />}
</Suspense>
</div>
);
}

View file

@ -0,0 +1,332 @@
// ============================================================
// AiGenerator — generate Learning Hub content via AI from
// 1. a topic description
// 2. one or more uploaded files (PDF, DOCX, TXT, …)
// 3. a Nextcloud/WebDAV file the user picks from a browser
//
// Faithful port of public/js/learningHub.js (@be14578) functions
// openAiPanel / updateAiOptions / runAiGenerate / applyAiContent /
// browseWebdav. Posts to the existing /api/admin/learning/
// ai-generate endpoint as multipart/form-data; the response payload
// (title, subject, body, questions) is handed back to the parent
// via `onGenerated` so the Content Editor can apply it.
// ============================================================
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { CmsQuestion, ContentType } from './cms-types';
interface Props {
contentType: ContentType;
onChangeType: (t: ContentType) => void;
onGenerated: (payload: GeneratedPayload, contentType: ContentType) => void;
onCancel: () => void;
}
export interface GeneratedPayload {
title?: string;
subject?: string;
body?: string;
marpMarkdown?: string;
questions?: CmsQuestion[];
}
interface MeOk { success: true; user: { nextcloud_url?: string | null } }
interface WebdavItem { path: string; name: string; isDir: boolean; contentType?: string; size?: number }
interface WebdavOk { success: true; path: string; parentPath: string; items: WebdavItem[] }
const input = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
type Tab = 'topic' | 'upload' | 'webdav';
export default function AiGenerator(props: Props) {
const [tab, setTab] = useState<Tab>('topic');
const [topic, setTopic] = useState('');
const [uploadCtx, setUploadCtx] = useState('');
const [webdavCtx, setWebdavCtx] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [refinement, setRefinement] = useState('');
const [wordCount, setWordCount] = useState('');
const [slideCount, setSlideCount] = useState('');
const [genQuestions, setGenQuestions] = useState(false);
const [qCount, setQCount] = useState(5);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const me = useQuery<MeOk>({
queryKey: ['auth-me'],
queryFn: () => api.get<MeOk>('/api/auth/me'),
});
const webdavConnected = !!me.data?.user?.nextcloud_url;
const showWords = props.contentType !== 'quiz' && props.contentType !== 'presentation';
const showSlides = props.contentType === 'presentation';
const quizAlwaysOn = props.contentType === 'quiz';
const showQCount = quizAlwaysOn || genQuestions;
useEffect(() => {
if (quizAlwaysOn) setGenQuestions(true);
}, [quizAlwaysOn]);
async function run() {
setErr(null);
const fd = new FormData();
fd.append('contentType', props.contentType);
fd.append('questionCount', String(showQCount ? qCount : 0));
if (refinement.trim()) fd.append('refinement', refinement.trim());
if (showWords && wordCount) fd.append('wordCount', wordCount);
if (showSlides && slideCount) fd.append('slideCount', slideCount);
if (tab === 'topic') {
if (!topic.trim()) { setErr('Describe what you want AI to create'); return; }
fd.append('topic', topic.trim());
} else if (tab === 'upload') {
if (files.length === 0) { setErr('Select at least one file'); return; }
for (const f of files) fd.append('files', f);
if (uploadCtx.trim()) fd.append('topic', uploadCtx.trim());
} else {
// webdav
if (!selectedPath) { setErr('Select a file from Nextcloud'); return; }
fd.append('webdavPath', selectedPath);
if (webdavCtx.trim()) fd.append('topic', webdavCtx.trim());
}
setBusy(true);
try {
const r = await fetch('/api/admin/learning/ai-generate', {
method: 'POST',
credentials: 'include',
body: fd,
});
const data = await r.json();
if (!data.success) throw new Error(data.error || 'Generation failed');
// Presentation returns marpMarkdown + optional questions directly;
// articles/pearls/quizzes return everything nested under `content`.
const payload: GeneratedPayload = data.contentType === 'presentation'
? { marpMarkdown: data.marpMarkdown, questions: data.questions }
: (data.content || {});
props.onGenerated(payload, props.contentType);
} catch (e) {
setErr((e as ApiError).message || 'Generation failed');
} finally {
setBusy(false);
}
}
// WebDAV browser state (only used in the webdav tab)
const [currentPath, setCurrentPath] = useState('/');
const [webdavData, setWebdavData] = useState<WebdavOk | null>(null);
const [webdavErr, setWebdavErr] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState('');
const [selectedName, setSelectedName] = useState('');
useEffect(() => {
if (tab !== 'webdav' || !webdavConnected) return;
setWebdavErr(null);
api.get<WebdavOk>('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(currentPath))
.then(setWebdavData)
.catch((e) => setWebdavErr((e as ApiError).message || 'WebDAV browse failed'));
}, [tab, currentPath, webdavConnected]);
return (
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 space-y-3" data-testid="cms-ai-panel">
<div className="flex items-center gap-2">
<strong className="text-sm"> Generate with AI</strong>
<button type="button" onClick={props.onCancel} className={btn + ' ml-auto'}> Close</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Type</span>
<select
className={input + ' w-full'}
value={props.contentType}
onChange={(e) => props.onChangeType(e.target.value as ContentType)}
data-testid="cms-ai-ctype"
>
<option value="article">Article</option>
<option value="pearl">Pearl</option>
<option value="quiz">Quiz</option>
<option value="presentation">Presentation</option>
</select>
</label>
{showWords && (
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Target word count (optional)</span>
<input
type="number"
min={100}
value={wordCount}
onChange={(e) => setWordCount(e.target.value)}
className={input + ' w-full'}
placeholder="e.g. 800"
/>
</label>
)}
{showSlides && (
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Slide count</span>
<input
type="number"
min={3}
max={40}
value={slideCount}
onChange={(e) => setSlideCount(e.target.value)}
className={input + ' w-full'}
placeholder="e.g. 10"
/>
</label>
)}
</div>
{!quizAlwaysOn && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={genQuestions}
onChange={(e) => setGenQuestions(e.target.checked)}
/>
Also generate quiz questions
</label>
)}
{showQCount && (
<label className="flex items-center gap-2 text-sm">
<span>Question count:</span>
<input
type="number"
min={1}
max={20}
value={qCount}
onChange={(e) => setQCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
className={input + ' w-20'}
/>
</label>
)}
<div className="flex gap-1 border-b border-border">
<button type="button" onClick={() => setTab('topic')}
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'topic' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
Topic
</button>
<button type="button" onClick={() => setTab('upload')}
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'upload' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
Upload files
</button>
{webdavConnected && (
<button type="button" onClick={() => setTab('webdav')}
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'webdav' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
Nextcloud
</button>
)}
</div>
{tab === 'topic' && (
<textarea
className={input + ' w-full min-h-[100px] font-mono text-sm'}
placeholder="Describe what you want AI to create (e.g. 'Bronchiolitis management for attending teaching rounds, high school education level')"
value={topic}
onChange={(e) => setTopic(e.target.value)}
data-testid="cms-ai-topic"
/>
)}
{tab === 'upload' && (
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.odt,.rtf,.txt,.md,.html,.htm,.pptx,.epub,image/*"
onChange={(e) => setFiles(Array.from(e.target.files || []))}
className="text-xs"
data-testid="cms-ai-files"
/>
{files.length > 0 && (
<ul className="text-xs text-muted-foreground">
{files.map((f, i) => <li key={i}>📄 {f.name} <span className="opacity-60">({Math.round(f.size / 1024)} KB)</span></li>)}
</ul>
)}
<textarea
className={input + ' w-full min-h-[60px] text-sm'}
placeholder="Optional: extra context / framing for the AI"
value={uploadCtx}
onChange={(e) => setUploadCtx(e.target.value)}
/>
</div>
)}
{tab === 'webdav' && (
<div className="space-y-2">
{webdavErr && <div className="text-sm text-destructive">{webdavErr}</div>}
{webdavData && (
<div className="rounded-md border border-border bg-background">
<div className="px-2 py-1 flex items-center gap-2 text-xs border-b border-border bg-muted/40">
<span className="font-mono text-muted-foreground">{webdavData.path}</span>
{currentPath !== '/' && (
<button type="button" onClick={() => setCurrentPath(webdavData.parentPath)} className={btn + ' ml-auto'}> Up</button>
)}
</div>
<div className="max-h-60 overflow-auto">
{webdavData.items.length === 0 && <div className="px-2 py-3 text-xs text-muted-foreground italic">Empty folder.</div>}
{webdavData.items.map((it) => (
<button
key={it.path}
type="button"
onClick={() => {
if (it.isDir) { setCurrentPath(it.path); setSelectedPath(''); setSelectedName(''); }
else { setSelectedPath(it.path); setSelectedName(it.name); }
}}
className={
'w-full text-left px-2 py-1.5 text-sm flex items-center gap-2 border-b border-border last:border-0 hover:bg-muted ' +
(selectedPath === it.path ? 'bg-primary/10' : '')
}
>
<span>{it.isDir ? '📁' : '📄'}</span>
<span className="truncate">{it.name}</span>
{!it.isDir && typeof it.size === 'number' && (
<span className="ml-auto text-[10px] text-muted-foreground">{Math.round((it.size || 0) / 1024)} KB</span>
)}
</button>
))}
</div>
</div>
)}
{selectedPath && (
<div className="text-xs text-muted-foreground">
Selected: <span className="font-semibold">{selectedName}</span>
</div>
)}
<textarea
className={input + ' w-full min-h-[60px] text-sm'}
placeholder="Optional: extra context / framing for the AI"
value={webdavCtx}
onChange={(e) => setWebdavCtx(e.target.value)}
/>
</div>
)}
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Refinement (optional)</span>
<input
type="text"
value={refinement}
onChange={(e) => setRefinement(e.target.value)}
className={input + ' w-full'}
placeholder="e.g. 'Keep it under 500 words', 'Focus on outpatient management'"
/>
</label>
{err && <div className="text-sm text-destructive">{err}</div>}
<div className="flex gap-2">
<button type="button" onClick={run} disabled={busy} className={btnPrimary} data-testid="cms-ai-run">
{busy ? '⌛ Generating…' : '✨ Generate content'}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,146 @@
// CMS categories sidebar — list, add, delete, plus the status +
// category filters that drive the content list. Mirrors the vanilla
// cms-sidebar / cms-add-cat / cms-filter-status / cms-filter-category
// from public/components/cms.html.
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type { CmsCategory } from './cms-types';
interface CategoriesOk { success: true; categories: CmsCategory[] }
interface Props {
statusFilter: 'all' | 'published' | 'draft';
onStatusFilter: (s: 'all' | 'published' | 'draft') => void;
categoryFilter: number | 'all';
onCategoryFilter: (id: number | 'all') => void;
}
const sm = 'rounded-md border border-input bg-background px-2 py-1 text-xs';
export default function CategoriesPanel(props: Props) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [err, setErr] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<CmsCategory | null>(null);
const { data } = useQuery<CategoriesOk>({
queryKey: ['cms-categories'],
queryFn: () => api.get<CategoriesOk>('/api/learning-admin/categories'),
});
const addCat = useMutation<{ success: true; id: number }, Error, string>({
mutationFn: (n) => api.post('/api/learning-admin/categories', { name: n }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-categories'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
setName('');
},
onError: (e) => setErr((e as ApiError).message || 'Failed'),
});
const delCat = useMutation<{ success: true }, Error, number>({
mutationFn: (id) => api.delete('/api/learning-admin/categories/' + id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-categories'] });
qc.invalidateQueries({ queryKey: ['cms-content'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
if (!name.trim()) return;
addCat.mutate(name.trim());
}
const cats = data?.categories || [];
return (
<aside className="w-64 shrink-0 space-y-3" data-testid="cms-sidebar">
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Categories</div>
<ul className="space-y-1 max-h-64 overflow-auto">
<li>
<button
type="button"
onClick={() => props.onCategoryFilter('all')}
className={'w-full text-left text-sm px-2 py-1 rounded ' +
(props.categoryFilter === 'all' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')
}
>All categories</button>
</li>
{cats.map((c) => (
<li key={c.id} className="flex items-center gap-1">
<button
type="button"
onClick={() => props.onCategoryFilter(c.id)}
className={'flex-1 text-left text-sm px-2 py-1 rounded ' +
(props.categoryFilter === c.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')
}
data-testid={'cms-cat-' + c.id}
>
{c.name}
{typeof c.content_count === 'number' && (
<span className="ml-1 text-[10px] text-muted-foreground">({c.content_count})</span>
)}
</button>
<button
type="button"
onClick={() => setPendingDelete(c)}
className="text-xs text-destructive hover:text-red-700 px-1"
title="Delete category"
>×</button>
</li>
))}
</ul>
<form onSubmit={submit} className="flex gap-1">
<input
type="text"
placeholder="New category…"
value={name}
onChange={(e) => setName(e.target.value)}
className={sm + ' flex-1'}
data-testid="cms-new-cat-name"
/>
<button type="submit" disabled={addCat.isPending} className="rounded-md bg-primary text-primary-foreground px-2 py-1 text-xs">
+
</button>
</form>
{err && <div className="text-xs text-destructive">{err}</div>}
</div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Filter</div>
<select
className={sm + ' w-full'}
value={props.statusFilter}
onChange={(e) => props.onStatusFilter(e.target.value as 'all' | 'published' | 'draft')}
data-testid="cms-filter-status"
>
<option value="all">All status</option>
<option value="published">Published</option>
<option value="draft">Drafts</option>
</select>
</div>
<ConfirmModal
open={!!pendingDelete}
title="Delete category?"
body={pendingDelete ? 'Delete "' + pendingDelete.name + '"? Its content moves to uncategorized.' : ''}
confirmText="Delete"
danger
busy={delCat.isPending}
onCancel={() => setPendingDelete(null)}
onConfirm={() => {
if (pendingDelete) {
delCat.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
}
}}
/>
</aside>
);
}

View file

@ -0,0 +1,248 @@
// CMS content editor — title / category / type / subject / body /
// published toggle. For quizzes, also embeds QuestionsEditor.
//
// New content: passes `null` id and POSTs on save. Existing content:
// passes id and PUTs. Both refresh the content list query.
//
// Body is a plain textarea — no rich editor in this first React port.
// (Vanilla used a Quill-ish toolbar; that lands as a follow-up if
// users actually start using it.)
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import RichTextEditor from '@/components/RichTextEditor';
import type { CmsCategory, CmsContentDetail, CmsQuestion, ContentType } from './cms-types';
import QuestionsEditor from './QuestionsEditor';
import SlideEditor from './SlideEditor';
import AiGenerator, { type GeneratedPayload } from './AiGenerator';
interface CategoriesOk { success: true; categories: CmsCategory[] }
interface ContentDetailOk { success: true; content: CmsContentDetail }
interface Props {
id: number | null; // null = creating new
initialType: ContentType; // for creates — pre-selects the type
onClose: () => void;
}
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
export default function ContentEditor({ id, initialType, onClose }: Props) {
const qc = useQueryClient();
const isNew = id == null;
const [title, setTitle] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>('');
const [contentType, setContentType] = useState<ContentType>(initialType);
const [published, setPublished] = useState(false);
const [questions, setQuestions] = useState<CmsQuestion[]>([]);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [aiOpen, setAiOpen] = useState(false);
function applyAi(payload: GeneratedPayload, ctype: ContentType) {
setContentType(ctype);
// Presentation → body is the Marp markdown (becomes the slide-editor
// source after split by \n---\n).
if (ctype === 'presentation' && payload.marpMarkdown) {
setBody(payload.marpMarkdown);
// Extract title from first # heading so the editor can save immediately.
const titleMatch = payload.marpMarkdown.match(/^#\s+(.+)$/m);
if (titleMatch) setTitle(titleMatch[1]);
} else {
if (payload.title) setTitle(payload.title);
if (payload.subject !== undefined) setSubject(payload.subject);
if (payload.body !== undefined) setBody(payload.body);
}
if (payload.questions && payload.questions.length) {
setQuestions(payload.questions);
}
setAiOpen(false);
setMsg({ kind: 'ok', text: 'Content generated — review, then save.' });
}
const cats = useQuery<CategoriesOk>({
queryKey: ['cms-categories'],
queryFn: () => api.get<CategoriesOk>('/api/learning-admin/categories'),
});
const detail = useQuery<ContentDetailOk>({
queryKey: ['cms-content-detail', id],
queryFn: () => api.get<ContentDetailOk>('/api/learning-admin/content/' + id),
enabled: !isNew,
});
// Hydrate state from server when editing an existing item.
useEffect(() => {
if (!detail.data?.content) return;
const c = detail.data.content;
setTitle(c.title);
setSubject(c.subject || '');
setBody(c.body || '');
setCategoryId(c.category_id ?? '');
setContentType(c.content_type);
setPublished(!!c.published);
setQuestions(c.questions || []);
}, [detail.data]);
const save = useMutation<{ success: true; id: number }, Error, void>({
mutationFn: async () => {
const body_ = {
title, subject, body,
category_id: categoryId === '' ? null : categoryId,
content_type: contentType,
published,
};
if (isNew) {
return api.post<{ success: true; id: number }>('/api/learning-admin/content', body_);
}
await api.put<{ success: true }>('/api/learning-admin/content/' + id, body_);
return { success: true as const, id: id! };
},
onSuccess: (d) => {
qc.invalidateQueries({ queryKey: ['cms-content'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
qc.invalidateQueries({ queryKey: ['cms-content-detail', d.id] });
setMsg({ kind: 'ok', text: isNew ? 'Created — switch to the list to add questions' : 'Saved' });
},
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Save failed' }),
});
return (
<div className="flex-1 space-y-3 min-w-0" data-testid="cms-editor">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
{isNew ? 'New ' + contentType : 'Edit ' + contentType}
</h3>
<div className="flex gap-2">
<button type="button" onClick={() => setAiOpen((v) => !v)} className={btnPrimary} data-testid="cms-open-ai">
{aiOpen ? 'Hide AI' : 'Generate with AI'}
</button>
<button type="button" onClick={onClose} className={btn}> Back to list</button>
</div>
</div>
{aiOpen && (
<AiGenerator
contentType={contentType}
onChangeType={setContentType}
onGenerated={applyAi}
onCancel={() => setAiOpen(false)}
/>
)}
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Title</span>
<input
type="text"
className={input + ' w-full'}
value={title}
onChange={(e) => setTitle(e.target.value)}
data-testid="cms-edit-title"
/>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Type</span>
<select
className={input + ' w-full'}
value={contentType}
onChange={(e) => setContentType(e.target.value as ContentType)}
data-testid="cms-edit-type"
>
<option value="article">Article</option>
<option value="pearl">Pearl</option>
<option value="quiz">Quiz</option>
<option value="presentation">Presentation</option>
</select>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Category</span>
<select
className={input + ' w-full'}
value={categoryId}
onChange={(e) => setCategoryId(e.target.value === '' ? '' : Number(e.target.value))}
data-testid="cms-edit-category"
>
<option value="">Uncategorized</option>
{(cats.data?.categories || []).map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</label>
<label className="flex items-end gap-2">
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
className="h-4 w-4"
data-testid="cms-edit-published"
/>
<span className="text-sm">Published</span>
</label>
</div>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Subject (optional)</span>
<input
type="text"
className={input + ' w-full'}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="e.g. Asthma, Bronchiolitis"
/>
</label>
<div>
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Body
</span>
{contentType === 'presentation' ? (
<SlideEditor value={body} onChange={setBody} />
) : (
<RichTextEditor
value={body}
onChange={setBody}
variant="default"
minHeight="min-h-[320px]"
placeholder="Write the content body…"
testId="cms-edit-body"
/>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => save.mutate()}
disabled={save.isPending || !title.trim()}
className={btnPrimary}
data-testid="cms-save"
>
{save.isPending ? 'Saving…' : (isNew ? 'Create' : 'Save changes')}
</button>
{msg && (
<span className={'text-xs ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</span>
)}
</div>
</div>
{!isNew && contentType === 'quiz' && (
<div className="rounded-lg border border-border bg-card p-4">
<QuestionsEditor
contentId={id!}
questions={questions}
onChange={setQuestions}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,165 @@
// CMS content list — table of articles/pearls/quizzes/presentations
// with toolbar (new article / new quiz / new pearl / new presentation
// + search), per-row publish-toggle / edit / delete. Mirrors the
// vanilla #lh-cms-content-list table.
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type { CmsContentRow, ContentType } from './cms-types';
interface ContentListOk { success: true; content: CmsContentRow[] }
interface Props {
statusFilter: 'all' | 'published' | 'draft';
categoryFilter: number | 'all';
onEdit: (id: number) => void;
onCreate: (type: ContentType) => void;
}
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
const tag = 'text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border';
const TYPE_TAG: Record<ContentType, string> = {
article: 'bg-blue-100 text-blue-700 border-blue-300',
quiz: 'bg-amber-100 text-amber-800 border-amber-300',
pearl: 'bg-purple-100 text-purple-700 border-purple-300',
presentation: 'bg-emerald-100 text-emerald-800 border-emerald-300',
};
export default function ContentList(props: Props) {
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [pendingDelete, setPendingDelete] = useState<CmsContentRow | null>(null);
const { data, isLoading } = useQuery<ContentListOk>({
queryKey: ['cms-content'],
queryFn: () => api.get<ContentListOk>('/api/learning-admin/content'),
});
const togglePublish = useMutation<{ success: true }, Error, { id: number; published: boolean }>({
mutationFn: ({ id, published }) => api.put('/api/learning-admin/content/' + id, { published }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-content'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
},
});
const del = useMutation<{ success: true }, Error, number>({
mutationFn: (id) => api.delete('/api/learning-admin/content/' + id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-content'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
},
});
const filtered = useMemo(() => {
const rows = data?.content || [];
return rows.filter((r) => {
if (props.statusFilter === 'published' && !r.published) return false;
if (props.statusFilter === 'draft' && r.published) return false;
if (props.categoryFilter !== 'all' && r.category_id !== props.categoryFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
const hay = (r.title + ' ' + (r.subject || '') + ' ' + (r.category_name || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}, [data, props.statusFilter, props.categoryFilter, search]);
return (
<div className="flex-1 space-y-3 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => props.onCreate('article')} className={btnPrimary} data-testid="cms-new-article">
+ Article
</button>
<button type="button" onClick={() => props.onCreate('quiz')} className={btn} data-testid="cms-new-quiz">
+ Quiz
</button>
<button type="button" onClick={() => props.onCreate('pearl')} className={btn} data-testid="cms-new-pearl">
+ Pearl
</button>
<button type="button" onClick={() => props.onCreate('presentation')} className={btn} data-testid="cms-new-presentation">
+ Presentation
</button>
<div className="ml-auto">
<input
type="search"
placeholder="Search content…"
className="rounded-md border border-input bg-background px-3 py-1.5 text-xs min-w-[220px]"
value={search}
onChange={(e) => setSearch(e.target.value)}
data-testid="cms-search"
/>
</div>
</div>
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="grid grid-cols-[1fr_120px_90px_90px_120px_120px] gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border">
<span>Title</span>
<span>Category</span>
<span>Type</span>
<span>Status</span>
<span>Updated</span>
<span className="text-right">Actions</span>
</div>
{isLoading && <div className="p-6 text-sm text-muted-foreground text-center">Loading</div>}
{!isLoading && filtered.length === 0 && (
<div className="p-6 text-sm text-muted-foreground italic text-center">No content matches.</div>
)}
{filtered.map((r) => (
<div
key={r.id}
className="grid grid-cols-[1fr_120px_90px_90px_120px_120px] gap-2 px-3 py-2 text-sm items-center border-b border-border last:border-0 hover:bg-muted/30"
data-testid={'cms-row-' + r.id}
>
<button type="button" onClick={() => props.onEdit(r.id)} className="text-left truncate hover:underline">
<strong>{r.title}</strong>
{r.subject && <span className="ml-2 text-xs text-muted-foreground truncate">· {r.subject}</span>}
{r.content_type === 'quiz' && typeof r.question_count === 'number' && (
<span className="ml-2 text-[10px] text-muted-foreground">{r.question_count} Q</span>
)}
</button>
<span className="text-xs text-muted-foreground truncate">{r.category_name || '—'}</span>
<span className={tag + ' ' + TYPE_TAG[r.content_type]}>{r.content_type}</span>
<button
type="button"
onClick={() => togglePublish.mutate({ id: r.id, published: !r.published })}
className={tag + ' ' + (r.published ? 'bg-green-100 text-green-700 border-green-300' : 'bg-muted text-muted-foreground border-border')}
title="Toggle published"
>
{r.published ? 'Published' : 'Draft'}
</button>
<span className="text-xs text-muted-foreground">{new Date(r.updated_at).toLocaleDateString()}</span>
<div className="flex justify-end gap-1">
<button type="button" onClick={() => props.onEdit(r.id)} className={btn}>Edit</button>
<button
type="button"
onClick={() => setPendingDelete(r)}
className={btn + ' text-destructive'}
>Del</button>
</div>
</div>
))}
</div>
<ConfirmModal
open={!!pendingDelete}
title="Delete content?"
body={pendingDelete ? 'Delete "' + pendingDelete.title + '"? This cannot be undone.' : ''}
confirmText="Delete"
danger
busy={del.isPending}
onCancel={() => setPendingDelete(null)}
onConfirm={() => {
if (pendingDelete) {
del.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
}
}}
/>
</div>
);
}

View file

@ -0,0 +1,185 @@
// Questions editor — used inside ContentEditor when content_type === 'quiz'.
// Manages the local list of questions + options for a content item; on save
// the parent diffs against server state via add/update/delete endpoints.
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import RichTextEditor from '@/components/RichTextEditor';
import type { CmsQuestion } from './cms-types';
interface Props {
contentId: number;
questions: CmsQuestion[];
onChange: (next: CmsQuestion[]) => void;
}
const input = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-2 py-1 text-xs font-medium disabled:opacity-50';
function emptyQ(): CmsQuestion {
return { question_text: '', question_type: 'mcq', explanation: '', options: [
{ option_text: '', is_correct: true },
{ option_text: '', is_correct: false },
] };
}
export default function QuestionsEditor(props: Props) {
const qc = useQueryClient();
const [pendingDeleteIdx, setPendingDeleteIdx] = useState<number | null>(null);
const createQ = useMutation<{ success: true; id: number }, Error, CmsQuestion>({
mutationFn: (q) => api.post('/api/learning-admin/content/' + props.contentId + '/questions', q),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cms-content-detail', props.contentId] }),
});
const updateQ = useMutation<{ success: true }, Error, CmsQuestion>({
mutationFn: (q) => api.put('/api/learning-admin/questions/' + q.id, q),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cms-content-detail', props.contentId] }),
});
const deleteQ = useMutation<{ success: true }, Error, number>({
mutationFn: (id) => api.delete('/api/learning-admin/questions/' + id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cms-content-detail', props.contentId] }),
});
function patchQ(idx: number, patch: Partial<CmsQuestion>) {
props.onChange(props.questions.map((q, i) => (i === idx ? { ...q, ...patch } : q)));
}
function patchOpt(qIdx: number, optIdx: number, patch: Partial<CmsQuestion['options'] extends (infer T)[] | undefined ? T : never>) {
props.onChange(props.questions.map((q, i) => {
if (i !== qIdx) return q;
const opts = (q.options || []).map((o, j) => (j === optIdx ? { ...o, ...patch } : o));
return { ...q, options: opts };
}));
}
function addQ() { props.onChange([...props.questions, emptyQ()]); }
function addOpt(qIdx: number) {
props.onChange(props.questions.map((q, i) => {
if (i !== qIdx) return q;
return { ...q, options: [...(q.options || []), { option_text: '', is_correct: false }] };
}));
}
function removeOpt(qIdx: number, optIdx: number) {
props.onChange(props.questions.map((q, i) => {
if (i !== qIdx) return q;
return { ...q, options: (q.options || []).filter((_, j) => j !== optIdx) };
}));
}
function saveQ(idx: number) {
const q = props.questions[idx];
if (q.id) updateQ.mutate(q);
else createQ.mutate(q);
}
function handleDelete(idx: number) {
const q = props.questions[idx];
if (q.id) deleteQ.mutate(q.id);
props.onChange(props.questions.filter((_, i) => i !== idx));
setPendingDeleteIdx(null);
}
return (
<div className="space-y-3" data-testid="cms-questions-editor">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Questions ({props.questions.length})</h4>
<button type="button" onClick={addQ} className={btnPrimary}>+ Add question</button>
</div>
{props.questions.length === 0 && (
<div className="text-sm text-muted-foreground italic">No questions yet.</div>
)}
{props.questions.map((q, qIdx) => (
<div key={qIdx} className="rounded-lg border border-border bg-muted/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">Q{qIdx + 1}</span>
<select
className={input + ' text-xs'}
value={q.question_type}
onChange={(e) => patchQ(qIdx, { question_type: e.target.value as CmsQuestion['question_type'] })}
>
<option value="mcq">MCQ (single answer)</option>
<option value="multi">Multi-select</option>
<option value="true_false">True/False</option>
</select>
<div className="ml-auto flex gap-1">
<button type="button" onClick={() => saveQ(qIdx)} className={btnPrimary} disabled={createQ.isPending || updateQ.isPending}>
Save Q
</button>
<button type="button" onClick={() => setPendingDeleteIdx(qIdx)} className={btn + ' text-destructive'}>
Del
</button>
</div>
</div>
<RichTextEditor
value={q.question_text}
onChange={(html) => patchQ(qIdx, { question_text: html })}
variant="mini"
minHeight="min-h-[60px]"
placeholder="Question text"
/>
<div className="space-y-1">
{(q.options || []).map((o, optIdx) => (
<div key={optIdx} className="flex items-start gap-2">
<input
type={q.question_type === 'multi' ? 'checkbox' : 'radio'}
name={'q-' + qIdx + '-correct'}
checked={o.is_correct}
onChange={(e) => {
if (q.question_type === 'multi') {
patchOpt(qIdx, optIdx, { is_correct: e.target.checked });
} else {
props.onChange(props.questions.map((qq, i) => {
if (i !== qIdx) return qq;
const opts = (qq.options || []).map((oo, j) => ({ ...oo, is_correct: j === optIdx }));
return { ...qq, options: opts };
}));
}
}}
className="mt-2"
/>
<div className="flex-1 space-y-1">
<RichTextEditor
value={o.option_text}
onChange={(html) => patchOpt(qIdx, optIdx, { option_text: html })}
variant="option"
minHeight="min-h-[40px]"
placeholder={'Option ' + (optIdx + 1)}
/>
<RichTextEditor
value={o.explanation || ''}
onChange={(html) => patchOpt(qIdx, optIdx, { explanation: html })}
variant="option"
minHeight="min-h-[32px]"
placeholder="Per-option explanation (optional)"
/>
</div>
<button type="button" onClick={() => removeOpt(qIdx, optIdx)} className="text-xs text-destructive mt-2">×</button>
</div>
))}
<button type="button" onClick={() => addOpt(qIdx)} className={btn}>+ Option</button>
</div>
<RichTextEditor
value={q.explanation || ''}
onChange={(html) => patchQ(qIdx, { explanation: html })}
variant="mini"
minHeight="min-h-[40px]"
placeholder="Question explanation (shown after answering)"
/>
</div>
))}
<ConfirmModal
open={pendingDeleteIdx !== null}
title="Delete question?"
body="The question and its options will be removed."
confirmText="Delete"
danger
busy={deleteQ.isPending}
onCancel={() => setPendingDeleteIdx(null)}
onConfirm={() => { if (pendingDeleteIdx !== null) handleDelete(pendingDeleteIdx); }}
/>
</div>
);
}

View file

@ -0,0 +1,107 @@
// ============================================================
// SlideEditor — presentation body editor. Vanilla stores slide
// decks in the `body` column as a single string with slides
// separated by `\n---\n` (see public/js/learningHub.js around
// line 338 where it does body.split(/\n---\n/).length).
//
// Each slide is a rich-text block, and the editor joins them
// back together with the `---` separator before handing the
// string up to ContentEditor.
// ============================================================
import { useMemo, useState } from 'react';
import RichTextEditor from '@/components/RichTextEditor';
interface Props {
value: string;
onChange: (next: string) => void;
}
const SEP = '\n---\n';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
function splitSlides(s: string): string[] {
if (!s) return [''];
return s.split(/\n-{3,}\n/);
}
export default function SlideEditor({ value, onChange }: Props) {
const slides = useMemo(() => splitSlides(value), [value]);
const [active, setActive] = useState(0);
const safeActive = Math.min(active, slides.length - 1);
function updateSlides(next: string[]) {
onChange(next.join(SEP));
}
function updateSlide(i: number, next: string) {
const copy = slides.slice();
copy[i] = next;
updateSlides(copy);
}
function addSlide(after: number) {
const copy = slides.slice();
copy.splice(after + 1, 0, '');
updateSlides(copy);
setActive(after + 1);
}
function removeSlide(i: number) {
if (slides.length <= 1) { updateSlides(['']); setActive(0); return; }
const copy = slides.slice();
copy.splice(i, 1);
updateSlides(copy);
setActive(Math.max(0, Math.min(active, copy.length - 1)));
}
function move(i: number, dir: -1 | 1) {
const j = i + dir;
if (j < 0 || j >= slides.length) return;
const copy = slides.slice();
[copy[i], copy[j]] = [copy[j], copy[i]];
updateSlides(copy);
setActive(j);
}
return (
<div className="rounded-md border border-input bg-background" data-testid="cms-slide-editor">
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-border bg-muted/40">
<span className="text-xs font-semibold text-muted-foreground">
Slide {safeActive + 1} of {slides.length}
</span>
<div className="flex flex-wrap items-center gap-1 ml-2">
{slides.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setActive(i)}
className={
'w-7 h-7 rounded text-xs border ' +
(i === safeActive
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-muted')
}
aria-label={'Slide ' + (i + 1)}
>{i + 1}</button>
))}
</div>
<div className="ml-auto flex gap-1">
<button type="button" onClick={() => move(safeActive, -1)} disabled={safeActive === 0} className={btn}> Move</button>
<button type="button" onClick={() => move(safeActive, 1)} disabled={safeActive === slides.length - 1} className={btn}>Move </button>
<button type="button" onClick={() => addSlide(safeActive)} className={btnPrimary}>+ Slide</button>
<button type="button" onClick={() => removeSlide(safeActive)} className={btn + ' text-destructive'}>Remove</button>
</div>
</div>
<RichTextEditor
key={safeActive}
value={slides[safeActive] || ''}
onChange={(html) => updateSlide(safeActive, html)}
variant="default"
minHeight="min-h-[260px]"
placeholder="Slide content…"
/>
</div>
);
}

View file

@ -0,0 +1,34 @@
// CMS stats bar — read-only summary across the top of the Cms page.
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { CmsStats } from './cms-types';
interface StatsOk { success: true; stats: CmsStats }
export default function StatsBar() {
const { data } = useQuery<StatsOk>({
queryKey: ['cms-stats'],
queryFn: () => api.get<StatsOk>('/api/learning-admin/stats'),
refetchInterval: 30_000,
});
const s = data?.stats;
const cells = [
{ k: 'Published', v: s?.publishedContent ?? '' },
{ k: 'All content', v: s?.totalContent ?? '' },
{ k: 'Categories', v: s?.totalCategories ?? '' },
{ k: 'Quizzes', v: s?.totalQuizzes ?? '' },
{ k: 'Attempts', v: s?.totalAttempts ?? '' },
{ k: 'Embeddings', v: s?.embeddingsEnabled ? (s?.withEmbeddings ?? '') : 'off' },
];
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2" data-testid="cms-stats-bar">
{cells.map((c) => (
<div key={c.k} className="rounded-md border border-border bg-card px-3 py-2">
<div className="text-lg font-semibold">{c.v}</div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">{c.k}</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,61 @@
// Shared types for the Learning Hub CMS — match the server response
// shapes from src/routes/learningAdmin.ts. Kept narrow (only the fields
// the CMS reads/writes) so a column rename on the server breaks the
// client at compile time.
export interface CmsCategory {
id: number;
name: string;
slug: string;
description?: string | null;
sort_order?: number;
content_count?: number;
}
export type ContentType = 'article' | 'quiz' | 'pearl' | 'presentation';
export interface CmsContentRow {
id: number;
title: string;
slug?: string;
subject?: string | null;
content_type: ContentType;
published: boolean;
category_id?: number | null;
category_name?: string | null;
author_name?: string | null;
question_count?: number;
created_at: string;
updated_at: string;
}
export interface CmsOption {
id?: number;
option_text: string;
is_correct: boolean;
explanation?: string;
}
export interface CmsQuestion {
id?: number;
question_text: string;
question_type: 'mcq' | 'true_false' | 'multi';
explanation?: string;
sort_order?: number;
options?: CmsOption[];
}
export interface CmsContentDetail extends CmsContentRow {
body?: string;
questions?: CmsQuestion[];
}
export interface CmsStats {
totalContent: number;
publishedContent: number;
totalCategories: number;
totalQuizzes: number;
totalAttempts: number;
withEmbeddings: number;
embeddingsEnabled: boolean;
}

View file

@ -0,0 +1,462 @@
// ============================================================
// BY VISIT AGE — AAP Bright Futures recommendations per visit.
// Faithful port of public/js/wellVisit.js (@be14578) renderVisitPanel.
//
// Reads /api/schedule-data and renders, per visit:
// • Billing codes (ICD-10 + CPT)
// • Measurements (height/weight/HC/BMI/BP)
// • Vaccines due (with Given/Refused/Deferred/Already Done buttons)
// • Screenings (sensory, developmental, procedures, oral)
// • Expected growth + feeding guidance
// • Expected reflexes
// • BMI classification table (AAP 2023, ages 2+)
// • Notes
//
// Visit statuses persist to localStorage under ped_visit_statuses
// (same key as vanilla so cross-app continuity is preserved).
// "Copy to Visit Note" writes a summary to sessionStorage so the
// Visit Note tab can carry it into the encounter note.
// ============================================================
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import {
mapToGrowthKey,
mapToReflexKey,
reflexStatusColor,
getStatusValue,
type VisitStatusVal,
} from '@shared/clinical/visit-status';
interface VisitAge { id: string; label: string; era: string }
interface VaccineDose { vaccine: string; dose?: number | string; notes?: string }
type ItemStatus = 'dot' | 'range' | 'arrow' | string;
interface VisitData {
measurements?: Record<string, ItemStatus>;
vaccines?: VaccineDose[];
sensory?: Record<string, ItemStatus>;
developmental?: Record<string, ItemStatus>;
procedures?: Record<string, ItemStatus>;
oralHealth?: Record<string, ItemStatus>;
notes?: string;
}
interface BillingCodes { icd10: string; cpt: string; description: string }
interface GrowthRef {
weight?: string; length?: string; headCirc?: string;
feeding?: string[]; bmiClassification?: boolean;
}
interface ReflexEntry { name: string; status: string; note: string }
interface ReflexRef { intro?: string; reflexes: ReflexEntry[] }
interface BmiCategory { label: string; range: string; action: string; color: string }
interface BmiClassification { categories: BmiCategory[]; notes?: string }
interface ScheduleDataOk {
visitAges: VisitAge[];
periodicity: Record<string, VisitData>;
wellVisitCodes: Record<string, BillingCodes>;
growthReference: Record<string, GrowthRef>;
reflexesReference: Record<string, ReflexRef>;
bmiClassification: BmiClassification;
vaccineFullNames: Record<string, string>;
}
// Status persistence shape — keyed `<visitId>.<itemKey>`. Mirrors the
// vanilla _visitStatuses state stored under localStorage["ped_visit_statuses"].
type VisitStatuses = Record<string, VisitStatusVal>;
const SCREEN_LABELS: Record<string, string> = {
maternalDepression: 'Maternal/Caregiver Depression Screen (Edinburgh/PHQ)',
developmentalScreening: 'Developmental Screening (ASQ / PEDS)',
autismScreening: 'Autism Screening (M-CHAT-R)',
developmentalSurveillance: 'Developmental Surveillance',
behavioralScreening: 'Social-Emotional/Behavioral Screening (ASQ:SE)',
tobaccoAlcoholDrugs: 'Tobacco / Alcohol / Drug Use Screening (CRAFFT/AUDIT)',
depressionSuicideRisk: 'Depression & Suicide Risk Screening (PHQ-A)',
};
const PROC_LABELS: Record<string, string> = {
newbornBlood: 'Newborn Blood Spot Screening (NBS)',
newbornBilirubin: 'Newborn Bilirubin (TcB or TSB)',
criticalCHD: 'Critical CHD Screening (Pulse Ox)',
immunization: 'Immunizations Review & Update',
anemia: 'Anemia Screening (Hgb/Hct)',
lead: 'Lead Exposure Risk / Blood Lead Level',
tuberculosis: 'Tuberculosis / Latent TB Risk Assessment',
dyslipidemia: 'Dyslipidemia Screening (lipid panel)',
sti: 'STI Screening (gonorrhea / chlamydia / syphilis)',
hiv: 'HIV Screening',
hepB: 'Hepatitis B Screening (HBsAg)',
hepC: 'Hepatitis C Screening (anti-HCV)',
suddenCardiacArrest: 'Sudden Cardiac Arrest Risk Assessment',
cervicalDysplasia: 'Cervical Dysplasia Screening (Pap smear)',
};
const MEASURE_LABELS: Record<string, string> = {
lengthHeight: 'Length / Height', weight: 'Weight',
headCircumference: 'Head Circumference', weightForLength: 'Weight-for-Length',
bmi: 'BMI', bloodPressure: 'Blood Pressure',
};
const ORAL_LABELS: Record<string, string> = {
assessment: 'Oral Health Risk Assessment',
fluorideVarnish: 'Fluoride Varnish Application',
fluorideSupplementation: 'Fluoride Supplementation (if water <0.6 ppm)',
};
const SENSORY_LABELS: Record<string, string> = {
vision: 'Vision Screening', hearing: 'Hearing Screening',
};
const ERA_NAMES: Record<string, string> = {
prenatal: 'Prenatal',
infancy: 'Infancy (012 mo)',
earlyChildhood: 'Early Childhood (15 y)',
middleChildhood: 'Middle Childhood (611 y)',
adolescence: 'Adolescence (1121 y)',
};
const STORAGE_KEY = 'ped_visit_statuses';
const VAX_STATUSES = ['Given', 'Refused', 'Deferred', 'Already Done'] as const;
const SCREEN_STATUSES = ['Done', 'Refused', 'Not Due / N/A'] as const;
// localStorage helpers — defensive against quota / blocked storage.
function loadStatuses(): VisitStatuses {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as VisitStatuses) : {};
} catch { return {}; }
}
function saveStatuses(s: VisitStatuses) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch { /* ignore */ }
}
// ── Sub-components ─────────────────────────────────────────────────
function StatusBar(props: {
statuses: readonly string[];
current: string;
onPick: (next: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1">
{props.statuses.map((s) => (
<button
key={s}
type="button"
onClick={() => props.onPick(props.current === s ? '' : s)}
className={
'text-[10px] uppercase tracking-wider px-2 py-0.5 rounded border ' +
(props.current === s
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-muted')
}
>
{s}
</button>
))}
</div>
);
}
function Section(props: { icon: string; title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="px-4 py-2 bg-muted/40 flex items-center gap-2 text-sm font-semibold">
<span>{props.icon}</span><span>{props.title}</span>
</div>
<div className="p-3 space-y-2">{props.children}</div>
</div>
);
}
// ── Main component ─────────────────────────────────────────────────
export default function ByVisitAge() {
const { data, isLoading, error } = useQuery<ScheduleDataOk>({
queryKey: ['schedule-data'],
queryFn: () => api.get<ScheduleDataOk>('/api/schedule-data'),
});
const [visitId, setVisitId] = useState<string>('newborn');
const [statuses, setStatuses] = useState<VisitStatuses>(() => loadStatuses());
const [msg, setMsg] = useState<string | null>(null);
// Persist on every change.
useEffect(() => { saveStatuses(statuses); }, [statuses]);
// Persist selected visit age to sessionStorage so the Visit Note tab can
// pick it up (matches vanilla `ped_visit_age` key).
useEffect(() => {
if (!data || !visitId) return;
const label = data.visitAges.find((v) => v.id === visitId)?.label || visitId;
try { sessionStorage.setItem('ped_visit_age', label); } catch { /* ignore */ }
}, [data, visitId]);
const groupedAges = useMemo(() => {
if (!data) return {} as Record<string, VisitAge[]>;
const out: Record<string, VisitAge[]> = {};
data.visitAges.forEach((v) => {
if (!out[v.era]) out[v.era] = [];
out[v.era].push(v);
});
return out;
}, [data]);
if (isLoading) return <div className="text-sm text-muted-foreground">Loading schedule</div>;
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
const periodicity = data.periodicity[visitId];
const codes = data.wellVisitCodes[visitId];
const growthKey = mapToGrowthKey(visitId, data.growthReference);
const growth = growthKey ? data.growthReference[growthKey] : null;
const reflexKey = mapToReflexKey(visitId, data.reflexesReference);
const reflex = reflexKey ? data.reflexesReference[reflexKey] : null;
function setStatus(key: string, status: string) {
setStatuses((s) => {
const cur = s[key];
const note = typeof cur === 'object' && cur ? cur.note : '';
return { ...s, [key]: { status, note } };
});
}
function setNote(key: string, note: string) {
setStatuses((s) => {
const cur = s[key];
const status = typeof cur === 'object' && cur ? cur.status : (cur || '');
return { ...s, [key]: { status, note } };
});
}
function clearVisit() {
setStatuses((s) => {
const out = { ...s };
Object.keys(out).forEach((k) => { if (k.indexOf(visitId + '.') === 0) delete out[k]; });
return out;
});
setMsg('Visit statuses cleared');
}
function copyToNote() {
if (!data) return;
const lines: string[] = [];
const visitLabel = data.visitAges.find((v) => v.id === visitId)?.label || visitId;
lines.push('Visit: ' + visitLabel);
Object.keys(statuses).forEach((k) => {
if (k.indexOf(visitId + '.') !== 0) return;
const v = statuses[k];
const status = typeof v === 'object' ? v.status : v;
const note = typeof v === 'object' ? v.note : '';
if (!status) return;
const itemKey = k.substring(visitId.length + 1);
lines.push(' - ' + itemKey + ': ' + status + (note ? ' (' + note + ')' : ''));
});
try {
sessionStorage.setItem('wv-byvisit-statuses', lines.join('\n'));
setMsg('Copied to Visit Note — switch tabs to use it');
} catch { setMsg('Session storage unavailable'); }
}
// Filter helpers for the screening sections.
function buildItems(map: Record<string, ItemStatus> | undefined, labels: Record<string, string>, exclude: string[] = []) {
if (!map) return [] as { key: string; label: string; status: ItemStatus }[];
return Object.keys(map)
.filter((k) => !exclude.includes(k))
.filter((k) => map[k] === 'dot' || map[k] === 'range' || map[k] === 'arrow')
.map((k) => ({ key: k, label: labels[k] || k, status: map[k] }));
}
const measDue = periodicity?.measurements
? Object.keys(periodicity.measurements).filter((k) => periodicity.measurements![k] === 'dot' || periodicity.measurements![k] === 'range')
: [];
const sensoryItems = buildItems(periodicity?.sensory, SENSORY_LABELS);
const devItems = buildItems(periodicity?.developmental, SCREEN_LABELS);
const procItems = buildItems(periodicity?.procedures, PROC_LABELS, ['immunization']);
const oralItems = buildItems(periodicity?.oralHealth, ORAL_LABELS);
return (
<div className="space-y-4">
<div className="flex items-end gap-3 flex-wrap">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Select visit age</span>
<select
className="rounded-md border border-input bg-background px-3 py-2 text-sm min-w-[260px]"
value={visitId}
onChange={(e) => setVisitId(e.target.value)}
data-testid="byvisit-select"
>
{Object.keys(groupedAges).map((era) => (
<optgroup key={era} label={ERA_NAMES[era] || era}>
{groupedAges[era].map((v) => (
<option key={v.id} value={v.id}>{v.label}</option>
))}
</optgroup>
))}
</select>
</label>
<button type="button" onClick={copyToNote} className="rounded-md bg-primary text-primary-foreground px-3 py-2 text-xs font-semibold">
📋 Copy to Visit Note
</button>
<button type="button" onClick={clearVisit} className="rounded-md border border-border bg-background px-3 py-2 text-xs text-destructive">
Clear this visit
</button>
{msg && <span className="text-xs text-muted-foreground">{msg}</span>}
</div>
{!periodicity && <p className="text-sm text-muted-foreground">No data for this visit.</p>}
{periodicity && codes && (
<Section icon="🧾" title="Billing codes">
<div className="flex flex-wrap gap-3 text-sm">
<span><span className="text-xs text-muted-foreground mr-1">ICD-10:</span><code className="bg-muted px-1.5 py-0.5 rounded">{codes.icd10}</code></span>
<span><span className="text-xs text-muted-foreground mr-1">CPT:</span><code className="bg-muted px-1.5 py-0.5 rounded">{codes.cpt}</code></span>
</div>
<div className="text-xs text-muted-foreground">{codes.description}</div>
</Section>
)}
{measDue.length > 0 && (
<Section icon="📏" title="Measurements">
<div className="flex flex-wrap gap-2">
{measDue.map((k) => (
<span key={k} className="text-xs px-2 py-1 rounded bg-muted">
{MEASURE_LABELS[k] || k}{periodicity!.measurements![k] === 'range' ? ' (range)' : ''}
</span>
))}
</div>
</Section>
)}
{periodicity?.vaccines && periodicity.vaccines.length > 0 && (
<Section icon="💉" title="Vaccines due">
<div className="space-y-2">
{periodicity.vaccines.map((v) => {
const itemKey = 'vax_' + v.vaccine + '_d' + (v.dose || '');
const k = visitId + '.' + itemKey;
const cur = getStatusValue(statuses[k]);
const fullName = data.vaccineFullNames[v.vaccine] || v.vaccine;
return (
<div key={itemKey} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1 min-w-[200px]">
<strong>{fullName}</strong>
{v.dose && <span className="ml-2 text-xs text-muted-foreground">Dose {v.dose}</span>}
{v.notes && <div className="text-xs text-muted-foreground">{v.notes}</div>}
</span>
<StatusBar statuses={VAX_STATUSES} current={cur} onPick={(s) => setStatus(k, s)} />
</div>
);
})}
</div>
</Section>
)}
{sensoryItems.length > 0 && (
<ScreenList title="Sensory screens" icon="👁" items={sensoryItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{devItems.length > 0 && (
<ScreenList title="Developmental / behavioral screens" icon="🧠" items={devItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{procItems.length > 0 && (
<ScreenList title="Labs & procedures" icon="🧪" items={procItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{oralItems.length > 0 && (
<ScreenList title="Oral health" icon="🦷" items={oralItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{growth && (
<Section icon="📈" title="Expected growth">
<div className="space-y-1 text-sm">
{growth.weight && <div><span className="text-muted-foreground"> Weight: </span>{growth.weight}</div>}
{growth.length && <div><span className="text-muted-foreground">📏 Length/Height: </span>{growth.length}</div>}
{growth.headCirc && <div><span className="text-muted-foreground">🧠 Head circumference: </span>{growth.headCirc}</div>}
</div>
{growth.feeding && growth.feeding.length > 0 && (
<div className="pt-2 border-t border-border mt-2">
<div className="text-xs font-semibold text-muted-foreground mb-1">🍽 Feeding & nutrition</div>
<ul className="list-disc pl-5 text-sm space-y-0.5">
{growth.feeding.map((f, i) => <li key={i}>{f}</li>)}
</ul>
</div>
)}
</Section>
)}
{reflex && reflex.reflexes.length > 0 && (
<Section icon="✋" title="Expected reflexes">
{reflex.intro && <div className="text-xs text-muted-foreground mb-2">{reflex.intro}</div>}
<div className="space-y-2">
{reflex.reflexes.map((r, i) => {
const c = reflexStatusColor(r.status);
return (
<div key={i} className="text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{r.name}</span>
<span
className="text-[11px] font-semibold px-2 py-0.5 rounded-full border"
style={{ backgroundColor: c + '1a', color: c, borderColor: c + '66' }}
>{r.status}</span>
</div>
<div className="text-xs text-muted-foreground">{r.note}</div>
</div>
);
})}
</div>
</Section>
)}
{growth?.bmiClassification && data.bmiClassification?.categories && (
<Section icon="⚖" title="BMI / weight classification (AAP 2023)">
<div className="space-y-1">
{data.bmiClassification.categories.map((c, i) => (
<div key={i} className="grid grid-cols-[1fr_auto_2fr] gap-2 text-sm border-l-4 pl-2 py-1" style={{ borderLeftColor: c.color }}>
<strong>{c.label}</strong>
<span className="text-muted-foreground">{c.range}</span>
<span>{c.action}</span>
</div>
))}
</div>
{data.bmiClassification.notes && <div className="text-xs text-muted-foreground pt-1"> {data.bmiClassification.notes}</div>}
</Section>
)}
{periodicity?.notes && (
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 p-3 text-sm">
{periodicity.notes}
</div>
)}
</div>
);
}
function ScreenList(props: {
icon: string;
title: string;
items: { key: string; label: string; status: string }[];
visitId: string;
statuses: VisitStatuses;
setStatus: (k: string, s: string) => void;
setNote: (k: string, n: string) => void;
}) {
return (
<Section icon={props.icon} title={props.title}>
<div className="space-y-2">
{props.items.map((it) => {
const k = props.visitId + '.' + it.key;
const v = props.statuses[k];
const cur = getStatusValue(v);
const note = typeof v === 'object' && v ? v.note : '';
return (
<div key={it.key} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1 min-w-[200px]">{it.label}</span>
<StatusBar statuses={SCREEN_STATUSES} current={cur} onPick={(s) => props.setStatus(k, s)} />
{cur === 'Refused' || cur === 'Done' ? (
<input
type="text"
value={note}
onChange={(e) => props.setNote(k, e.target.value)}
placeholder="note (optional)"
className="rounded-md border border-input bg-background px-2 py-1 text-xs flex-1 min-w-[160px]"
/>
) : null}
</div>
);
})}
</div>
</Section>
);
}

View file

@ -0,0 +1,328 @@
// ============================================================
// MILESTONES — developmental checklist per age group → AI narrative.
// Faithful port of public/js/milestones.js (@be14578).
//
// Flow:
// 1. GET /api/milestones-data → { [ageGroup]: { [domain]: string[] } }
// 2. User picks age group → renders checklist with ✓ / ✗ toggles
// (third state = null = "not assessed, omit from narrative")
// 3. Generate → POST /api/generate-milestone-narrative
// 4. Optional 3-sentence summary → /api/generate-milestone-summary
// 5. "Copy to Note" carries the narrative to the Visit Note tab
// (sessionStorage bridge: wv-milestones-narrative)
// ============================================================
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import OutputActions from '@/components/OutputActions';
type Status = 'yes' | 'no' | null;
type MilestonesData = Record<string, Record<string, string[]>>;
interface MilestonesDataOk { success: true; milestones: MilestonesData }
interface NarrativeOk {
success: true;
narrative: string;
model: string;
summary?: { achieved: number; notAchieved: number; notAssessed: number };
}
interface SummaryOk { success: true; summary: string; model: string }
interface ItemState {
domain: string;
milestone: string;
status: Status;
}
// Icon + tint per developmental domain (from the vanilla DOMAIN_CONFIG).
const DOMAIN_CONFIG: Record<string, { icon: string; className: string }> = {
'Gross Motor': { icon: '🏃', className: 'border-l-4 border-l-blue-500' },
'Fine Motor': { icon: '✋', className: 'border-l-4 border-l-purple-500' },
'Language': { icon: '💬', className: 'border-l-4 border-l-emerald-500' },
'Social/Emotional':{ icon: '😊', className: 'border-l-4 border-l-amber-500' },
'Cognitive': { icon: '🧠', className: 'border-l-4 border-l-pink-500' },
'Self-Help': { icon: '🧒', className: 'border-l-4 border-l-indigo-500' },
'Feeding': { icon: '🍼', className: 'border-l-4 border-l-cyan-500' },
'Sleep': { icon: '💤', className: 'border-l-4 border-l-slate-500' },
};
const DEFAULT_DOMAIN = { icon: '📋', className: 'border-l-4 border-l-border' };
const AGE_GROUP_ORDER = [
'Newborn / 1 month', '2 months', '4 months', '6 months', '9 months',
'12 months', '15 months', '18 months', '24 months', '30 months',
'36 months', '48 months', '60 months',
'6 years', '7 years', '8 years', '9 years', '10 years', '11 years',
];
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm font-semibold disabled:opacity-50';
export default function Milestones() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [ageGroup, setAgeGroup] = useState('');
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
const [state, setState] = useState<Record<string, ItemState>>({});
const [narrative, setNarrative] = useState<string | null>(null);
const [summaryStats, setSummaryStats] = useState<NarrativeOk['summary']>(undefined);
const [quickSummary, setQuickSummary] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const { data, isLoading } = useQuery<MilestonesDataOk>({
queryKey: ['milestones-data'],
queryFn: () => api.get<MilestonesDataOk>('/api/milestones-data'),
});
// Keep the age-group dropdown in the canonical order even if server returns
// them alphabetically. Anything not in AGE_GROUP_ORDER appears at the bottom.
const ageGroupKeys = useMemo(() => {
const available = data?.milestones ? Object.keys(data.milestones) : [];
const ordered = AGE_GROUP_ORDER.filter((a) => available.includes(a));
const extras = available.filter((a) => !AGE_GROUP_ORDER.includes(a));
return [...ordered, ...extras];
}, [data]);
// When ageGroup changes, (re)build the checklist state.
useEffect(() => {
if (!ageGroup || !data?.milestones?.[ageGroup]) { setState({}); return; }
const newState: Record<string, ItemState> = {};
const domains = data.milestones[ageGroup];
Object.keys(domains).forEach((domain) => {
domains[domain].forEach((m, idx) => {
newState[domain + '-' + idx] = { domain, milestone: m, status: null };
});
});
setState(newState);
setNarrative(null); setSummaryStats(undefined); setQuickSummary(null);
}, [ageGroup, data]);
const narrativeMut = useMutation<NarrativeOk, Error, void>({
mutationFn: async () => {
const body = {
milestones: Object.values(state),
ageGroup,
patientAge,
patientGender,
format,
};
return api.post<NarrativeOk>('/api/generate-milestone-narrative', body);
},
onSuccess: (d) => {
setNarrative(d.narrative);
setSummaryStats(d.summary);
setQuickSummary(null);
setMsg({ kind: 'ok', text: 'Generated' });
},
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Failed' }),
});
const summaryMut = useMutation<SummaryOk, Error, void>({
mutationFn: async () => api.post<SummaryOk>('/api/generate-milestone-summary', {
narrative, ageGroup, patientAge, patientGender,
}),
onSuccess: (d) => { setQuickSummary(d.summary); setMsg({ kind: 'ok', text: 'Summary generated' }); },
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Failed' }),
});
function toggle(id: string, action: 'yes' | 'no') {
setState((s) => {
const cur = s[id];
if (!cur) return s;
const next: Status = cur.status === action ? null : action;
return { ...s, [id]: { ...cur, status: next } };
});
}
function allYes() { setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, { ...v, status: 'yes' as Status }]))); }
function allClear() {
setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, { ...v, status: null as Status }])));
setNarrative(null); setSummaryStats(undefined); setQuickSummary(null);
}
function generate() {
if (!ageGroup) { setMsg({ kind: 'err', text: 'Select age group' }); return; }
const assessed = Object.values(state).filter((m) => m.status !== null);
if (!assessed.length) { setMsg({ kind: 'err', text: 'Assess at least one milestone' }); return; }
setMsg(null);
narrativeMut.mutate();
}
function copyToNote() {
if (!narrative) return;
try {
sessionStorage.setItem('wv-milestones-narrative', narrative);
setMsg({ kind: 'ok', text: 'Copied to Visit Note — switch to that tab' });
} catch {
setMsg({ kind: 'err', text: 'Session storage unavailable' });
}
}
// Group checklist items by domain for rendering.
const grouped = useMemo(() => {
const out: Record<string, { id: string; text: string; status: Status }[]> = {};
Object.entries(state).forEach(([id, v]) => {
if (!out[v.domain]) out[v.domain] = [];
out[v.domain].push({ id, text: v.milestone, status: v.status });
});
return out;
}, [state]);
const domainCounts = useMemo(() => {
const out: Record<string, { total: number; yes: number; no: number }> = {};
Object.values(state).forEach((v) => {
const c = out[v.domain] || (out[v.domain] = { total: 0, yes: 0, no: 0 });
c.total++;
if (v.status === 'yes') c.yes++;
if (v.status === 'no') c.no++;
});
return out;
}, [state]);
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Patient age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} placeholder="e.g. 9 months" />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Age group</span>
<select className={input} value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)} data-testid="ms-age-group">
<option value="">-- Select --</option>
{ageGroupKeys.map((k) => (<option key={k} value={k}>{k}</option>))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Output format</span>
<select className={input} value={format} onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}>
<option value="narrative">Narrative</option>
<option value="list">Structured list</option>
</select>
</label>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span><span className="inline-block w-4 h-4 align-middle bg-green-100 text-green-700 text-center rounded mr-1"></span> Achieved</span>
<span><span className="inline-block w-4 h-4 align-middle bg-red-100 text-red-700 text-center rounded mr-1"></span> Not achieved</span>
<span><span className="inline-block w-4 h-4 align-middle bg-muted text-center rounded mr-1"></span> Not assessed (omitted)</span>
</div>
</div>
{isLoading && <div className="text-sm text-muted-foreground">Loading milestones</div>}
{ageGroup && Object.keys(grouped).length === 0 && !isLoading && (
<div className="text-sm text-muted-foreground italic">No data for this age group.</div>
)}
{Object.entries(grouped).map(([domain, items]) => {
const cfg = DOMAIN_CONFIG[domain] || DEFAULT_DOMAIN;
const c = domainCounts[domain];
return (
<div key={domain} className={'rounded-lg bg-card p-0 overflow-hidden ' + cfg.className}>
<div className="px-4 py-2 flex items-center gap-2 bg-muted/40">
<span>{cfg.icon}</span>
<span className="font-semibold">{domain}</span>
<span className="ml-auto text-xs text-muted-foreground" data-testid={'ms-badge-' + domain.replace(/\W/g, '_')}>
{c && c.yes + c.no > 0 ? c.yes + '✓ ' + c.no + '✗ / ' + c.total : c.total + ' items'}
</span>
</div>
<div className="divide-y divide-border">
{items.map((it) => (
<div key={it.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<div className="flex gap-1 shrink-0">
<button
type="button"
onClick={() => toggle(it.id, 'yes')}
className={'w-8 h-8 rounded border text-xs font-bold ' +
(it.status === 'yes' ? 'bg-green-100 text-green-700 border-green-400' : 'bg-background border-border hover:bg-muted')
}
aria-label={'Achieved: ' + it.text}
></button>
<button
type="button"
onClick={() => toggle(it.id, 'no')}
className={'w-8 h-8 rounded border text-xs font-bold ' +
(it.status === 'no' ? 'bg-red-100 text-red-700 border-red-400' : 'bg-background border-border hover:bg-muted')
}
aria-label={'Not achieved: ' + it.text}
></button>
</div>
<span className={
it.status === 'yes' ? 'text-green-700' :
it.status === 'no' ? 'text-red-700 line-through decoration-red-400' : ''
}>{it.text}</span>
</div>
))}
</div>
</div>
);
})}
{Object.keys(grouped).length > 0 && (
<div className="flex flex-wrap gap-2">
<button type="button" onClick={allYes} className={btn}> All yes</button>
<button type="button" onClick={allClear} className={btn}>🧹 Clear all</button>
<button type="button" onClick={generate} disabled={narrativeMut.isPending} className={btnPrimary}>
{narrativeMut.isPending ? 'Generating…' : '✨ Generate narrative'}
</button>
</div>
)}
{msg && (
<div className={'text-sm ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</div>
)}
{narrative && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between">
<h3 className="text-sm font-semibold">Developmental assessment</h3>
<button type="button" onClick={copyToNote} className={btn}>📋 Copy to Note</button>
</header>
{summaryStats && (
<div className="flex gap-4 text-xs px-4 py-2 border-b border-border bg-background text-muted-foreground">
<span> Achieved: {summaryStats.achieved}</span>
<span> Not yet: {summaryStats.notAchieved}</span>
<span> Not assessed: {summaryStats.notAssessed} (omitted)</span>
</div>
)}
<div className="p-4 whitespace-pre-wrap text-sm">{narrative}</div>
<div className="px-4 pb-3">
<OutputActions
text={narrative}
onUpdate={setNarrative}
exportLabel="milestones"
exportType="milestones"
/>
</div>
<div className="px-4 pb-4 pt-2 border-t border-border space-y-2">
<button
type="button"
onClick={() => summaryMut.mutate()}
disabled={summaryMut.isPending}
className={btn}
data-testid="ms-summary-btn"
>
{summaryMut.isPending ? 'Summarizing…' : (quickSummary ? '↻ Regenerate summary' : '📏 3-sentence summary')}
</button>
{quickSummary && (
<div className="rounded-md bg-muted/40 p-3 whitespace-pre-wrap text-sm">
{quickSummary}
</div>
)}
</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,363 @@
// ============================================================
// SSHADESS — psychosocial screening (age 12+).
// Faithful port of public/js/shadess.js (@be14578) — same 8 domains,
// same questions, same concern_if flags, same skip toggle.
//
// Flow:
// 1. Clinician fills Yes/No + free-text + comments per domain
// (or hits "Listen in" and dictates the whole thing)
// 2. Generate → POST /api/well-visit/shadess with {patientAge,
// patientGender, domains, dictationText}
// 3. Result auto-fills sessionStorage so the Visit Note tab can
// carry it into the well-visit note (matches the vanilla
// wv-shadess-text auto-fill behaviour).
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import Recorder from '@/components/Recorder';
import OutputActions from '@/components/OutputActions';
interface YnQ { id: string; text: string; type: 'yn'; concern_if?: boolean }
interface TxtQ { id: string; text: string; type: 'text'; placeholder?: string }
type Q = YnQ | TxtQ;
interface Domain {
key: string;
label: string;
icon: string; // emoji stand-in
color: string; // border tint
intro: string;
questions: Q[];
}
// Verbatim from public/js/shadess.js — 8 domains, exact question wording.
const SHADESS_DOMAINS: Domain[] = [
{ key: 'strengths', label: 'Strengths', icon: '⭐', color: '#f59e0b',
intro: 'Starting with what you do well helps us get to know you better.',
questions: [
{ id: 'str1', text: 'Has something they are proud of or enjoy', type: 'yn' },
{ id: 'str2', text: 'Describes self positively when asked', type: 'yn' },
{ id: 'str3', text: 'Has at least one trusted adult they can talk to', type: 'yn' },
]
},
{ key: 'school', label: 'School', icon: '🎓', color: '#3b82f6',
intro: 'Ask about school performance, attendance, and future plans.',
questions: [
{ id: 'sch1', text: 'Grades are satisfactory / doing their best', type: 'yn' },
{ id: 'sch2', text: 'Likes school or finds something enjoyable about it', type: 'yn' },
{ id: 'sch3', text: 'Regular attendance (no truancy concerns)', type: 'yn' },
{ id: 'sch4', text: 'Has plans or goals for the future', type: 'yn' },
]
},
{ key: 'home', label: 'Home', icon: '🏠', color: '#10b981',
intro: 'Ask about living situation and family relationships.',
questions: [
{ id: 'hom1', text: 'Stable living situation', type: 'yn' },
{ id: 'hom2', text: 'Gets along with people at home', type: 'yn' },
{ id: 'hom3', text: 'Would talk to family member if stressed', type: 'yn' },
{ id: 'hom4', text: 'Has experienced household violence or instability (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'activities', label: 'Activities', icon: '👥', color: '#8b5cf6',
intro: 'Ask about friends, hobbies, and peer relationships.',
questions: [
{ id: 'act1', text: 'Has friends and spends time with them', type: 'yn' },
{ id: 'act2', text: 'Involved in sports, clubs, or hobbies', type: 'yn' },
{ id: 'act3', text: 'Social media/screen use within healthy limits', type: 'yn' },
{ id: 'act4', text: 'Has experienced bullying (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'drugs', label: 'Drugs / Substances', icon: '💊', color: '#ef4444',
intro: 'This is confidential. Ask in private.',
questions: [
{ id: 'drg1', text: 'Has tried cigarettes / vaping / tobacco', type: 'yn', concern_if: true },
{ id: 'drg2', text: 'Has tried alcohol', type: 'yn', concern_if: true },
{ id: 'drg3', text: 'Has tried marijuana or other drugs', type: 'yn', concern_if: true },
{ id: 'drg4', text: 'Friends use substances', type: 'yn' },
{ id: 'drg5', text: 'CRAFFT screen result (if done)', type: 'text', placeholder: 'e.g., Score 0 — low risk' },
]
},
{ key: 'emotions', label: 'Emotions / Eating', icon: '❤️', color: '#ec4899',
intro: 'Screen for depression, anxiety, and disordered eating.',
questions: [
{ id: 'emo1', text: 'Feeling down, sad, or hopeless recently', type: 'yn', concern_if: true },
{ id: 'emo2', text: 'Feeling unusually stressed or anxious', type: 'yn', concern_if: true },
{ id: 'emo3', text: 'Trouble sleeping', type: 'yn' },
{ id: 'emo4', text: 'PHQ-A / depression screen result (if done)', type: 'text', placeholder: 'e.g., PHQ-A score 3 — minimal' },
{ id: 'emo5', text: 'Happy with eating habits and body image', type: 'yn' },
{ id: 'emo6', text: 'Restricting food / purging / using diet pills (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'sexuality', label: 'Sexuality', icon: '🛡️', color: '#f97316',
intro: 'Ask in private. Normalize the questions.',
questions: [
{ id: 'sex1', text: 'Comfortable discussing attraction/identity', type: 'yn' },
{ id: 'sex2', text: 'Sexually active', type: 'yn' },
{ id: 'sex3', text: 'Uses protection consistently if sexually active', type: 'yn' },
{ id: 'sex4', text: 'History of unwanted sexual contact (concern if YES)', type: 'yn', concern_if: true },
{ id: 'sex5', text: 'STI screening indicated/done', type: 'yn' },
]
},
{ key: 'safety', label: 'Safety', icon: '🛡', color: '#6366f1',
intro: 'Ask about violence, weapons, and suicidal ideation.',
questions: [
{ id: 'saf1', text: 'Feels safe at school and home', type: 'yn' },
{ id: 'saf2', text: 'Carries a weapon (concern if YES)', type: 'yn', concern_if: true },
{ id: 'saf3', text: 'Has been in physical fights recently', type: 'yn', concern_if: true },
{ id: 'saf4', text: 'Wears seatbelt; safe driving practices', type: 'yn' },
{ id: 'saf5', text: 'Thoughts of hurting self or suicide (concern if YES — STAT eval)', type: 'yn', concern_if: true },
{ id: 'saf6', text: 'Columbia/ASQ suicide screen result (if done)', type: 'text', placeholder: 'e.g., ASQ: negative' },
]
}
];
interface DomainAnswers {
questions: Record<string, string>; // qid -> 'yes'|'no'|free text
comment: string;
concern: boolean;
skipped: boolean;
}
type AnswersMap = Record<string, DomainAnswers>;
interface ShadessOk { success: true; assessment: string; model: string }
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-semibold disabled:opacity-50';
function emptyAnswers(): AnswersMap {
const out: AnswersMap = {};
SHADESS_DOMAINS.forEach((d) => {
out[d.key] = { questions: {}, comment: '', concern: false, skipped: false };
});
return out;
}
export default function Shadess() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [answers, setAnswers] = useState<AnswersMap>(emptyAnswers);
const [dictationText, setDictationText] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const generate = useMutation<ShadessOk, Error, void>({
mutationFn: async () => {
const domains: Record<string, { skipped: boolean; comment: string; concern: boolean; questions: { id: string; text: string; answer: string }[] }> = {};
SHADESS_DOMAINS.forEach((d) => {
const a = answers[d.key];
const qs = d.questions
.map((q) => ({ id: q.id, text: q.text, answer: a.questions[q.id] || '' }))
.filter((q) => q.answer !== '');
domains[d.key] = { skipped: a.skipped, comment: a.comment, concern: a.concern, questions: qs };
});
const hasData = Object.values(domains).some((d) => !d.skipped && (d.questions.length > 0 || d.comment));
if (!hasData && !dictationText.trim()) {
throw new Error('Fill in at least one domain or dictate something');
}
return api.post<ShadessOk>('/api/well-visit/shadess', {
patientAge, patientGender, domains, dictationText: dictationText.trim() || null,
});
},
onSuccess: (d) => {
setResult(d.assessment);
setMsg({ kind: 'ok', text: 'Generated' });
try { sessionStorage.setItem('wv-shadess-assessment', d.assessment); } catch { /* ignore */ }
},
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Generation failed' }),
});
function setQ(domainKey: string, qid: string, value: string, concernIf?: boolean) {
setAnswers((m) => {
const cur = m[domainKey];
const newQuestions = { ...cur.questions, [qid]: value };
// Auto-flag concern when the answer matches the concern_if rule.
let concern = cur.concern;
if (concernIf !== undefined && (value === 'yes') === concernIf) {
concern = true;
}
return { ...m, [domainKey]: { ...cur, questions: newQuestions, concern } };
});
}
function setComment(domainKey: string, value: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], comment: value } }));
}
function toggleSkip(domainKey: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], skipped: !m[domainKey].skipped } }));
}
function toggleConcern(domainKey: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], concern: !m[domainKey].concern } }));
}
function clearAll() {
setAnswers(emptyAnswers());
setDictationText('');
setResult(null);
setMsg(null);
}
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Patient age</span>
<input className={input + ' w-32'} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} placeholder="e.g. 14 years" />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Gender</span>
<select className={input + ' w-40'} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
<option>Non-binary/Other</option>
</select>
</label>
<div className="ml-auto text-xs text-muted-foreground">
Recommended age 12 and older. Ask in private.
</div>
</div>
<div>
<span className="text-xs font-semibold uppercase text-muted-foreground">Listen in (optional dictation)</span>
<Recorder
module="shadess"
onTranscript={(text, meta) => {
setDictationText((prev) => meta.appended ? (prev ? prev + ' ' + text : text) : text);
setRecError(null);
}}
onError={(m) => setRecError(m)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<textarea
className={input + ' w-full mt-1 min-h-[60px] font-mono text-xs'}
placeholder="Or type the dictation directly. Used as supplementary input alongside the structured answers below."
value={dictationText}
onChange={(e) => setDictationText(e.target.value)}
/>
</div>
</div>
{SHADESS_DOMAINS.map((d) => {
const a = answers[d.key];
return (
<div
key={d.key}
className="rounded-lg border border-border bg-card overflow-hidden"
style={{ borderLeftWidth: 3, borderLeftColor: d.color }}
data-testid={'shadess-domain-' + d.key}
>
<div className="px-4 py-2 flex items-center gap-2 bg-muted/30">
<span style={{ color: d.color }}>{d.icon}</span>
<strong className="text-sm">{d.label}</strong>
<span className="text-xs text-muted-foreground flex-1 ml-2">{d.intro}</span>
{a.concern && (
<span className="text-xs text-amber-700 bg-amber-100 px-2 py-0.5 rounded"> Concern</span>
)}
<button
type="button"
onClick={() => toggleConcern(d.key)}
className="text-xs text-muted-foreground hover:text-foreground"
title="Toggle concern flag"
>🚩</button>
<label className="text-xs text-muted-foreground flex items-center gap-1">
<input type="checkbox" checked={a.skipped} onChange={() => toggleSkip(d.key)} /> Skip
</label>
</div>
<div className={'px-4 py-2 space-y-2 ' + (a.skipped ? 'opacity-30' : '')}>
{d.questions.map((q) => {
if (q.type === 'yn') {
return (
<div key={q.id} className="flex items-center gap-2 text-sm">
<span className="flex-1">
{q.text}
{q.concern_if !== undefined && (
<span className="text-[10px] text-muted-foreground ml-1">
(flag if {q.concern_if ? 'Yes' : 'No'})
</span>
)}
</span>
<select
className={input + ' text-xs py-1 w-24'}
value={a.questions[q.id] || ''}
onChange={(e) => setQ(d.key, q.id, e.target.value, q.concern_if)}
disabled={a.skipped}
data-testid={'shadess-q-' + q.id}
>
<option value=""></option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
);
}
return (
<div key={q.id} className="flex items-center gap-2 text-sm">
<span className="flex-1">{q.text}</span>
<input
type="text"
className={input + ' text-xs py-1 flex-1 max-w-[260px]'}
placeholder={q.placeholder}
value={a.questions[q.id] || ''}
onChange={(e) => setQ(d.key, q.id, e.target.value)}
disabled={a.skipped}
data-testid={'shadess-q-' + q.id}
/>
</div>
);
})}
<div className="flex items-start gap-2 text-sm">
<span className="flex-1 pt-1">Additional notes:</span>
<textarea
rows={2}
className={input + ' text-xs flex-1 min-w-[200px] resize-y'}
placeholder="Free text comments for this domain…"
value={a.comment}
onChange={(e) => setComment(d.key, e.target.value)}
disabled={a.skipped}
/>
</div>
</div>
</div>
);
})}
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => generate.mutate()} disabled={generate.isPending} className={btnPrimary} data-testid="shadess-generate">
{generate.isPending ? 'Generating…' : '✨ Generate SSHADESS Assessment'}
</button>
<button type="button" onClick={clearAll} className={btn}> New patient</button>
</div>
{msg && (
<div className={'text-sm ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</div>
)}
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h3 className="text-sm font-semibold">SSHADESS Assessment</h3>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={dictationText}
exportLabel="sshadess"
exportType="sshadess"
/>
</div>
<div className="px-4 pb-3 text-xs text-muted-foreground">
Auto-saved to session for the Visit Note tab switch tabs to incorporate.
</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,311 @@
// ============================================================
// WELL VISIT — note generator (one of four sub-tabs).
// Picks up SSHADESS + Milestones carry-overs from sessionStorage
// (auto-set by Shadess.tsx and Milestones.tsx) so the user can
// flow byvisit → milestones → shadess → note without retyping.
// ============================================================
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
import Recorder from '@/components/Recorder';
import RosPeTable, { rosAllWnl, rosClear } from '@/components/RosPeTable';
import DxPicker from '@/components/DxPicker';
import {
ROS_SYSTEMS,
PE_SYSTEMS,
formatRosForAI,
formatDxForAI,
type RosData,
type DxEntry,
} from '@shared/clinical/ros-pe-dx';
const TYPE = 'wellvisit' as const;
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
export default function VisitNote() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [visitAge, setVisitAge] = useState('');
const [vitals, setVitals] = useState('');
const [measurements, setMeasurements] = useState('');
const [parentConcerns, setParentConcerns] = useState('');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [shadess, setShadess] = useState('');
const [milestones, setMilestones] = useState('');
const [screenings, setScreenings] = useState('');
const [vaccines, setVaccines] = useState('');
const [byvisit, setByvisit] = useState('');
const [rosData, setRosData] = useState<RosData>({});
const [peData, setPeData] = useState<RosData>({});
const [diagnoses, setDiagnoses] = useState<DxEntry[]>([]);
const [dxFreetext, setDxFreetext] = useState('');
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
const [result, setResult] = useState<string | null>(null);
// On mount: pick up SSHADESS / Milestones / By-Visit carry-overs.
useEffect(() => {
try {
const s = sessionStorage.getItem('wv-shadess-assessment');
if (s) setShadess(s);
const m = sessionStorage.getItem('wv-milestones-narrative');
if (m) setMilestones(m);
const b = sessionStorage.getItem('wv-byvisit-statuses');
if (b) setByvisit(b);
const va = sessionStorage.getItem('ped_visit_age');
if (va) setVisitAge((cur) => cur || va);
} catch { /* ignore */ }
}, []);
const generate = useMutation<VisitNoteOk, Error, Record<string, unknown>>({
mutationFn: (body) => api.post<VisitNoteOk>('/api/well-visit/note', body),
onSuccess: (data) => setResult(data.note),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
const rosText = formatRosForAI(ROS_SYSTEMS, rosData, 'ROS');
const peText = formatRosForAI(PE_SYSTEMS, peData, 'PHYSICAL EXAM');
const dxText = formatDxForAI(diagnoses, dxFreetext);
generate.mutate({
patientAge, patientGender, visitAge,
vitals, measurements, parentConcerns,
transcript: (interim || transcript).trim(),
shadessAssessment: shadess || undefined,
// The byvisit summary is appended to screenings so the AI sees it.
screenings: [screenings, byvisit].filter(Boolean).join('\n\n'),
vaccines,
ros: rosText || undefined,
physicalExam: peText || undefined,
diagnoses: dxText || undefined,
// Milestones get folded into transcript context as a developmental block.
physicianMemories: milestones ? '[DEVELOPMENTAL ASSESSMENT]\n' + milestones : undefined,
noteStyle,
});
}
const displayedTranscript = interim || transcript;
return (
<div className="space-y-4">
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, shadess, milestones, byvisit, screenings, vaccines, rosData, peData, diagnoses, dxFreetext, noteStyle }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.visitAge) setVisitAge(pd.visitAge);
if (pd?.vitals) setVitals(pd.vitals);
if (pd?.measurements) setMeasurements(pd.measurements);
if (pd?.parentConcerns) setParentConcerns(pd.parentConcerns);
if (pd?.shadess) setShadess(pd.shadess);
if (pd?.milestones) setMilestones(pd.milestones);
if (pd?.byvisit) setByvisit(pd.byvisit);
if (pd?.screenings) setScreenings(pd.screenings);
if (pd?.vaccines) setVaccines(pd.vaccines);
if (pd?.rosData) setRosData(pd.rosData);
if (pd?.peData) setPeData(pd.peData);
if (pd?.diagnoses) setDiagnoses(pd.diagnoses);
if (pd?.dxFreetext) setDxFreetext(pd.dxFreetext);
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null);
setPatientAge(''); setPatientGender(''); setVisitAge('');
setVitals(''); setMeasurements(''); setParentConcerns('');
setShadess(''); setMilestones(''); setByvisit('');
setScreenings(''); setVaccines('');
setRosData({}); setPeData({}); setDiagnoses([]); setDxFreetext('');
setNoteStyle('full');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option><option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Visit age</span>
<input className={input} placeholder="e.g. 6 months" value={visitAge} onChange={(e) => setVisitAge(e.target.value)} />
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Vital signs</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={vitals} onChange={(e) => setVitals(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Measurements / growth</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={measurements} onChange={(e) => setMeasurements(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Parent / patient concerns</span>
<textarea className={input + ' min-h-[60px] text-sm'} value={parentConcerns} onChange={(e) => setParentConcerns(e.target.value)} />
</label>
<Recorder
module="wellvisit"
onTranscript={(text, meta) => {
setTranscript((prev) => meta.appended ? (prev ? prev + ' ' + text : text) : text);
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(m) => setRecError(m)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
<textarea
className={input + ' min-h-[160px] font-mono text-sm'}
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
placeholder="Click Start recording, or type / paste."
/>
</label>
{(shadess || milestones || byvisit) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/30 p-3 space-y-2">
<div className="text-xs font-semibold text-amber-800 dark:text-amber-200">Carry-overs from other tabs (used in note generation)</div>
{milestones && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">Developmental assessment ({milestones.length} chars)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={milestones} onChange={(e) => setMilestones(e.target.value)} />
</details>
)}
{shadess && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">SSHADESS assessment ({shadess.length} chars)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={shadess} onChange={(e) => setShadess(e.target.value)} />
</details>
)}
{byvisit && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">By-visit-age statuses ({byvisit.split('\n').length} lines)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={byvisit} onChange={(e) => setByvisit(e.target.value)} />
</details>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Screenings completed</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={screenings} onChange={(e) => setScreenings(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Immunizations today</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={vaccines} onChange={(e) => setVaccines(e.target.value)} />
</label>
</div>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Review of Systems</span>
<div className="flex gap-1">
<button type="button" onClick={() => setRosData(rosAllWnl(ROS_SYSTEMS, rosData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All WNL</button>
<button type="button" onClick={() => setRosData(rosClear(rosData, ROS_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={ROS_SYSTEMS}
data={rosData}
onChange={setRosData}
btnLabels={{ wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }}
testIdPrefix="wv-ros"
/>
</div>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Physical Examination</span>
<div className="flex gap-1">
<button type="button" onClick={() => setPeData(rosAllWnl(PE_SYSTEMS, peData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All Normal</button>
<button type="button" onClick={() => setPeData(rosClear(peData, PE_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={PE_SYSTEMS}
data={peData}
onChange={setPeData}
btnLabels={{ wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }}
testIdPrefix="wv-pe"
/>
</div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider">Diagnoses (ICD-10)</span>
<DxPicker value={diagnoses} onChange={setDiagnoses} testIdPrefix="wv-dx" />
<label className="block">
<span className="text-[11px] text-muted-foreground">Additional free-text diagnosis / note (optional)</span>
<input
type="text"
value={dxFreetext}
onChange={(e) => setDxFreetext(e.target.value)}
className={input + ' text-sm'}
placeholder="e.g. Rule out iron-deficiency anaemia pending labs"
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Note style</span>
<select className={input} value={noteStyle} onChange={(e) => setNoteStyle(e.target.value as 'full' | 'short')}>
<option value="full">Full encounter note</option>
<option value="short">Brief SOAP</option>
</select>
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || (!patientAge.trim() && !visitAge.trim())}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Well Visit Note'}
</button>
</form>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="wellvisit"
title="Well Visit Note"
exportLabel="well-visit-note"
exportType="well-visit"
sourceContext={displayedTranscript}
/>
)}
</div>
);
}

View file

@ -0,0 +1,118 @@
// ============================================================
// ZOD SCHEMAS — runtime validation at API boundaries
// ============================================================
// Each incoming request body gets parsed through one of these schemas
// BEFORE the handler sees it. If parse fails, a 400 is returned with
// the detailed validation error — no more silent "undefined reading
// X" crashes on malformed input. TypeScript types for req.body can
// be inferred from the schema with `z.infer<typeof XxxSchema>`.
//
// Keep wire-shape aligned with shared/types.ts. The types file is
// for RESPONSES (what the server returns); this file is for REQUESTS
// (what the client sends).
// ============================================================
import { z } from 'zod';
// ── Common fragments ─────────────────────────────────────────
const NonEmptyString = z.string().min(1);
const OptionalTrimmed = z.string().optional();
const OptionalModel = z.string().optional();
// ── Auth ─────────────────────────────────────────────────────
export const LoginRequestSchema = z.object({
email: z.string().email(),
password: NonEmptyString,
turnstileToken: OptionalTrimmed,
totpCode: OptionalTrimmed,
});
export const RegisterRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: NonEmptyString,
turnstileToken: OptionalTrimmed,
});
export const ForgotPasswordRequestSchema = z.object({
email: z.string().email(),
turnstileToken: OptionalTrimmed,
});
// ── AI generation requests ───────────────────────────────────
export const HpiEncounterRequestSchema = z.object({
transcript: NonEmptyString,
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
model: OptionalModel,
setting: z.enum(['outpatient', 'inpatient']).optional(),
physicianMemories: OptionalTrimmed,
});
export const SoapRequestSchema = z.object({
transcript: NonEmptyString,
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
model: OptionalModel,
type: z.enum(['full', 'subjective']).optional(),
additionalInstructions: OptionalTrimmed,
physicianMemories: OptionalTrimmed,
});
export const SickVisitRequestSchema = z.object({
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
chiefComplaint: NonEmptyString,
transcript: OptionalTrimmed,
dictation: OptionalTrimmed,
ros: OptionalTrimmed,
physicalExam: OptionalTrimmed,
diagnoses: OptionalTrimmed,
physicianMemories: OptionalTrimmed,
model: OptionalModel,
});
export const RefineRequestSchema = z.object({
currentDocument: NonEmptyString,
instructions: NonEmptyString,
sourceContext: OptionalTrimmed,
model: OptionalModel,
});
export const PeNarrativeRequestSchema = z.object({
steps: z.array(z.object({
component: OptionalTrimmed,
label: NonEmptyString,
method: OptionalTrimmed,
normal: OptionalTrimmed,
status: z.enum(['normal', 'abnormal']).nullable().optional(),
note: OptionalTrimmed,
})).min(1),
ageGroup: OptionalTrimmed,
system: OptionalTrimmed,
patientAge: OptionalTrimmed,
patientGender: OptionalTrimmed,
model: OptionalModel,
format: z.enum(['narrative', 'list']).optional(),
});
// ── Extensions CRUD ──────────────────────────────────────────
export const ExtensionCreateSchema = z.object({
location: z.string().min(1).max(120),
name: z.string().min(1).max(120),
number: z.string().min(1).max(40),
type: z.enum(['extension', 'pager']).optional(),
notes: z.string().max(500).optional(),
});
export const ExtensionUpdateSchema = ExtensionCreateSchema;
// ── Inferred types (use instead of hand-written interfaces) ─
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export type ForgotPasswordRequest = z.infer<typeof ForgotPasswordRequestSchema>;
export type HpiEncounterRequest = z.infer<typeof HpiEncounterRequestSchema>;
export type SoapRequest = z.infer<typeof SoapRequestSchema>;
export type SickVisitRequest = z.infer<typeof SickVisitRequestSchema>;
export type RefineRequest = z.infer<typeof RefineRequestSchema>;
export type PeNarrativeRequest = z.infer<typeof PeNarrativeRequestSchema>;
export type ExtensionCreate = z.infer<typeof ExtensionCreateSchema>;

572
client/src/shared/types.ts Normal file
View file

@ -0,0 +1,572 @@
// ============================================================
// SHARED TYPES — consumed by both server (src/routes/*.ts) and
// client (client/src/*). A response-shape change here breaks the
// build on both sides until they agree.
//
// This is the single most important file in the TypeScript
// migration. Three of the bugs we hit before this point were
// response-shape mismatches (refine returned `content` vs
// `refined`, sick-visit endpoint path mismatch, hospital-course
// key mismatch). Typing the wire protocol makes them compile-time
// errors.
//
// Keep this file strictly about wire protocol — no DB row shapes,
// no server-internal helpers. Anything that crosses the network.
// ============================================================
// ── Envelope ─────────────────────────────────────────────────
// Every route returns either `{success: true, ...extraFields}` or
// `{success: false, error: string}`. The generic `T` is the shape
// of the extra fields on success. Client code does:
// const r: ApiResponse<HpiOk> = await fetch(...).then(r => r.json());
// if (r.success) console.log(r.hpi);
// else showToast(r.error);
export interface ApiErr {
success: false;
error: string;
}
export type ApiResponse<T> = ({ success: true } & T) | ApiErr;
// Model tag accompanies every AI response — LiteLLM pass-through.
export interface WithModel {
model: string;
}
// ── AI generation responses ──────────────────────────────────
// Keys match exactly what each route returns today. Do not rename
// without updating the server-side res.json() call in the same commit.
// /api/generate-hpi-encounter
// /api/generate-hpi-dictation
export interface HpiOk extends WithModel {
hpi: string;
}
// /api/generate-soap
export interface SoapOk extends WithModel {
soap: string;
}
// /api/sick-visit/note (NOT /api/generate-sick-visit — that path does not exist)
// /api/well-visit/note
export interface VisitNoteOk extends WithModel {
note: string;
}
// /api/generate-hospital-course
export interface HospitalCourseOk extends WithModel {
hospitalCourse: string;
format?: string;
}
// /api/generate-chart-review
export interface ChartReviewOk extends WithModel {
review: string;
}
// /api/generate-pe-narrative
export interface PeNarrativeOk extends WithModel {
narrative: string;
summary: {
normal: number;
abnormal: number;
notAssessed: number;
};
}
// /api/generate-milestone-narrative
export interface MilestoneNarrativeOk extends WithModel {
narrative: string;
summary: {
achieved: number;
notAchieved: number;
notAssessed: number;
};
}
// /api/generate-milestone-summary
export interface MilestoneSummaryOk extends WithModel {
summary: string;
}
// /api/well-visit/shadess
export interface ShadessOk extends WithModel {
assessment: string;
}
// /api/refine
export interface RefineOk extends WithModel {
refined: string;
}
// /api/shorten (via refine.js router)
export interface ShortenOk extends WithModel {
shortened: string;
}
// /api/clarify and /hospital-course/clarify
export interface ClarifyOk extends WithModel {
questions: string;
}
// /api/suggest-billing-codes
export interface BillingCodesOk extends WithModel {
icd10: Array<{ code: string; description: string; reason?: string }>;
cpt: Array<{ code: string; description: string; reason?: string }>;
}
// /api/transcribe
export interface TranscribeOk {
text: string;
provider: string;
duration: number;
}
// /api/tts
export interface TtsOk {
audioBase64: string;
}
// /api/models
export interface ModelsOk {
models: Array<{ id: string; label?: string }>;
}
// ── Auth ─────────────────────────────────────────────────────
export interface AuthUser {
id: number;
email: string;
name: string;
role?: string;
isVerified?: boolean;
has2FA?: boolean;
// canLocalAuth: false for SSO-auto-created accounts whose password is a
// random hex blob that can never verify. Settings hides password/2FA UI
// for those users. totp_enabled / email_verified / nextcloud_* mirror
// DB columns returned by /api/auth/me.
canLocalAuth?: boolean;
totp_enabled?: boolean;
email_verified?: boolean;
nextcloud_url?: string | null;
nextcloud_user?: string | null;
nextcloud_folder?: string | null;
webdav_learning_path?: string | null;
created_at?: string;
}
// /api/auth/login (two-phase: may also return requires2FA / needsVerification)
export interface LoginOk {
token: string;
user: AuthUser;
sessionId?: string;
}
export interface LoginRequires2FA {
success: true;
requires2FA: true;
}
export interface LoginNeedsVerification {
success: true;
needsVerification: true;
}
// /api/auth/me
export interface MeOk {
user: AuthUser;
}
// ── Sessions ─────────────────────────────────────────────────
// Keys match the wire shape returned by /api/sessions (snake_case from
// the DB columns, deliberately unchanged to keep the existing vanilla
// client working during migration).
export interface SessionRow {
id: string;
ip_address?: string | null;
device_label?: string | null;
created_at: string;
last_activity: string;
}
export interface SessionsOk {
sessions: SessionRow[];
currentSessionId: string | null;
}
export interface RevokeAllSessionsOk {
revoked?: number;
}
// ── 2FA + password change ────────────────────────────────────
// /api/auth/setup-2fa
export interface Setup2faOk {
secret: string;
qrCode: string;
}
// /api/auth/verify-2fa — backupCodes populated only on first enable
export interface Verify2faOk {
backupCodes: string[] | null;
}
// /api/auth/2fa/backup-codes/count
export interface BackupCodesCountOk {
remaining: number;
}
// /api/auth/2fa/backup-codes (regenerate)
export interface RegenBackupCodesOk {
codes: string[];
message?: string;
}
// /api/auth/change-password
export interface ChangePasswordOk {
message: string;
passwordWarning?: string;
}
// ── Integrations ─────────────────────────────────────────────
// /api/nextcloud/connect
export interface NextcloudConnectOk {
message: string;
}
// /api/nextcloud/disconnect — {success: true}
// /api/documents — shape returned to the client
export interface UserDocument {
id: number;
filename: string;
mime_type: string;
size_bytes: number;
description?: string | null;
created_at: string;
}
export interface DocumentsListOk {
documents: UserDocument[];
s3_configured: boolean;
}
// /api/documents/upload — multipart; response below
export interface DocumentUploadOk {
id: number;
filename: string;
}
// /api/documents/:id/download — returns a short-lived presigned URL
export interface DocumentDownloadOk {
url: string;
}
// ── Voice prefs + transcription settings ─────────────────────
// /api/user/preferences
export interface UserPreferencesOk {
stt_model: string | null;
tts_voice: string | null;
}
// /api/user/preferences/options — the provider-scoped lists of models/voices
export interface VoiceOption {
value: string;
label: string;
}
export interface PreferencesOptionsOk {
sttProvider: string;
sttModels: VoiceOption[];
ttsProvider: string;
ttsVoices: VoiceOption[];
}
// ── Saved encounters list (Settings view) ────────────────────
// Note: /api/encounters/saved returns a richer row than the sidebar
// EncounterSummary. The Settings list only needs these fields.
export interface SavedEncounterRow {
id: number;
label: string;
enc_type: string;
status?: string | null;
created_at: string;
updated_at: string;
expires_at: string;
transcript_preview?: string;
note_preview?: string;
}
export interface SavedEncountersListOk {
encounters: SavedEncounterRow[];
}
// ── Audio backups (server-stored recordings, 24h TTL) ────────
export interface AudioBackupRow {
id: number;
module: string;
mime_type: string;
size_bytes: number;
compressed_bytes?: number;
created_at: string;
expires_at: string;
}
export interface AudioBackupsListOk {
backups: AudioBackupRow[];
}
// ── Memories (templates + corrections share this shape) ──────
// Extends the minimal Memory type with fields needed by the Settings
// list view (corrections need created_at to show dates).
export interface MemoryRow {
id: number;
category: string;
name: string;
content: string;
created_at?: string;
}
export interface MemoriesOk {
memories: MemoryRow[];
}
// ── Admin ───────────────────────────────────────────────────
// /api/admin/users (admin-gated)
export interface AdminUser {
id: number;
email: string;
name: string;
role: string | null;
email_verified: boolean;
totp_enabled: boolean;
disabled: boolean;
created_at: string;
updated_at?: string;
nextcloud_url?: string | null;
api_calls?: number;
last_login?: string | null;
}
export interface AdminUsersOk { users: AdminUser[] }
export interface AdminUserOk { user: AdminUser }
export interface AdminSettingsOk {
settings: { registrationEnabled: boolean };
stats: { totalUsers: number; totalApiCalls: number; todayApiCalls: number };
}
export interface AdminLogEntry {
id: number;
user_id: number | null;
action: string;
detail: string;
category: string;
ip_address?: string | null;
timestamp: string;
user_email?: string | null;
user_name?: string | null;
}
export interface AdminLogsOk { logs: AdminLogEntry[] }
// /api/admin/config / /api/admin/config/:key
export interface AdminConfigRow {
key: string;
value: string | null;
description?: string | null;
source?: 'env' | 'db' | 'openbao' | string;
}
export interface AdminConfigOk { config: AdminConfigRow[] }
export interface AdminAnnouncementOk {
enabled: boolean;
message: string;
kind?: string;
}
// /api/admin/config/prompts
export interface AdminPromptRow {
key: string;
value: string;
description?: string;
default?: string;
isDefault?: boolean;
}
export interface AdminPromptsOk { prompts: AdminPromptRow[] }
// /api/admin/config/smtp/status
export interface AdminSmtpStatusOk {
configured: boolean;
host?: string;
port?: number;
user?: string;
from?: string;
}
// /api/admin/config/models
export interface AdminModelRow {
id: string;
label?: string;
provider?: string;
enabled: boolean;
isDefault?: boolean;
isCustom?: boolean;
tags?: string[];
}
export interface AdminModelsOk {
models: AdminModelRow[];
provider?: string;
defaultModel?: string | null;
}
// /api/admin/config/tts and /stt
export interface AdminVoiceProviderOk {
provider: string;
enabled: boolean;
voice?: string | null;
model?: string | null;
endpoint?: string | null;
extra?: Record<string, unknown>;
}
// ── Learning Hub (user-facing) ──────────────────────────────
// Categories
export interface LearningCategory {
id: number;
name: string;
slug: string;
description?: string | null;
}
export interface LearningCategoriesOk {
categories: LearningCategory[];
}
// Feed / category / search list rows — same shape across all list endpoints
export interface LearningFeedRow {
id: number;
title: string;
slug: string;
subject?: string | null;
content_type: 'article' | 'pearl' | 'presentation' | 'quiz' | string;
created_at: string;
updated_at?: string;
category_name?: string | null;
category_slug?: string | null;
author_name?: string | null;
question_count?: number;
score?: number;
match_type?: 'keyword' | 'semantic';
}
export interface LearningFeedListOk {
content: LearningFeedRow[];
total?: number;
method?: 'keyword' | 'semantic' | 'hybrid';
}
// Single content with questions + progress
export interface LearningOption {
id: number;
option_text: string;
sort_order: number;
}
export interface LearningQuestion {
id: number;
question_text: string;
question_type: 'single' | 'multi' | 'true_false' | string;
explanation?: string | null;
options: LearningOption[];
}
export interface LearningProgressEntry {
score: number;
total: number;
completed_at: string;
}
export interface LearningContentFull {
id: number;
title: string;
slug: string;
subject?: string | null;
body?: string;
content_type: string;
category_name?: string | null;
category_slug?: string | null;
author_name?: string | null;
questions: LearningQuestion[];
progress: LearningProgressEntry[];
}
export interface LearningContentOk {
content: LearningContentFull;
}
// Quiz submit
export interface QuizAnswer {
questionId: number;
optionId?: number | null;
optionIds?: number[];
}
export interface QuizResultEntry {
questionId: number;
questionType: string;
questionText: string;
isCorrect: boolean;
selectedOptionId?: number | null;
selectedOptionIds?: number[];
correctOptionId?: number | null;
correctOptionIds?: number[];
correctOptionText?: string;
selectedExplanation?: string;
generalExplanation?: string;
}
export interface QuizSubmitOk {
score: number;
total: number;
percentage: number;
results: QuizResultEntry[];
}
// /api/learning/content/:slug/slides (Marp rendering)
export interface LearningSlidesOk {
css: string;
slides: string[]; // pre-rendered HTML per slide from the server
}
// Public config for the auth screen (anonymous users allowed).
// /api/auth/public-config
export interface PublicConfigOk {
registrationEnabled: boolean;
turnstileSiteKey: string | null;
oidcEnabled: boolean;
disableLocalAuth: boolean;
ssoButtonLabel: string;
}
// ── Extensions (pagers/directory) ────────────────────────────
export interface Extension {
id: number;
location: string;
name: string;
number: string;
type: 'extension' | 'pager';
notes?: string;
deletedAt?: string | null;
}
export interface ExtensionsListOk {
items: Extension[];
}
// ── Memories (saved templates / style hints) ─────────────────
export interface Memory {
id: number;
category: string;
name: string;
content: string;
}
export interface MemoriesListOk {
items: Memory[];
}
// ── Learning hub ─────────────────────────────────────────────
export interface LearningContentItem {
id: number | string;
slug?: string;
title: string;
category?: string;
excerpt?: string;
body?: string;
}
export interface LearningFeedOk {
content: LearningContentItem[];
total?: number;
}
// ── Encounters (saved drafts) ────────────────────────────────
export interface EncounterSummary {
id: number;
label: string;
type: 'encounter' | 'dictation' | 'hospital' | 'chart' | 'wellvisit' | 'sickvisit' | 'soap';
createdAt: string;
updatedAt: string;
}
export interface EncountersListOk {
items: EncounterSummary[];
}
// ── Health ───────────────────────────────────────────────────
export interface HealthOk {
ok: boolean;
}

37
client/tsconfig.app.json Normal file
View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../shared/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"../shared/**/*.ts"
],
"exclude": [
"../shared/**/*.test.ts"
]
}

7
client/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
client/tsconfig.node.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

36
client/vite.config.ts Normal file
View file

@ -0,0 +1,36 @@
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// Vite config for the React client.
//
// Build output → ../public/app/ so Express can serve it as a static
// bundle. While migration is in-flight, the old vanilla JS still lives
// at /, and the React tree answers /app/*.
//
// Dev server proxies /api to the backend running on localhost:3000
// (or wherever the backend is) so React dev works against real data.
//
// @shared alias resolves to the repo-root shared/ directory — the
// typed wire-protocol + Zod schemas imported by server and client alike.
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../shared'),
},
},
server: {
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
},
},
build: {
outDir: path.resolve(__dirname, '../public/app'),
emptyOutDir: true,
assetsDir: 'assets',
},
base: '/app/',
});

Some files were not shown because too many files have changed in this diff Show more