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>
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).
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.
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.
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.
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.
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.