Commit graph

7 commits

Author SHA1 Message Date
Daniel
18550e263f 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
Daniel
8d7bffb07a 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
Daniel
719d0cb7f7 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
Daniel
520a1f8fb1 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
Daniel
9dce93cf67 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
Daniel
6d30edf88f 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
a2fe1b38d1 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