Compare commits

..

445 commits

Author SHA1 Message Date
Daniel
f556d50a09 Remove the --gh flag from release.sh
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m46s
release.sh --gh ran `gh release create` against GitHub. This project
publishes releases to Forgejo, and CI does it automatically on a v* tag
push (.forgejo/workflows/android-apk.yml attaches the signed APK for
Obtainium). The flag could not do anything useful here, and having it in
the usage text implied a manual publish step that does not exist.

Drops the flag, its DO_RELEASE branch, and the usage/header references.
--push remains the only option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:52:04 +02:00
Daniel
3fb4c10f2b Point the APK download link at Forgejo
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m48s
The login page linked to
github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest, which returns
404 — releases are published to git.danvics.com by the Forgejo APK
workflow. Verified: the Forgejo URL returns 200, the GitHub one 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:17:29 +02:00
Daniel
4613a27879 Release v7.14.16
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 3m13s
2026-07-31 01:12:47 +02:00
Daniel
f31afcdbf4 Fix biometric login, and keep recording alive across screen lock
Biometric sign-in has never worked. auth.js drove
window.Capacitor.Plugins.NativeBiometric — the API of
capacitor-native-biometric, which is not a dependency of this project. The
installed plugin is @aparajita/capacitor-biometric-auth, registered as
BiometricAuthNative with an entirely different API and no credential
storage at all. bioPlugin() therefore always returned null, bioAvailable()
always resolved {ok:false}, and the button was never revealed.

Rewritten against what is actually installed, with no new dependency:
BiometricAuthNative (checkBiometry/authenticate) presents the prompt, and
the already-working SecureStoragePlugin — via the window.SecureStorage
wrapper — holds the credentials. Credentials are only read after
authenticate() resolves, so the OS still gates access. biometryType is a
numeric enum in this plugin, so the old FACE_ID/TOUCH_ID string maps are
replaced with a single lookup exposed as typeName.

Secure storage itself was fine and is unchanged: SecureStoragePlugin
matches the name the wrapper looks up and is registered in
capacitor.settings.gradle.

Recording across screen lock: window.nativeKeepAwake() called Capacitor's
KeepAwake plugin, which is also not installed here, so it silently did
nothing and the device slept mid-encounter — taking the WebView's
MediaRecorder with it. keepAwake() is now a method on the existing
NativeRecording JavascriptInterface, which sets FLAG_KEEP_SCREEN_ON, and
is bound to the WebView so it survives the launcher's navigation to the
remote origin. The Capacitor plugin remains a fallback.

If the screen is locked anyway (power button, incoming call), the activity
pauses and Chromium throttles timers for hidden WebViews, starving
MediaRecorder's chunk delivery. MainActivity now calls resumeTimers() on
pause while recording. The foreground service was already correct — it
holds a partial wake lock and declares FOREGROUND_SERVICE_TYPE_MICROPHONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:12:47 +02:00
Daniel
a814d2a2c2 Fix release.sh pushing to a remote that does not exist
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m27s
--push ran `git push origin HEAD`, but this repo's only remote is named
forgejo — so the flag failed after the script had already committed and
tagged, leaving the release half-done. Resolve the remote instead:
prefer forgejo, fall back to origin, else use the only remote present,
and fail with a clear message if there is none.

Also corrects the header comment, which claimed the script does not build
the APK and pointed at --gh / gh CLI. Forgejo CI builds the APK on every
branch push and attaches it to a Forgejo release on a v* tag push, which
is what Obtainium tracks. --gh is a leftover from the GitHub era.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:05:43 +02:00
Daniel
bca107846e Release v7.14.15
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 4m43s
2026-07-30 17:34:39 +02:00
Daniel
524ad40d49 Match TTS voices to the selected LiteLLM model
Voice lists were a single flat set from LITELLM_TTS_VOICES, so picking a
model could leave an incompatible voice selected and the request would
fail at the gateway. Voices are now resolved per model family (Kokoro,
Kitten, Supertonic, Groq Orpheus EN/AR), with a compatibility check that
falls back through user → admin → env → first valid voice. Groq Orpheus
requests also pin response_format to wav.

Also refreshes the cardiac/respiratory auscultation samples, extends the
well-visit component, and fixes the Android launch theme background
(@null → colorPrimary) so the splash does not flash through.

NOTE: this is in-progress work that was already sitting uncommitted in
the working tree; it is committed here as-is so the tree was clean for
the release bump.
2026-07-30 17:34:34 +02:00
Daniel
82ed46d01b Fix Turnstile in the mobile app; drop it from login
The Turnstile challenge failed reliably inside the Capacitor WebView,
which blocked login and registration from the Android app.

Three separate causes:

1. Android WebView blocks third-party cookies by default. Turnstile runs
   in a cross-origin iframe from challenges.cloudflare.com and needs its
   own storage, so the widget never emitted a token. MainActivity now
   calls setAcceptThirdPartyCookies on the app's own WebView.

2. The register handler read the Turnstile response with an unscoped
   document.querySelector, which matched the *login* widget's input (it
   comes first in the DOM). Registration therefore submitted the login
   widget's token — single-use with a 5 minute expiry, so any prior login
   attempt or slow signup made it fail server-side.

3. The register and forgot-password widgets auto-rendered inside forms
   that start at display:none, where Turnstile does not reliably complete
   a challenge, and nothing re-rendered them when the form was shown.

Widgets are now rendered explicitly when their form first becomes
visible, and tokens are captured from the render callback instead of
being read back out of the injected input — which makes the unscoped
lookup in (2) structurally impossible. Added error/expired/timeout
callbacks so a widget failure surfaces the Cloudflare error code instead
of failing silently behind a generic toast.

Login is no longer gated at all. It is the path mobile users hit
constantly, and it is already covered by a 10-per-15-min per-IP rate
limit, a constant-time credential check, and TOTP 2FA. Registration and
password reset — the endpoints that actually attract bots — stay gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:22:03 +02:00
Daniel
bee9361c1d Fix authenticated mobile image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m39s
2026-06-09 16:02:11 +02:00
Daniel
2ca969e099 Fix generated image mobile downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m21s
2026-06-09 15:20:06 +02:00
Daniel
80139d9a82 Prevent mobile image download preview fallback
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
2026-06-09 03:11:35 +02:00
Daniel
f7cfc6695d Fix clinical assistant image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
2026-06-08 22:15:34 +02:00
Daniel
b0e1f4969a feat: improve clinical assistant prompts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
2026-06-06 00:01:07 +02:00
Daniel
d02b9e2771 Fix clinical prompt pool fallback seeding
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m14s
2026-05-22 16:53:53 +02:00
Daniel
c7921ab822 Release v7.14.9
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 3m51s
2026-05-22 06:06:43 +02:00
Daniel
fb3e4d4135 Update deployment networks
Some checks are pending
Forgejo Android APK / Build signed APK (push) Waiting to run
2026-05-22 05:59:48 +02:00
Daniel
cb63729656 Expand clinical prompt pool taxonomy
Some checks are pending
Forgejo Android APK / Build signed APK (push) Waiting to run
2026-05-22 05:56:08 +02:00
Daniel
2cef65fb1f Fix mobile assistant image downloads
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-13 16:07:41 +02:00
Daniel
629dea808e Fix assistant cancel button visibility
Some checks are pending
Forgejo Android APK / Build signed APK (push) Waiting to run
2026-05-12 16:20:10 +02:00
Daniel
8dca18292a fix assistant cancel hidden state
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 18:05:03 +02:00
Daniel
1f5e4aabac fix assistant cancel visibility
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 17:53:54 +02:00
Daniel
97ddd87449 fix mobile assistant table streaming
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 16:15:45 +02:00
Daniel
e1266c6d38 ci: route Android APK through Forgejo releases
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 04:21:15 +02:00
Daniel
6e8fae72e7 fix mobile image save to photos
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-11 01:28:43 +02:00
Daniel
2c287bd1b3 fix mobile export save actions
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-10 23:56:51 +02:00
Daniel
f871384063 fix mobile assistant export and image actions
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
2026-05-10 20:28:46 +02:00
Daniel
977ebfc037 ci: publish forgejo apk releases 2026-05-10 19:37:52 +02:00
Daniel
212ce7dd95 ci: use forgejo-compatible artifact upload 2026-05-10 18:48:40 +02:00
Daniel
b29c6f7717 ci: harden forgejo android signing restore 2026-05-10 18:31:57 +02:00
Daniel
8f51e56723 ci: fetch android setup actions from github 2026-05-10 18:24:12 +02:00
Daniel
7fe0a0e7ec ci: prepare compose env files in forgejo 2026-05-10 18:21:18 +02:00
Daniel
71655aa7e9 ci: use forgejo local runner label 2026-05-10 18:13:14 +02:00
Daniel
f01ca5a094 ci: scope github release workflows 2026-05-10 18:07:52 +02:00
Daniel
52544e9116 ci: add forgejo build workflows 2026-05-10 18:05:55 +02:00
Daniel
046b07a84a fix clinical assistant mobile exports 2026-05-10 17:23:54 +02:00
Daniel
cddc1a4d79 document architecture and harden rendering 2026-05-10 01:07:56 +02:00
Daniel
a176e1b014 protect code blocks during citation repair 2026-05-09 20:54:05 +02:00
Daniel
83d9a77160 fix table source citation links 2026-05-09 20:50:54 +02:00
Daniel
baf0020981 prefer file names for clinical sources 2026-05-09 20:10:24 +02:00
Daniel
795ad9ffae harden clinical assistant source handling 2026-05-09 19:59:04 +02:00
Daniel
8d69fe57a5 fix litellm metadata discovery auth 2026-05-09 15:15:47 +02:00
Daniel
90cdf17bd9 fix litellm capability discovery 2026-05-09 15:06:28 +02:00
Daniel
1b3ea569b7 simplify speech and embeddings through litellm 2026-05-09 05:09:02 +02:00
Daniel
79037fa775 fix litellm tts search fallbacks 2026-05-09 04:50:55 +02:00
Daniel
2a3631d067 fix litellm metadata model discovery 2026-05-09 04:46:06 +02:00
Daniel
ea12b9a46f chore add pull request template 2026-05-09 04:12:57 +02:00
Daniel
2387e6f136 fix litellm speech model discovery 2026-05-09 04:12:57 +02:00
Daniel
39d77116ac docs remove stale extended guide 2026-05-09 04:08:27 +02:00
Daniel
6f5782734f fix stt local model defaults 2026-05-09 01:57:37 +02:00
Daniel
bb31c6f515 test docs toc links 2026-05-09 01:33:06 +02:00
Daniel
113f004230 docs refresh current workflows 2026-05-09 00:40:45 +02:00
Daniel
05f8b00401 docs align memory and audio backup behavior 2026-05-08 23:32:39 +02:00
Daniel
0503a25d0b fix docs toc fallback and remove redundant iifes 2026-05-08 23:08:47 +02:00
Daniel
e84f19b5cb fix docs reader anchor navigation 2026-05-08 22:56:03 +02:00
Daniel
493a1d230c fix docs table of contents scrolling 2026-05-08 22:41:47 +02:00
github-actions[bot]
d108bdc091 Release v7.14.0
Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled
2026-05-08 20:27:26 +00:00
Daniel
416fff624a feat: add patient education handouts 2026-05-08 22:26:53 +02:00
Daniel
1cbe248450 feat: add extension import preview 2026-05-08 22:26:53 +02:00
github-actions[bot]
b7f9da6600 Release v7.13.0
Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled
2026-05-08 18:49:16 +00:00
Daniel
4a9a518134 add extension transfer workflow 2026-05-08 20:48:58 +02:00
Daniel
ba180d7dde fix metrics route normalization 2026-05-08 20:22:14 +02:00
github-actions[bot]
446a3b33d0 Release v7.12.0 2026-05-08 17:20:31 +00:00
Daniel
aeb31a2d15 scrub vendor model docs 2026-05-08 19:15:54 +02:00
Daniel
210ec06fe5 remove vendor model references 2026-05-08 19:14:51 +02:00
Daniel
a6807ef7a4 add hardening and metrics tests 2026-05-08 19:08:29 +02:00
Daniel
a08524d95f harden logging and observability 2026-05-08 19:08:19 +02:00
Daniel
467593a109 extract learning hub ai panel controller 2026-05-08 16:54:48 +02:00
Daniel
92351e1ab5 extract learning hub webdav controller 2026-05-08 16:44:02 +02:00
Daniel
dc62cc880d extract learning hub quiz controller 2026-05-08 16:38:51 +02:00
Daniel
d97d92f8f2 extract learning hub cms controller 2026-05-08 16:23:01 +02:00
Daniel
41598e0fb6 extract learning hub slide controller 2026-05-08 16:04:21 +02:00
Daniel
7858484cb0 refactor learning hub renderers 2026-05-08 15:47:10 +02:00
Daniel
6d13765fc4 improve learning hub generation flow 2026-05-08 09:32:58 +02:00
Daniel
d8e9ba149e allow bedside respiratory without weight 2026-05-08 09:10:33 +02:00
Daniel
d71482037f destroy learning hub row editors 2026-05-08 09:05:13 +02:00
Daniel
de3e1c0a15 remove sutures disclaimer copy 2026-05-08 09:05:13 +02:00
Daniel
2fec33dc4d convert learning hub to module 2026-05-08 09:01:34 +02:00
Daniel
df87d93306 remove legacy well visit schedule data 2026-05-08 08:59:23 +02:00
Daniel
e731780c7b move pe guide data to json 2026-05-08 08:56:21 +02:00
Daniel
300b40b181 set clinical prompt pool target 2026-05-08 08:52:29 +02:00
Daniel
27c3e07d98 fix learning hub model selector 2026-05-08 08:48:02 +02:00
Daniel
9167532a14 use indexed assistant prompt examples 2026-05-08 08:44:34 +02:00
Daniel
db2ecca45d remove top bar model selector 2026-05-08 08:28:54 +02:00
Daniel
6da5565f89 harden audio backup settings rendering 2026-05-08 08:23:38 +02:00
Daniel
04e736eb2e convert speech helpers to modules 2026-05-08 08:09:38 +02:00
Daniel
ea52890908 gate browser speech recognition setting 2026-05-08 08:05:23 +02:00
Daniel
9ca5365daf convert auth helpers to modules 2026-05-08 07:56:41 +02:00
Daniel
bc0b43151d convert settings helpers to modules 2026-05-08 07:54:08 +02:00
Daniel
b6ebca0e6f convert admin docs and drug loader to modules 2026-05-08 07:50:26 +02:00
Daniel
1756043125 test note refine correction policy 2026-05-08 07:46:39 +02:00
Daniel
03ee07f92f clarify template-only AI memory context 2026-05-08 07:38:27 +02:00
Daniel
20fc6798a9 remove browser whisper transcription 2026-05-08 07:33:12 +02:00
Daniel
59fa229b59 convert settings loaders to modules 2026-05-08 07:17:29 +02:00
Daniel
38667608b1 convert encounter note entrypoints to modules 2026-05-08 07:15:11 +02:00
Daniel
02f348ddb3 convert clinical note entrypoints to modules 2026-05-08 07:13:08 +02:00
Daniel
ffffe17b30 tighten chart review source coverage 2026-05-08 07:10:00 +02:00
Daniel
d48a19a891 honor admin default model selections 2026-05-08 06:49:50 +02:00
Daniel
c458c4b4ff convert chart review entrypoint to module 2026-05-08 06:49:46 +02:00
Daniel
e05720083a test clinical note entrypoints 2026-05-08 06:33:34 +02:00
Daniel
90938f8ec1 fix diagram delete confirmation 2026-05-08 06:28:12 +02:00
Daniel
289b0197e7 remove redundant app wrappers 2026-05-08 06:21:55 +02:00
Daniel
0102c9cbc6 convert admin entrypoint to module 2026-05-08 06:19:51 +02:00
Daniel
3be21a137b remove unused admin provider imports 2026-05-08 06:13:11 +02:00
Daniel
548c39a883 normalize LiteLLM embedding requests 2026-05-08 06:11:36 +02:00
Daniel
b40941e4d5 centralize LiteLLM admin headers 2026-05-08 06:08:46 +02:00
Daniel
48ee92fc5d extract STT provider handling 2026-05-08 06:05:39 +02:00
Daniel
d4a3c8fd60 simplify TTS provider handling 2026-05-08 06:00:33 +02:00
Daniel
ca9be8bd85 remove redundant module wrappers 2026-05-08 05:30:32 +02:00
Daniel
34f198edc0 extract well visit schedule data 2026-05-08 05:11:29 +02:00
Daniel
c52f7664b9 deduplicate admin HTML escaping 2026-05-08 04:45:02 +02:00
Daniel
784d1a2e21 consolidate calculator growth helpers 2026-05-08 04:36:39 +02:00
Daniel
9b5f01a19b extract calculator GCS helper 2026-05-08 04:33:46 +02:00
Daniel
ae0ca83ccb extract calculator dosing helpers 2026-05-08 04:32:09 +02:00
Daniel
c2eb550048 ignore vendor model local settings 2026-05-08 04:27:17 +02:00
Daniel
41a05a6b5e extract calculator BP data 2026-05-08 04:24:18 +02:00
Daniel
b75be521ab extract calculator growth data 2026-05-08 04:05:51 +02:00
Daniel
951d102ac0 add BMI calculator combination tests 2026-05-08 03:57:30 +02:00
Daniel
bbd03bfeed avoid hardcoded BMI LMS anchors 2026-05-08 03:56:28 +02:00
Daniel
d79e578863 extract calculator BMI data 2026-05-08 03:52:08 +02:00
Daniel
903cd7eca8 extract AAP bilirubin threshold data 2026-05-08 03:42:51 +02:00
Daniel
d8b8f9bdcb extract bilirubin risk zone data 2026-05-08 03:28:52 +02:00
Daniel
f566455108 remove duplicate bedside handlers from calculators 2026-05-08 03:25:30 +02:00
Daniel
6a081bb53b extract calculator reference data 2026-05-08 03:18:37 +02:00
Daniel
3c4ca84b5f extract calculator vitals data 2026-05-08 03:06:14 +02:00
Daniel
e4ea553bee extract calculator shared modules 2026-05-08 02:58:58 +02:00
Daniel
4a14a71151 split notes frontend modules 2026-05-08 02:44:22 +02:00
Daniel
1ed1a37161 convert notes scripts to modules 2026-05-08 01:37:41 +02:00
Daniel
116bd941e1 extract notes api client 2026-05-08 01:31:06 +02:00
Daniel
326fb726a1 split assistant frontend modules 2026-05-08 01:19:12 +02:00
Daniel
f54b293d39 extract assistant frontend api client 2026-05-08 00:58:44 +02:00
Daniel
7c700ed7f5 refactor clinical assistant backend utilities 2026-05-08 00:55:58 +02:00
Daniel
10f39fc4ff restore assistant streaming 2026-05-08 00:26:10 +02:00
Daniel
a8f364f177 stabilize assistant and add diagrams 2026-05-08 00:23:00 +02:00
Daniel
4f3f5d2f05 fix assistant truncated answer detection 2026-05-07 23:38:35 +02:00
Daniel
49a2bed0d0 fix assistant image follow-up routing 2026-05-07 23:06:13 +02:00
Daniel
569363754f fix assistant stream truncation handling 2026-05-07 23:02:26 +02:00
Daniel
70f6aa4ac6 fix clinical assistant export cleanup 2026-05-07 22:52:46 +02:00
Daniel
c8436d5e4c refactor clinical assistant prompt handling 2026-05-07 22:52:46 +02:00
github-actions[bot]
27ffbfdd77 Release v7.10.1 2026-05-07 15:44:22 +00:00
Daniel
3d7fea8639 fix: simplify assistant lookup wording 2026-05-07 17:44:06 +02:00
github-actions[bot]
d4c85ad638 Release v7.10.0 2026-05-07 15:26:34 +00:00
Daniel
b9270414b0 feat: stream clinical assistant responses 2026-05-07 17:26:10 +02:00
github-actions[bot]
18b219dbff Release v7.9.0 2026-05-07 15:15:48 +00:00
Daniel
21fb631fb5 feat: improve clinical assistant export and citations 2026-05-07 17:15:26 +02:00
github-actions[bot]
5e5c219d33 Release v7.8.0 2026-05-07 02:43:00 +00:00
Daniel
fb339325ee feat: use visual captions in assistant sources 2026-05-07 04:42:32 +02:00
github-actions[bot]
d600c98153 Release v7.7.0 2026-05-07 01:43:57 +00:00
Daniel
198fd8e809 feat: use indexed assistant topic suggestions 2026-05-07 03:43:41 +02:00
github-actions[bot]
566d5c7e8d Release v7.6.1 2026-05-07 01:33:35 +00:00
Daniel
5e926de010 fix: use portrait canvas for assistant flowcharts 2026-05-07 03:33:26 +02:00
github-actions[bot]
f35dd65f2f Release v7.6.0 2026-05-07 01:20:07 +00:00
Daniel
b2f3539c5e feat: diversify assistant starter prompts 2026-05-07 03:19:51 +02:00
github-actions[bot]
ced5d6fc6b Release v7.5.0 2026-05-07 01:01:54 +00:00
Daniel
18109f8bcf feat: map assistant visual source intent 2026-05-07 03:01:45 +02:00
github-actions[bot]
dfdd2c8740 Release v7.4.0 2026-05-07 00:45:32 +00:00
Daniel
f134d0e80c feat: improve clinical assistant visual sources 2026-05-07 02:45:17 +02:00
Daniel
e4a95ad72e refactor: move assistant examples to module 2026-05-06 23:39:41 +02:00
github-actions[bot]
cc50fb8c59 Release v7.3.0 2026-05-06 21:35:12 +00:00
Daniel
098da5d5e3 feat: blend multimodal assistant sources 2026-05-06 23:35:02 +02:00
github-actions[bot]
ed76107a2b Release v7.2.1 2026-05-06 21:32:00 +00:00
Daniel
c39630792b fix: preview generated assistant images inline 2026-05-06 23:31:43 +02:00
github-actions[bot]
6c9ef4cef9 Release v7.2.0 2026-05-06 21:29:30 +00:00
Daniel
605e76b14c feat: add assistant PDF export 2026-05-06 23:29:20 +02:00
github-actions[bot]
d50640ad81 Release v7.1.5 2026-05-06 21:08:07 +00:00
Daniel
6dcfbcd456 fix: enforce Vancouver citation clusters 2026-05-06 23:07:56 +02:00
github-actions[bot]
81884f8a36 Release v7.1.4 2026-05-06 21:04:49 +00:00
Daniel
6c25e8ff05 fix: polish clinical assistant citations 2026-05-06 23:04:40 +02:00
github-actions[bot]
31163e1894 Release v7.1.3 2026-05-06 06:19:42 +00:00
Daniel
96306673a3 fix: show assistant progress and order citations 2026-05-06 08:19:31 +02:00
github-actions[bot]
63fe53a029 Release v7.1.2 2026-05-06 06:04:06 +00:00
Daniel
2310c43aea fix: improve clinical assistant rendering 2026-05-06 08:03:28 +02:00
github-actions[bot]
81c627bec7 Release v7.1.1 2026-05-06 05:42:13 +00:00
Daniel
33ca4e65d9 fix: harden clinical assistant MCP connectivity 2026-05-06 07:42:03 +02:00
Daniel
c8e911cb64 chore: sync package lock 2026-05-06 07:34:10 +02:00
Daniel
96c4565b9c feat: add clinical assistant MCP search
Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled
2026-05-06 07:31:32 +02:00
Daniel
0710c4de8e rebrand to Pediatric Clinical Tools, add diagrams tab, simplify litellm transcribe/tts 2026-05-02 18:23:06 +02:00
Daniel
cf1d88f36b Release v7.0.0 2026-04-28 03:11:55 +02:00
Daniel
b82db99ebc feat(mobile): biometric sign-in (Face ID / Touch ID / fingerprint)
Adds opt-in biometric login to the Capacitor app. Replaces the password
step on subsequent sign-ins; the 2FA step (if any) still applies — by
design, defense in depth.

How it works:
- After a successful password sign-in on a Capacitor build, prompt the
  user to enroll. If they accept, capacitor-native-biometric.setCredentials
  stores the (email, password) pair in the iOS Keychain / Android Keystore
  with biometric-protected access. The local flag ped_bio_enabled=1 is
  set so the next launch knows to probe.
- On the login form, if isNativeApp() + bioStored() + bioAvailable.ok,
  reveal the "Sign in with Face ID / Touch ID / fingerprint" button at
  the top. Label is set from the actual biometryType returned by the
  plugin so users see what their device supports.
- Tap → verifyIdentity (OS prompt) → getCredentials → fill the email +
  password fields → fire the existing form submit so all the regular
  flow runs (turnstile, 2FA prompt, error handling, session storage).
- Explicit logout deletes credentials AND clears the local flag,
  hiding the button on the next visit. Auto-logout (token expiry,
  network) does NOT come through that path, so biometric persists
  across silent session resets.

Storage choice — password not JWT:
- JWTs expire and the storage would constantly need refresh.
- Storing the password lets the standard /api/auth/login flow run,
  which already handles password-rotation (a stale stored password
  just fails 401 → user falls back to typing the new one → re-enrolls).
- The password sits in OS-level secure storage, accessible only after
  successful biometric verification — same security posture as a
  password manager autofill.

Files:
- mobile/package.json: add capacitor-native-biometric@^5.0.0 (Capacitor 6
  compat)
- mobile/android/app/src/main/AndroidManifest.xml: add USE_BIOMETRIC
  uses-permission
- mobile/ios/App/App/Info.plist: add NSFaceIDUsageDescription string
- public/js/auth.js: bioPlugin/bioAvailable/bioStored/bioEnroll/
  bioRetrieve/bioForget helpers; window.PedBio surface; reveal-on-load;
  click handler; post-login enrollment prompt; logout cleanup
- public/index.html: hidden #btn-bio-login + #bio-divider above the
  email field on the login form
- public/css/styles.css: themed gradient button + hover lift
- mobile/README.md: feature list updated

Build steps for Daniel:
  cd mobile && npm install        # picks up capacitor-native-biometric
  npx cap sync                    # ports the plugin into android/ + ios/
  # then build APK / IPA as usual
2026-04-28 03:09:38 +02:00
Daniel
b53aa34248 feat: ED multi-stage UX, extensions polish, docs viewer + application-logic docs
Three concurrent themes from this session:

═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════

UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):

- Each generated stage stays on screen as its own editable card with
  its own embedded "Don't Miss" panel. No more single rolling note
  element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
  before any operation (advance, finalize, persist) so inline edits
  flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
  background after Add-more before generation; "Stage N" with gray
  after generation. Fixes the bug where the badge flipped to Stage 2
  the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
  1. edConsolidate (new prompt) → polished single final note that
     integrates every stage chronologically (HPI / ROS / PE / ED Course /
     A&P with disposition).
  2. edFinalize (rewritten with full inline 2023 AMA E/M element
     rubric — problems / data / risk definitions, level mapping with
     concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
  Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
  finalized} so resume re-renders the full state.

Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.

Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).

═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════

Multiple iterations based on Daniel's feedback:

- Layout: align-items:flex-start so action buttons stay pinned top-right
  when long numbers wrap (was align-items:center → buttons drifted into
  the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
  so long numbers wrap within their column instead of pushing under the
  buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
  at-a-glance type signal (replacing an earlier 3px left stripe that
  Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
  -0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
  list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
  55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
  at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.

Files: public/js/extensions.js, public/css/styles.css.

═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════

Document moves (preserving git history via git mv):
  BROWSER_WHISPER_SETUP.md          → docs/browser-whisper-setup.md
  BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
  DEVELOPER_GUIDE.md                → docs/developer-guide-extended.md
  EMBEDDINGS_SETUP.md               → docs/embeddings-setup.md
  FEATURES_EXPLAINED.md             → docs/features-explained.md
  IMPROVEMENTS.md                   → docs/improvements.md
  OPENID_SETUP.md                   → docs/openid-setup.md
  TRANSCRIPTION_OPTIONS.md          → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.

New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.

  docs/logic/README.md                — index + recommended reading order
  docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
                                         load, backend route convention,
                                         schema, encryption, deployment
  docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
  docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
                                         pocket + calculators + PE Guide
                                         + suture selector
  docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
                                         admin panel + Learning Hub
                                         (Quiz engine logic at sub-detail
                                         only — TODO follow-up)
  docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
                                         prompts, voice/STT, helper trio
  docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
                                         session's worked example)

Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
  the tree as JSON; /file?path=X validates path stays inside docs/ and
  renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
  revealed in auth.js when user.role==='admin' (same pattern as the
  existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
  with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
  activation, renders collapsible folders, persists expanded state and
  last-opened path via UIState. Server-rendered HTML so no client
  markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
  scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
  with router.use(authMiddleware) at /api accidentally 401s every other
  /api/* path (caught and fixed during testing — /api/health was 401'ing).

Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).

═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════

- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
  tracking + progress dashboard) is covered at the architectural level
  in docs/logic/auth-admin-learning.md but not drilled into the quiz
  data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
  the consolidated finalNote in the error payload, but client doesn't
  surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
2026-04-28 03:09:38 +02:00
Daniel
4f129b24e1 feat: don't-miss tooltip after notes + bedside suture selector
#2 — Don't-miss tooltip (encounters HPI + sick visit, max 5)
- New POST /api/dont-miss returning {points: [{point, why}]} capped at 5
  (cap defended both in the prompt and server-side .slice(0,5))
- New dontMissTooltip prompt in prompts.js
- New suggestDontMiss() helper in app.js mirroring suggestBillingCodes;
  inserts an orange-bordered card next to the note output, silent on empty
- Wired into liveEncounter.js (encounter HPI) and sickVisit.js. Not added
  to wellvisit/soap/hospital/chart per spec.

#1 — Bedside suture selector
- New ES module public/js/bedside/sutures.js (~300L) following the
  burns.js pattern: site × age × tension × cosmetic × contamination ×
  hours-since-injury → material, size, technique, removal day range,
  glue/Steri-strip alternative, warnings, tetanus reminder.
- 15 anatomic sites covered (face, eyelid, lip vermilion, intraoral,
  ear, scalp, neck, trunk, upper/lower ext, hand, foot, joint surface,
  genitalia, fingertip).
- Bites: cat/human → don't-close-primarily warning; dog bite to hand →
  loose-approximation note. Heavy contamination → delayed primary
  closure. >12h non-face/scalp → judgment call note.
- Removal days shown as ranges (3–5, 7–10, 10–14) per source norms,
  not single midpoints.
- Subungual hematoma trephination guidance corrected: any painful
  hematoma with intact nail and no displaced fracture (especially if
  25–50% or more), per current UpToDate guidance.
- Inline citation: Roberts & Hedges 7e (2019), Fleisher & Ludwig 8e,
  AAP Section on EM, UpToDate (Pope JV).
- Pill registered in sub-nav SECTIONS + bedside/index.js. Persists
  active state via existing UIState helper.

All 46 tests pass.
2026-04-28 03:09:38 +02:00
Daniel
67b7667e04 feat: ED encounters + notes model selector; remove AI corrections; fix notes framing
Four changes batched:

1. ED Encounters tab (new) — multi-stage emergency note with don't-miss
   tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters
   (generate per-stage + finalize MDM), new ed-encounters.js owning all
   client logic, new ed-encounter.html component, new template_ed memory
   category. Persists draft to localStorage every keystroke and to
   saved_encounters on stage advance. encounters.js touched only to
   register the new tab in sessionStorage restore + tabMap (save and
   idempotency code untouched).

2. Notes model selector — /notes/from-voice now accepts a client-supplied
   model (validated by the existing callAI allow-list); falls back to the
   admin default. Added <select class="tab-model-select"> to notes.html
   so the existing app.js populator handles options + default.

3. Remove AI-learning-from-corrections — deleted correctionTracker.js,
   POST /memories/correction, the corrections branch in
   /memories/context, the settings UI section, the FAQ entry, and all
   dead trackAIOutput/saveCorrection guards in callers. Legacy
   correction_* DB rows are filtered (NOT LIKE) rather than dropped, so
   no destructive migration.

4. Fix notes AI framing — /notes/from-voice prompt no longer assumes
   "physician dictation". Plain notes (shopping lists, reminders,
   ideas) now match the dictation tone instead of being forced into
   clinical structure.

All 46 tests pass.
2026-04-28 03:09:38 +02:00
github-actions[bot]
2872f1d063 Release v6.53.2 2026-04-24 23:44:32 +00:00
Daniel
dccb3b4bcb fix(notes): Stop button never transcribed — silent-cancel branch was always taken
Root cause for "note recording, not working nor going into textbox / no
progress etc". The click handler was wired with:

  recStop.addEventListener('click', stopRecording);

addEventListener invokes the handler with the click Event as the first
argument. My stopRecording(silent) signature uses that first arg as a
boolean — and a non-null Event is truthy, so every Stop click hit the
silent-cancel branch (mic off, stream closed, UI back to idle, but no
.then-chain fires, no transcribeAudio, no /api/notes/from-voice).

Symptom matched exactly: click Dictate → record → click Stop → mic
indicator vanishes → editor stays empty → no Network activity for
/api/notes/from-voice. Server logs from earlier transcribes were all
from the Encounter / Dictation tabs (untouched flows), never Notes.

Fix: wrap all three voice-button handlers so the Event isn't passed
through. Only stopRecording cared about the first arg, but wrap all
three for symmetry and so future signature changes don't reintroduce
the same class of bug.

  recStart → function() { startRecording(); }
  recPause → function() { togglePauseRecording(); }
  recStop  → function() { stopRecording(false); }

SW cache bumped to pedscribe-v12-notes7.

About the model: /api/notes/from-voice already reads `models.default`
from app_settings (whatever you picked in Admin Panel → Models →
Default Model). Confirmed in your DB it's set to
"openrouter-vendor-model-sonnet-4.6". No new admin UI added.
2026-04-25 01:44:23 +02:00
Daniel
7ed8a2365b chore: ignore .codex workspace marker (accidentally committed in 66d4f1f) 2026-04-25 01:35:27 +02:00
github-actions[bot]
9106d85f98 Release v6.53.1 2026-04-24 23:35:20 +00:00
Daniel
df1a6613dd fix(notes): voice → AI note now produces HTML the editor can render
Symptom Daniel reported: "note recording, not working nor going into
textbox". Root cause was on the server side — /api/notes/from-voice
asked the AI for HTML in its prompt, but real-world models return
markdown ~25% of the time. Tiptap's setContent only renders HTML;
markdown comes through as literal text or a partial render, looking
like the textbox didn't fill.

Server (src/routes/notes.js):
  • New toHtmlBody() helper. If the AI returned real HTML (any
    block tag), pass through. Otherwise run through `marked` so
    markdown becomes <p>/<h*>/<strong>/etc.
  • Strips ```json / ```html code fences before JSON parsing.
  • Stricter JSON-recovery: only accepts {title|body} parsed shape;
    falls back to wrapping the AI's full reply via toHtmlBody().
  • Final guard: if body would be empty after sanitisation, wrap
    the raw transcript so the user can at least edit it manually.

Client (public/js/notes.js):
  • applyGeneratedNote prefers _editor.commands.setContent over a
    full remount — avoids the toolbar-reattach flicker + the brief
    window where the body looked empty.
  • Logs to console when the editor target is missing or Tiptap
    setContent throws, so a future regression is greppable.

Plus the two infra fixes Daniel approved earlier in the same
session — keeping them in this commit since they're already
deployed and tested:

  • src/db/database.js: cleanup interval handle exposed; server
    shutdown now clearInterval()s it before pool.end(). Removes
    the SIGTERM → 9-second-hang → Docker SIGKILL race.
  • src/routes/audioBackups.js: switch multer.memoryStorage() to
    diskStorage with cleanup. 10 concurrent 25 MB uploads no
    longer pin 250 MB of RAM. Identical user-visible perf since
    upload is wire-bound.

New dep: marked@latest (used server-side only in toHtmlBody).
2026-04-25 01:35:10 +02:00
github-actions[bot]
fd5108e7b3 Release v6.53.0 2026-04-24 23:02:58 +00:00
Daniel
9961688bfa feat(notes): trash + restore + DOMPurify sanitizer + 9 contract tests
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.

Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).

Server (src/routes/notes.js):
  GET    /api/notes              now filters deleted_at IS NULL
  GET    /api/notes/trash        new — list trashed items, newest-
                                  deleted first
  DELETE /api/notes/:id          now soft-deletes (sets deleted_at)
  DELETE /api/notes/:id?hard=1   hard-delete, only allowed on items
                                  already in trash (UI bug can't
                                  erase an active note)
  POST   /api/notes/:id/restore  pull a note out of trash
  POST   /api/notes/trash/empty  hard-delete every trashed note for
                                  the user

Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
  • Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
  • Trash tab shows deleted-at timestamps, Restore + delete-forever
    per row, Empty-trash button at the bottom
  • Active list and trash count refresh in parallel after every
    save / delete / restore
  • Delete button in the editor now says "Move to trash" and uses
    the showConfirm helper (no native dialogs)

Sanitizer swap (public/js/notes.js):
  Replaced the homegrown allowlist walker with DOMPurify (already
  loaded from cdnjs in index.html, used by learningHub.js too).
  Custom HTML sanitizers historically have bypasses; DOMPurify is
  the right primitive.

Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
       as new dev deps):
  9 contract tests covering script-tag stripping, inline event
  handlers, img onerror, iframe/object, style attributes, every
  preserved tag in the allowlist, javascript: URI rejection,
  null/undefined input, and nested-script-inside-paragraph. Total
  test count: 37 → 46 passing.

SW cache bumped to pedscribe-v12-notes5.
2026-04-25 01:02:49 +02:00
github-actions[bot]
8c9c03c656 Release v6.52.1 2026-04-24 11:22:36 +00:00
Daniel
2a4269d496 fix(notes): 5 bugs found in post-commit review
1. flushAutosave dropped brand-new unsaved notes.
   The guard was `if (_dirty && _activeId != null)` but _activeId is
   null until the first POST succeeds. So tap-New-Note → type → tap-
   Back went through flushAutosave → skipped → closeToList → lost
   the typed content. saveNote() already handles isNew via POST;
   the guard only needs to check _dirty.

2. beforeunload sendBeacon used wrong HTTP method.
   navigator.sendBeacon is POST-only by spec, but my code used it to
   target PUT /api/notes/:id. Beacons silently hit a 404 route and
   the note didn't save. For brand-new notes the URL resolved to
   /api/notes/undefined. Replaced with fetch({ keepalive: true })
   which browsers queue + ship even across unload, supports PUT,
   and handles POST for new notes correctly.

3. Mobile: empty-state card stacked below the sidebar in list view.
   The mobile media-query hid .notes-reader + .notes-editor when
   data-view="list" but didn't include .notes-empty-state, so the
   big "new note" card appeared below the list on phones. Added
   the missing selector.

4. Delete fallback used window.confirm.
   The `else if (window.confirm(...))` branch in deleteActive was
   a rule violation even as a fallback — feedback_no_native_dialogs
   says no native dialogs anywhere in frontend. Dropped it;
   showConfirm is loaded by app.js before any tab activates so the
   fallback path is unreachable in practice. Also upgraded the
   confirm call to use the danger + confirmText options so the
   modal renders red "Delete" button instead of neutral "Confirm".

5. Recorder kept running after switching tabs or notes.
   Dictating, then tapping another clinical-tools tab (or opening
   another note) left the MediaRecorder active and the mic stream
   open — wasted battery, privacy surprise. Added stopRecording
   (silent=true) to the non-notes branch of tabChanged, to
   startCreate, and to openReader so any in-flight recording
   cancels cleanly when the user moves on.

Minor:
  • Dead "'Saving…' : 'Saving…'" ternary in saveNote simplified.

SW cache bumped to pedscribe-v12-notes4.
2026-04-24 13:22:26 +02:00
github-actions[bot]
a25c36c875 Release v6.52.0 2026-04-24 10:49:31 +00:00
Daniel
2d89f295dd feat(notes): strip explanatory UI copy
Removed the "your note saves as you type" / "encrypted at rest" /
tip-list filler that telegraphs AI-generated code. UI now reads
the way a real app reads — names where names go, no paragraphs
explaining what a button does.

  • Module header: "Notes" — removed the paragraph tagline.
  • Empty state: icon + "New note" button only — removed the
    "Hello 👋" heading, the description, and the three-tip list.
  • Placeholders: "Title" / "Search" — removed the "…" ellipses
    and the "Note title" / "Search notes" verbosity.
  • Status + meta: emptied the "New note — will save automatically
    once you type" meta string and the "This note is empty. Tap
    Edit to add content" reader placeholder.
  • Empty-list copy trimmed to just "No matches" when the search
    filter is active.

SW cache bumped to pedscribe-v12-notes3.
2026-04-24 12:49:22 +02:00
github-actions[bot]
fd658739c3 Release v6.51.0 2026-04-24 10:45:40 +00:00
Daniel
9bb4879be1 feat(notes): read-mode default + Edit toggle + autosave + mobile layout
Four changes landing together so the UX flows properly.

1. Fix voice-generation model error.
   /api/notes/from-voice was passing `req.body.model` (undefined)
   through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
   for LiteLLM deployments with no explicit default) → `model=""` →
   LiteLLM 400 "Invalid model name". Server now reads the admin-
   configured `models.default` setting, falls back to the
   LITELLM_DEFAULT_MODEL env var, and only passes a model if one
   resolved. Empty string never reaches the provider.

2. Read mode as the default post-save.
   Opening an existing note or saving a new one now lands on a
   clean, read-only rendering of the body (sanitized HTML with an
   allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
   Click the "Edit" button to switch to the Tiptap editor. Matches
   the mental model of "notes are documents I review, not drafts I'm
   always editing."

3. Autosave during edit.
   Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
   In-flight saves coalesce: if the user types more while a save is
   in progress, a follow-up fires right after it lands so the last
   keystroke never gets stranded. beforeunload uses navigator.
   sendBeacon to best-effort flush on tab close. The manual Save
   button is kept as a "save + switch to reader" shortcut. Ctrl/
   Cmd+S still works in the editor.

4. Mobile layout.
   The two-pane layout collapses to a single-pane view below 900px,
   driven by a data-view attribute on .notes-layout. Back buttons
   appear in the reader + editor heads on mobile to return to the
   list. Desktop layout unchanged (sidebar + right pane always
   visible).

Also:
  • CSS specificity fix — .hidden{display:none} was losing to
    .notes-rec-indicator{display:inline-flex} on source order, so
    the "Recording" indicator was showing when idle. Added explicit
    .notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
    overrides at higher specificity.
  • Reader-body sanitizer — allowlist of safe tags + attributes;
    <a> links get target=_blank + rel=noopener.
  • SW cache bumped to pedscribe-v12-notes2 so clients pick up the
    new module / component / CSS.
2026-04-24 12:45:30 +02:00
github-actions[bot]
415f67d432 Release v6.50.0 2026-04-24 04:18:56 +00:00
Daniel
3c0de624fc feat(notes): personal notes with rich-text editor + voice dictation
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.

Backend:
  migrations/…_add-personal-notes.js  — personal_notes table
    (id, user_id → users ON DELETE CASCADE, title, body, created_at,
    updated_at) with indexes on user_id + (user_id, updated_at).
  src/routes/notes.js — CRUD + one AI endpoint:
    GET    /api/notes           list, newest-updated first
    GET    /api/notes/:id       fetch one
    POST   /api/notes           create (title + body required)
    PUT    /api/notes/:id       update
    DELETE /api/notes/:id       remove
    POST   /api/notes/from-voice  transcript → { title, body }
                                   via callAI (admin-controlled
                                   provider — never selectable by
                                   the clinician).
    Body + title encrypted at rest via the same cryptoUtil used for
    user_memories; 500-note per-user cap; 200-char title / 50 KB
    body limits.

Frontend:
  public/components/notes.html — empty-state card ("Hello 👋"),
    sidebar list with search, editor head with voice-bar (Dictate /
    Pause / Resume / Stop + live timer + pulse indicator), Tiptap
    body, metadata footer. Uses existing .tp-* toolbar classes.
  public/js/notes.js — lazy-init on first tab activation; Tiptap
    editor built from window.Tiptap (same bundle the Content
    Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
    uses app.js's AudioRecorder + transcribeAudio so the STT
    pipeline is shared. On stop → transcribe → /api/notes/from-
    voice → drop the AI-structured title + body into the editor;
    clinician reviews then saves.
  public/css/styles.css — 70 lines of .notes-* styles (card
    layout, warm empty-state with gradient icon + tips, pulse
    animation for the recording indicator, focus states, hover
    nudges).
  public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
    notes1 so clients pick up the new module/component.

Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
2026-04-24 06:18:41 +02:00
Daniel
d71714b65d test(lint): static reference linter — catches dead-code + orphan refs
You were right that Playwright has been catching the easy bugs while
the high-signal bugs (lightbox stranded after the Bedside reorg, PVC
clinically wrong, prod not rebuilt) all had to be caught by you as
the user. Adding a static linter so the class of bug that produced
the lightbox regression fails CI next time instead of the app.

scripts/lint-references.js walks public/js and validates every
getElementById('X') and querySelector('#X') resolves to an id that
is defined SOMEWHERE in the repo — either a static HTML attribute,
a .id = 'X' assignment, or an id="X" substring inside a JS template
string. It also walks HTML for asset references (data-img-src,
<img src>, <audio src>, <link href>) and verifies each root-absolute
path maps to a real file on disk.

Running it on the current tree surfaced two real problems:

1. shadess.js:917 reached for #wv-note-transcript when collecting
   refine source context. The actual id is #wv-transcript (no -note-
   infix — the transcript element is shared across well-visit sub-
   panels). The bug meant a generated well-visit note's Refine call
   silently missed the original transcript as source context; the AI
   still "worked" but with less signal and no error. Fixed.

2. public/js/adminMilestones.js (221 lines) referenced 17 ids that
   were removed a year+ ago in commit 3173ce6 ("Remove milestone
   admin UI, add CMS content refresh button"). That commit dropped
   the HTML but forgot the JS, which has been dead-loaded on every
   page view since. All its addEventListener calls are guarded with
   optional chaining, so nothing errored — just silent cruft.
   Deleted the file and dropped the <script defer> tag from
   index.html.

scripts/e2e.sh runs the linter as a preflight before the Playwright
container starts; a broken reference now fails the suite before any
test even boots.

Allowlist is kept small and prefix-based for the handful of id families
that are built dynamically by JS (bedside em-* sections, BP chart
elements, etc.). When a new component is added, its ids get picked up
automatically by the repo-wide collect() pass; the allowlist rarely
needs to grow.

Suite: 294 passed / 0 failed.
2026-04-23 18:58:38 +02:00
Daniel
abc1a64363 test(e2e): +44 tests — sick visit, hospital course, auth screen,
session persistence, per-tab model selector

Brings detailed coverage to sections that were previously only smoke-
tested:

- sickvisit-workflow.spec.js: generate → mocked note, refine bar
  round-trip, load popover, New button clears demographics + transcript.
- hospitalcourse-workflow.spec.js: fill H&P → generate → mocked
  narrative renders via /api/generate-hospital-course, refine updates
  text, load popover open/close.
- auth-screen.spec.js: unauthenticated landing visible, login form
  structure, forgot-password swap + return, register link is
  intentionally display:none on this instance (pinned), register form
  DOM still wired correctly if the link is manually unhidden, reg-
  password has minlength=8 + type=password.
- session-persistence.spec.js: clear cookie simulates logout (UI login
  is gated by Turnstile which can't be completed in the e2e container);
  re-login via loginAs restores the last tab + sub-pill via localStorage.
- model-selector.spec.js: tab-model-select dropdowns render with >0
  options across 7 tabs.

Fixture corrections:
- /api/sick-visit/note and /api/well-visit/note were not being mocked
  at all — the wrong /api/generate-sick-visit pattern was intercepting
  nothing, so tests fell through to the real AI backend. Both now have
  correct patterns and response shapes (sick-visit returns `note`,
  well-visit note returns `note`).
- /api/generate-hospital-course response key updated from `narrative`
  to `hospitalCourse` to match what the frontend actually reads.

wellvisit-workflow: the Visit Note test now asserts a concrete
waitForResponse on /api/well-visit/note + text render, instead of the
previous "hit either endpoint" fallback.

Suite: 294 passed / 0 failed in 5m30s.
2026-04-23 18:58:38 +02:00
Daniel
9c53e19d29 fix(pe-guide): remove PVC entry from cardiac sounds library
PVCs are an ECG / rhythm finding, not a routine auscultation sample —
what you actually hear on the stethoscope is an irregular rhythm with a
compensatory pause, which depends on the underlying rate and is not
teachable from a canned audio clip. The card was also backed by a
synthesized sound, not a real recording. Removing both the card and
the pvc.ogg asset.
2026-04-23 18:58:38 +02:00
Daniel
1b209b5eb7 fix(nav): move image lightbox to index.html so NRP + seizure pathways
open from the Bedside tab

The #img-lightbox overlay markup was sitting at the bottom of
calculators.html. Before the reorg it was fine — the calculators tab
was always the only home for bedside, so by the time a user clicked
the seizure or NRP pathway button the lightbox HTML was guaranteed to
be in the DOM. After promoting Bedside to its own tab, a user can
open Bedside -> Seizures without having visited Calculators first;
the lightbox JS's getElementById('img-lightbox') then returns null
and the click silently no-ops.

Moved the overlay markup to the bottom of index.html so it exists
from page load regardless of which tab has been lazy-loaded. The e2e
harness gets a duplicate copy so the existing lightbox smoke test
keeps working.

Added a regression test for NRP (bedside-smoke.spec.js:141) to catch
any future breakage — the prior seizure-only test didn't exercise the
second pathway button and so the neonatal/NRP path had never been
clicked in CI.

Suite: 252 passed / 0 failed.
2026-04-23 18:58:38 +02:00
Daniel
2e517a67a9 feat(ui-state): persist sub-pill selections across reload + sign-out
Tab-level choice (ped_last_tab) already survived sign-out/in via
localStorage, but sub-pill and sub-tab selections inside a loaded tab
lived only in memory — they reset to defaults after a reload or
browser restart. Now the following are persisted under the ped_ui/
namespace:

  - Calculators nav pill (BP / BMI / GCS / …)
  - Bedside sub-pill (neonatal / airway / …)
  - Well Visit sub-tab (byvisit / milestones / shadess / note)
  - Physical Exam Guide age group + system

Implementation:
- Added public/js/ui-state.js — a ~30-line window.UIState wrapper
  around localStorage with a ped_ui/ prefix and try/catch around both
  read and write (Safari private mode + quota errors silently no-op).
- Each tab's click handler now also calls UIState.set; each tab's
  init path calls UIState.get and replays the saved value through
  the same function a click would call — so there is exactly one
  code path for "show this selection", whether it came from the user
  or from a restore. For Bedside, the restore additionally listens
  for tabChanged so the lazy-loaded HTML is guaranteed to exist by
  the time we re-activate the pill.

Tests:
- e2e/tests/ui-state-persistence.spec.js — 5 specs × 2 viewports =
  10 tests. Each clicks the feature, reloads the page, and asserts
  the same pill / subtab / dropdown value is still active. Catches
  any future regression in the persistence wiring.
- e2e/tests/soap-hospital-workflow.spec.js — fills SOAP transcript,
  generates via mocked AI, clears, opens/closes load popovers; also
  smoke-tests the Hospital Course save-bar.

Suite: 250 passed / 0 failed (+ 20 over the last run).
2026-04-23 18:58:38 +02:00
Daniel
8d97b13bf7 feat(nav): promote Bedside to its own tab; reorganise sidebar sections
Bedside is now a top-level tab instead of a sub-pill inside Calculators —
it's the highest-traffic emergency reference in the app and deserves a
one-click entry. Same DOM structure and JS modules; only the container
moved.

Sidebar reorg:
- Notes: Hospital Course, Chart Review, SOAP Note, Well Visit, Sick Visit
  (Well Visit + Sick Visit relocated from the old "Pediatric" group —
  they're clinical note workflows, not pure reference tools).
- Section rename: "Pediatric" → "Clinical Tools". The section now holds
  Vaccine Schedule, Catch-Up Schedule, Physical Exam Guide, Bedside,
  Calculators, Pagers & Extensions, Learning Hub, Content Manager — mix
  of reference tables + active calculators + utilities, none strictly
  pediatric. "Clinical Tools" reads naturally for the combined set.

Layout changes:
- calculators.html: dropped 480 lines of bedside panel + the Bedside
  nav-pill. The shared age→weight estimator moved with it.
- bedside.html: new component file, contains the full bedside card +
  the age→weight estimator prepended.
- index.html: added bedside-tab section, sidebar restructured.
- e2e-harness.html: renders calculators + bedside side-by-side (not the
  old calc-tab→bedside-pill dance) so the bedside smoke suite still
  works without auth. e2e-bootstrap fetches both with a cache-buster.
- bedside-smoke.spec.js: removed the now-obsolete calc-nav-pill click
  from each test.

Tab persistence is unchanged — ped_last_tab already survives sign-out,
and the lazy-load cache keeps sub-pill state across navigation within a
session. Persistence across browser restart for sub-pills is a separate
follow-up.
2026-04-23 18:58:38 +02:00
Daniel
3884bf673b test(e2e): +120 tests across 8 new specs; baseline fixes
8 new spec files covering sections previously only smoke-tested:
- ai-endpoints-contract.spec.js  — hits 8 real AI endpoints via request
  context and fails if the response leaks TypeError / ReferenceError /
  'Cannot read properties of undefined' / 'is not defined' / 'is not a
  function'. This is the class of bug that shipped the PE-narrative
  regression to prod because every page-level mock prevented the real
  handler from running.
- encounter-workflow.spec.js — generate HPI, refine, clear transcript.
- encounter-save-load.spec.js — save draft, load popover, repopulate.
- wellvisit-workflow.spec.js — byvisit, milestones, SSHADESS (12+
  reveal), visit note.
- vaxschedule-content.spec.js — schedule + catch-up panels populate
  beyond "Loading".
- chart-review-workflow.spec.js — generate + load popover.
- learning-tab.spec.js — search filter, category pills, feed.
- settings-faq-dictation.spec.js — voice/password/2FA/Nextcloud
  sections, FAQ expand/collapse, dictation generate flow.

Baseline fixes:
- Added CORS_ORIGINS + API_RATE_LIMIT_MAX env overrides so the e2e
  container accepts the browser's Origin header and can absorb the
  full suite's API traffic without tripping the 200/min guard.
- Server's /api/ rate limit is now configurable via
  API_RATE_LIMIT_MAX (default stays 200).
- extensions-crud: replaced native page.on('dialog') listeners with
  #confirm-modal-ok clicks (we moved off native confirm()).
- pe-guide-smoke + extensions-crud: mobile viewport opens the hamburger
  before clicking sidebar tabs.
- fixtures.js: /api/refine mock uses 'refined' (real API shape), not
  'content'. /api/chart-review replaced with /api/generate-chart-review.

Suite: 230 passed / 0 failed in 4m36s.
2026-04-23 18:58:38 +02:00
Daniel
015eaf9945 feat(pe-guide): real lung recordings for normal/rhonchi/pleural-rub;
drop grunting + dead synth module

Three lung sounds that were Web-Audio syntheses now play real clinical
recordings sourced from the HLS-CMDS manikin dataset (MIT license):

- normal-vesicular.ogg
- rhonchi.ogg
- pleural-rub.ogg

Dropped expiratory grunting from the library entirely — no
openly-licensed clinical recording located across Wikimedia, Freesound
CC0, SPRSound, Pixabay, Internet Archive, or Littmann/EasyAuscultation
(all proprietary). Card is honest by omission rather than hiding a
synth behind a Play-only UI.

All seven remaining entries now use the same native <audio controls>
player (pause, seek, volume). The synth fallback branch in
renderSoundCard, the stopAllExcept synth reset loop, the script tag,
and the entire public/js/respiratorySounds.js (332 lines of Web Audio)
are removed since nothing references them anymore.
2026-04-23 18:58:38 +02:00
Daniel
02e7281e52 fix(pe-guide): correct route imports (prod 500 regression)
The route destructured PROMPTS/INJECTION_GUARD/wrapUserText from
../utils/prompts, but PROMPTS is the module's default export and the
other two live in ../utils/promptSafe. All three resolved to undefined,
so every /api/generate-pe-narrative request crashed with
"Cannot read properties of undefined (reading 'peGuideNarrative')"
and the client surfaced "Request failed" for PE Guide narrative and
summary generation. Split the require into the two-line form that
every other AI route already uses.
2026-04-23 18:58:38 +02:00
github-actions[bot]
fda5b12143 Release v6.20.0 2026-04-22 20:57:21 +00:00
Daniel
8cfa07dcf5 feat(pe-guide): unified single-play + remove badges; fix(e2e): shared fixture + raised login limit
User-visible changes:
- Removed the REAL / SYNTH badge from each sound card. User feedback:
  "ridiculous". Cards now just show the sound title + description +
  player, no distinction beyond the player type (native <audio controls>
  for recordings, play/stop/progress bar for synth).
- Removed the "real recordings; synth labelled SYNTH" subtitle from
  the sounds library header.
- Single-playback policy across the whole PE Guide: when any sound
  starts (audio or synth), every other playing sound stops. Covers:
  audio → audio (pause the previous), audio → synth, synth → audio,
  synth → synth. Listeners attach on each .pe-audio 'play' event and
  on every synth play-button click.

E2E infrastructure fixes (for the test failures we hit):
- auth-gated-smoke.spec.js now imports test + loginAs from the shared
  fixtures.js so the token cache is unified across every spec. Without
  this, each spec file's module-scoped _tokenCache multiplied logins
  and hit the 10/15min rate limit.
- server.js: /api/auth/login rate limit is now configurable via
  LOGIN_RATE_LIMIT_MAX env var (default 10, prod unchanged).
- docker-compose.e2e.yml: LOGIN_RATE_LIMIT_MAX="500" so Playwright's
  two-project (chromium + mobile-chrome) multi-worker runs can do
  their logins without tripping the cap. Prod container unaffected.
- fixtures.js console.error allowlist expanded to suppress known
  non-bugs: Cross-Origin-Opener-Policy warnings on http:// e2e
  server, transient 401/403/404/503 resource loads, ERR_BLOCKED_BY_CLIENT.
2026-04-22 22:57:11 +02:00
github-actions[bot]
b80a91e40a Release v6.19.0 2026-04-22 19:51:04 +00:00
Daniel
2913b09abd feat(pe-guide): 7 cardiac sounds, APTM image full-width + tap-to-zoom, synth stop/progress
Three user-flagged fixes:

1. Cardiac sounds library expanded from 3 to 7 (from Wikimedia Commons
   heart-sounds + heart-murmurs subcategories). All real recordings:
   - Normal (61 bpm)                 [existing]
   - Infant heartbeat                [new — pediatric reference]
   - VSD                              [existing]
   - Mitral valve prolapse            [existing]
   - Still's murmur in a toddler      [new — classic innocent murmur]
   - Functional murmur (adult female) [new — benign flow murmur]
   - PVCs                             [new — arrhythmia]
   All with native <audio controls> (play/pause/seek/elapsed/total
   work on desktop AND mobile for free).

2. APTM diagram is now full-width on every device, no longer sharing
   row space with the legend on mobile:
   - Image always on its own row, max-width:420px, centered
   - Wrapped in an <a href target="_blank"> — tap/click opens the PNG
     full-size in a new tab where browser pinch-zoom works natively
   - "Tap to open full-size" hint line under the image
   - Legend below uses auto-fit minmax(min(100%,260px),1fr) so it stacks
     on narrow viewports without cramping

3. Synth sounds (rhonchi, pleural rub, grunting, normal vesicular — all
   Web-Audio-API generated, no native controls) now have:
   - Play button → kicks off playback and hides itself
   - Stop button appears, lets user terminate early
   - Progress bar animates over 3.2 s, resets at end
   - Only one synth sound plays at a time (clicking another stops the
     previous)
2026-04-22 21:50:54 +02:00
github-actions[bot]
bf62d15ad6 Release v6.18.0 2026-04-22 19:10:45 +00:00
Daniel
9bbfb4ce83 feat(pe-guide): native audio controls + cardiac sounds library + mobile layout
Three user-flagged issues fixed together:

1. Mixed real/synth audio was labelled only "synthesised samples" — misleading
   since wheeze, stridor, fine+coarse crackles are real Wikimedia recordings.
   Now each sound card has a REAL or SYNTH badge and the library header
   reads: "real recordings where available; synthesised approximations
   labelled SYNTH".

2. No murmur sounds in the CV section. Added a Cardiac sounds library
   between the APTM diagram and the innocent-murmur panel:
     - Normal heart sounds (S1, S2) — 61 bpm reference
     - Ventricular septal defect (VSD) — harsh holosystolic at LLSB
     - Mitral valve prolapse (MVP) — mid-systolic click + late systolic
   All 3 are real recordings from Wikimedia Commons.

3. No pause / stop / duration controls. Replaced the synth-only play
   button with a native <audio controls> element for every real
   recording — gives play, pause, seek, elapsed/total time, volume
   for free on desktop AND mobile (browser-native, accessibility-
   compliant, consistent with platform conventions). Synth sounds
   (rhonchi, pleural rub, grunting, normal vesicular) keep the one-
   shot play button since they\'re Web-Audio-API generated and don\'t
   support seeking.

Mobile layout:
- The APTM diagram + legend was hard-coded 2-col (minmax(280px,1fr) 1fr)
  which could overflow narrow screens. Switched to repeat(auto-fit,
  minmax(280px,1fr)) — stacks to 1-col below ~580 px viewport.
- Refactored sound-library + APTM render into helpers (renderSoundCard,
  renderSoundsLibrary, TWO_COL_GRID constant) to reduce duplication.
2026-04-22 21:10:36 +02:00
github-actions[bot]
7d860c5287 Release v6.17.1 2026-04-22 19:04:54 +00:00
Daniel
b7a2e15107 fix(entrypoint): docker-compose env overrides win over OpenBao-fetched values
The previous entrypoint unconditionally exported every key from
kv/ped-ai/prod. This broke the e2e container, which needs
TURNSTILE_SECRET_KEY="" and SMTP_HOST="" set via docker-compose
environment block so login works without bot challenge and register
auto-verifies. OpenBao's real values were overriding those empties,
re-enabling Turnstile and email on e2e.

Fix: before the OpenBao fetch, snapshot every env var name already
defined (env_file + environment: block). During the export loop,
skip any OpenBao key that's already in the snapshot. Docker-compose
wins, OpenBao fills in the rest.

Impact:
- Prod container: no change (env_file only has OPENBAO_* bootstrap
  vars, which aren't in the KV payload anyway)
- E2e container: TURNSTILE_SECRET_KEY="" and SMTP_HOST="" preserved
  even when the image is rebuilt from the current source tree
- Any future per-container override via docker-compose environment:
  block just works

Log line now reports counts: "applied N secrets; M already set by
docker (kept override)".
2026-04-22 21:04:46 +02:00
Daniel
abb67bd03a test(e2e): PE Guide smoke — 8 tests covering all systems + age-group coverage
Uses the shared fixture so pageerror + console.error fail the test —
would catch any regression of the bug-class that shipped the SSO
ReferenceError. All tests log in as the seeded e2e user and drive the
real UI.

Coverage:
- tab loads with empty-state before age selected
- MSK (default) renders with scales + steps
- switch to Neuro shows MRC scale + teaching pearl
- Respiratory shows 8-sound library with play buttons + RR scale
- Cardiovascular shows APTM image (naturalWidth > 0 — actual network
  fetch confirmed), all 5 landmark letters, innocent-murmur panel,
  7 "S" criteria footer
- parametrised: every age group × {resp, cv} must NOT show "no data",
  must have ≥ 1 step (catches the regression Daniel just flagged)
- step toggle cycles Normal → Abnormal → Skip with visual state change
  and note-field show/hide
- grading scales <details> is collapsible and expands on click
2026-04-22 21:03:13 +02:00
github-actions[bot]
8cabe7da4b Release v6.17.0 2026-04-22 19:00:54 +00:00
Daniel
8be268df6a feat(pe-guide): real audio, innocent-murmur panel, APTM image, all-ages resp+CV
Four changes rolled together:

1. REAL AUDIO from Wikimedia Commons (CC BY-SA 3.0, attribution to follow
   in privacy policy per Daniel). Embedded in /public/audio/ — synthesis
   stays as fallback for sounds not available from Wikimedia.
   respiratorySounds.js now tries the real OGG first; falls back to Web
   Audio synthesis if file missing.

2. REAL APTM IMAGE from Daniel's Nextcloud share, placed at
   /public/images/pe-guide/aptm.png (134 KB PNG). Replaces the inline SVG.
   Kept the side legend with A/P/E/T/M colour-coded points.

3. INNOCENT MURMUR REFERENCE PANEL below the APTM diagram with the 5
   classic innocent murmurs (Still's, pulmonary flow, venous hum, carotid
   bruit, PPS) — age, location, character, confirming maneuver — plus
   the 7 "S" criteria summary.

4. ALL-AGE RESP + CV DATA. Before: only adolescent. Now every age group
   has age-appropriate resp + cv content (newborn through adolescent).
   Newborn: Silverman, pre/postductal sats, duct-dependent lesion screen.
   Infant: bronchiolitis, CHF diaphoresis, VSD, early CHD.
   Toddler: croup/FB/epiglottitis, innocent murmur peak age.
   Preschool + school-age: adult-pattern transition, sports screening.
2026-04-22 21:00:42 +02:00
github-actions[bot]
1aa785068b Release v6.16.0 2026-04-22 18:15:16 +00:00
Daniel
316a5e0338 feat(pe-guide): Cardiovascular system with APTM auscultation SVG
Fourth PE system added. Adolescent cardiovascular exam at the same
teaching-focused depth as respiratory/neuro: five components with
significance + pearls + detailed step methods + watch-for blocks.

APTM auscultation diagram (new, inline SVG, no image file):
- Stylised anterior chest with sternum, clavicles, ICS level lines,
  left mid-clavicular line
- Five colour-coded landmarks:
   A  Aortic    — 2nd ICS right sternal border
   P  Pulmonic  — 2nd ICS left sternal border
   E  Erb's pt  — 3rd ICS left sternal border
   T  Tricuspid — 4th ICS left sternal border
   M  Mitral    — 5th ICS mid-clavicular (apex)
- Side legend: location + what to listen for at each point
- Patient's-left / patient's-right labels to prevent mirror-image
  confusion

CV-specific grading scales (3 new entries in SCALES):
- Murmur grade Levine 1–6
- Pulse amplitude 0–4+
- Capillary refill time thresholds

CV components:
1. Inspection (general appearance, central/peripheral cyanosis,
   clubbing with Schamroth sign, precordial bulge, visible apex, JVP)
2. Palpation (apex position + character, parasternal heave, thrills
   at all 5 points, peripheral pulses upper + lower, radio-femoral
   delay for coarctation)
3. Auscultation — approach (positioning, diaphragm vs bell, systematic
   walk through all 5 points, left lateral decub for MS, leaning
   forward for AR)
4. Auscultation — heart sounds + murmurs (S1, S2 split, S3/S4 gallops,
   murmur characterisation by timing/location/radiation/character,
   Levine grading, dynamic maneuvers, innocent-murmur "7 S" pearl)
5. Peripheral vascular (four-limb BP for coarctation, radio-femoral
   delay, bounding pulse differential)

UI wiring:
- renderSystem() emits APTM diagram card at the top of cv system,
  before scales. Two-column layout: SVG on left, legend on right.
- accentMap/iconMap/labelMap extended with cv = rose accent,
  heart-pulse icon, "Cardiovascular" label
- New sub-tab pill in pe-guide.html
2026-04-22 20:15:08 +02:00
github-actions[bot]
8efc4e9a56 Release v6.15.0 2026-04-22 18:10:04 +00:00
Daniel
83a78fa8cd feat(pe-guide): Respiratory system with synthesized sounds library
Third PE system added. Adolescent respiratory fully fleshed out with
the same teaching-focused depth as neuro: overview, grading scales,
per-component significance + pearls, detailed step methods with HOW
and NORMAL labels, and a watch-for red-flag block.

New respiratorySounds.js uses the Web Audio API to synthesize 8 classic
breath sounds on demand — no network, no audio files, no licensing:
  - Normal vesicular
  - Wheeze (two-partial + vibrato, filtered sawtooth)
  - Stridor (inspiratory, bandpass-filtered sawtooth sweep)
  - Fine crackles (dense brief high-freq noise bursts, late inspiration)
  - Coarse crackles (sparser, longer, lower-freq bursts)
  - Rhonchi (low-pitched warbled sawtooth, expiratory)
  - Pleural friction rub (bandpass noise, biphasic)
  - Expiratory grunting (square-wave short grunts)

Sounds are synthesised approximations intended to teach the pattern
(what makes a wheeze a wheeze vs a stridor). Labelled as such in the UI.
Controls: one play at a time, auto-stop ~3s.

Respiratory-specific grading scales:
  - RR by age (WHO tachypnea cutoffs)
  - Pulse ox (SpO2) with hypoxemia thresholds
  - Silverman–Andersen (neonatal retractions, 0–10)
  - Westley croup severity score

Components in adolescent respiratory:
  1. Inspection (observation-first — RR, pattern, WOB, audible sounds,
     chest shape, colour, clubbing with Schamroth sign)
  2. Palpation (trachea, expansion symmetry, tactile fremitus,
     tenderness, subcutaneous emphysema)
  3. Percussion (technique + systematic zones + cardiac/hepatic
     dullness + diaphragmatic excursion)
  4. Auscultation — normal breath sounds (vesicular, bronchovesicular,
     bronchial) with systematic side-to-side comparison
  5. Auscultation — adventitious sounds with per-sound listen buttons
     linking directly to the sounds library
  6. Special maneuvers — bronchophony, egophony, whispered pectoriloquy

Older age groups (newborn through school-age) will get their own resp
blocks incrementally — v1 focused on adolescent for the quality bar.

UI: new sub-tab pill "Respiratory" with lung icon, sky-blue accent.
renderSystem refactored to use accent/icon maps instead of per-system
if/else — scales to future systems (cardiovascular coming next).
2026-04-22 20:09:54 +02:00
github-actions[bot]
4e1a870fe2 Release v6.14.0 2026-04-22 17:35:12 +00:00
Daniel
33bfc0bfbc feat(pe-guide): teaching-focused redesign — grading scales, pearls, significance
User feedback: the exam steps were too generic (e.g. "Shoulder abduction
— 5/5 bilaterally" never explained what 5/5 means or HOW to test it).
The guide needs to serve as a teaching tool, not just a checkbox list.

Three changes:

1. GRADING SCALES reference card (new). Collapsible panel at the top of
   each system showing the relevant scales:
   - Neuro: MRC strength (0-5), DTR (0-4+), Plantar response
   - MSK: Scoliometer ATR, Beighton hypermobility score
   Each scale shows the grade AND its clinical meaning in a compact
   table. No more orphan "5/5 bilaterally" without definition.

2. Per-component SIGNIFICANCE + PEARL fields (optional). Adolescent
   neuro components enriched with:
   - Significance: one-line clinical relevance (what this component is
     actually for — what pathologies it detects)
   - Teaching pearl: a Hutchison/Bates/Nelson-style tip that helps the
     learner see past the mechanics to the reasoning
   Visually distinct — pearl gets a warm amber accent, significance is
   a crosshair icon under the name.

3. Method strings REWRITTEN for every adolescent strength step. Before:
   "Shoulder abduction — 5/5 bilaterally". After: "Patient abducts both
   arms to 90°. Examiner pushes down on each arm just above the elbow
   while patient resists. Compare sides. — Holds against full resistance
   — MRC 5/5 bilaterally". Same treatment for all 14 strength steps,
   all 8 DTR steps, and tone/pronator-drift.

UI redesign:
- Accent bars on cards (cyan for MSK, purple for neuro) for visual
  anchor
- Numbered step circles instead of "1." prefix
- HOW / NORMAL label badges on each step
- Watch-for block with red left-border for red-flag grouping
- System-level header with icon (bone for MSK, brain for neuro)

Other age groups (newborn through school-age) keep the old data shape
(steps without pearls) — they still render correctly, just without the
pearl/significance blocks. Enriching them is an incremental follow-up.
2026-04-22 19:35:03 +02:00
github-actions[bot]
31507a4f09 Release v6.13.1 2026-04-22 17:30:03 +00:00
Daniel
b37c565cf0 fix(ui): replace native alert/confirm with showConfirm/showToast helpers
Daniel flagged the native browser confirm() dialogs in Extensions as ugly
and incompatible with the app's design. There was also a stray alert()
in calculators.js resus-meds weight validation.

Replaced:
- extensions.js: confirmDelete → showConfirm(..., {confirmText: 'Move to trash'})
- extensions.js: confirmPurge  → showConfirm(..., {danger: true, confirmText: 'Delete permanently'})
- calculators.js:2060 alert() → showToast(..., 'error')

All three helpers (showConfirm, showToast) are already defined as globals
in public/js/app.js. The design already had a modal — I should have used
it from the start.

Audit confirmation: `grep -rnE '\b(alert|confirm|prompt)\s*\(' public/`
now returns only comment references and the showConfirm definition
itself. No native dialogs remain anywhere in the frontend.

Playwright test updated to click the in-app modal's #confirm-modal-ok
and #confirm-modal-cancel buttons instead of intercepting page.on('dialog').
2026-04-22 19:29:53 +02:00
Daniel
8ea79c7f30 test(e2e): shared fixture with pageerror + console-error guards + Extensions CRUD suite
Two additions:

1. e2e/fixtures.js — shared test infrastructure
   - Custom `test` extending @playwright/test with two auto-fixtures on
     every page: page.on('pageerror') and page.on('console') of type
     'error'. Any uncaught JS error fails the test. This is the SSO-
     bug-class safety net: if we'd had this earlier, the silent
     admin.js ReferenceError would have failed CI instead of shipping.
   - `authedPage` fixture — logs in via API, injects session cookie,
     provides pre-authed page ready to drive.
   - `mockAI(page)` helper — intercepts generate-* and transcribe
     endpoints with canned JSON responses. Enables fast deterministic
     CI runs. Opt-out via E2E_USE_REAL_AI=1 to hit real LiteLLM.
   - Console-error allowlist for known noise (favicon 404, lazy-loaded
     Whisper models, etc.).

2. e2e/tests/extensions-crud.spec.js — 11 tests covering the new
   Pagers & Extensions feature end-to-end:
   - empty state, add extension, add pager (correct grouping)
   - edit persists, search by location + by number
   - soft-delete with confirm → moves to trash
   - restore from trash → reappears in active
   - purge from trash → permanent
   - cancel dialog keeps item, cancel form keeps nothing, validation
     on required fields

Known follow-up: the e2e container (pediatric-ai-scribe-e2e) is still
on the pre-entrypoint image from before the OpenBao migration, so it
doesn't have the Extensions routes yet. Rebuilding it needs a small
entrypoint enhancement to honor docker-compose-level env overrides
vs OpenBao-fetched values (e.g. TURNSTILE_SECRET_KEY="" for the e2e
instance). That's separate work — this commit just lays in the tests.
2026-04-22 19:27:05 +02:00
github-actions[bot]
651f799c17 Release v6.13.0 2026-04-22 16:54:37 +00:00
Daniel
d859c8c5a9 feat(extensions): personal Pagers & Extensions directory with soft-delete
New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.

Data:
- New table user_phone_extensions (id, user_id, location, name, number,
  type CHECK (extension|pager), notes, trashed_at, timestamps).
  Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.

API (all user-scoped, all params validated):
- GET    /api/extensions?trash=1&q=text  — list active or trash, optional search
- POST   /api/extensions                  — create
- PUT    /api/extensions/:id              — update (requires all three core fields)
- DELETE /api/extensions/:id              — soft-delete (sets trashed_at)
- POST   /api/extensions/:id/restore      — un-trash
- DELETE /api/extensions/:id/purge        — hard-delete (only if trashed)

All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.

UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
  locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
  edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
2026-04-22 18:54:26 +02:00
Daniel
917d4f8115 chore(pe-guide): restore source-citation comment block
Daniel clarified the earlier feedback: not "never cite in code", but
"ask before citing". He confirmed this block should stay.
2026-04-22 18:08:19 +02:00
Daniel
ee4940e0a6 chore(pe-guide): remove source-citation comment block
Daniel prefers no citations embedded in the code. Keeps the data-model
comment which is load-bearing for future edits.
2026-04-22 17:49:36 +02:00
github-actions[bot]
c35b05fc5a Release v6.12.0 2026-04-22 15:15:37 +00:00
Daniel
c13ba04955 feat(pe-guide): step-by-step OSCE checklist, Bates/Nelson/Hutchison sourced
Replaces the generic one-line-per-component format with a step-level
checklist. Each exam component now contains 3–13 discrete steps, each
with its own Normal/Abnormal/Skip toggle and optional abnormal note.
Physician ticks the exam off step-by-step; report generation
summarises at the component level but knows exactly which steps were
performed.

Example — previously the adolescent "Cranial nerves (II–XII)" was a
single row: "How to perform: Full formal adult-pattern exam. Expected:
All cranial nerves intact." That's unhelpful. Now it's 14 discrete
steps: CN I, CN II acuity, CN II fields, CN II fundoscopy, CN II/III
pupils, CN III/IV/VI EOM, CN V sensation V1/V2/V3, CN V motor, CN V
corneal, CN VII forehead/eye-close/smile/puff, CN VIII, CN IX/X, CN
XI, CN XII — each with specific method and expected finding. Same
depth for MSK: scoliosis = 5 discrete steps (standing inspection,
Adam forward-bend, rib-hump check, scoliometer, plumb-line), joint
stability = 8 named tests (Lachman, anterior drawer, varus/valgus,
McMurray, apprehension, Neer/Hawkins, anterior drawer ankle, talar
tilt), Beighton = 5 per-joint measurements, etc.

Sources cited in code header: Bates' Guide 13th ed, Nelson Textbook
22nd ed, Hutchison's Clinical Methods 25th ed, Fenichel Clinical
Pediatric Neurology 8th ed.

Backend route accepts the flat step array (grouped by component on
the server), passes structured text to the AI with methods and
expected findings per step. Prompts updated to summarise at the
component level rather than step-by-step, so output is clinically
readable.

Scope: MSK + Neuro × 6 age groups (newborn, infant, toddler, preschool,
school-age, adolescent). More systems follow the same pattern —
append to PE_DATA.
2026-04-22 17:15:27 +02:00
github-actions[bot]
22dd5cc8f4 Release v6.11.0 2026-04-22 14:52:24 +00:00
Daniel
ac5292b015 feat(pe-guide): Physical Exam Guide tab — OSCE reference + narrative report
New top-level tab (positioned after Catch-Up Schedule) combining two
functions:
1. Study reference — for each (age group, system) shows OSCE-style
   components with technique, expected normal finding, and abnormal-
   feature watch-list.
2. Documentation generator — physician marks each component
   Normal / Abnormal (with free-text detail) / Skip; AI produces a
   two-section report (Technique + Findings), narrative or structured
   list format.

Scope v1: MSK + Neuro × 6 age groups (newborn, infant, toddler,
preschool, school-age, adolescent). More systems can be added to the
embedded PE_DATA in peGuide.js without route changes.

Files:
- src/routes/peGuide.js      — POST /api/generate-pe-narrative (mirrors
                                milestone-narrative pattern: AppRole-level
                                injection guard, clinical audit category,
                                PHI redaction upstream already in place)
- src/utils/prompts.js       — peGuideNarrative + peGuideList prompts,
                                structured two-section output
- public/components/pe-guide.html — demographics bar + sub-pills + cards
- public/js/peGuide.js       — embedded PE_DATA (all clinical content),
                                render + state + AI call
- public/index.html          — tab button, section, script include
- server.js                  — mount route at /api

No schema change. No PHI stored — findings live in memory only, exported
via existing copy/read-aloud/Nextcloud actions.
2026-04-22 16:52:13 +02:00
github-actions[bot]
1bb8918b46 Release v6.10.3 2026-04-22 11:11:43 +00:00
Daniel
749aa23e87 fix(bilirubin-ui): clarify neurotoxicity-risk-factor label + expandable AAP 2022 list
The dropdown was labeled just "Risk Factors" with option "None (lower risk)"
— ambiguous because AAP 2022 uses "risk factors" in two distinct senses:
(a) risk factors for developing hyperbilirubinemia (screening-only, do not
change thresholds) and (b) neurotoxicity risk factors (do change thresholds).
Only (b) belongs on the threshold nomogram, and a clinician glancing at the
form could easily pick wrong.

Changes:
- Label: "Neurotoxicity risk factors"
- Options: "Absent" / "Present (any one qualifies)" — removes the misleading
  "None (lower risk)" phrasing (no-risk curve actually has HIGHER thresholds)
- Expandable details listing the 6 specific AAP 2022 neurotoxicity risk
  factors (isoimmune hemolysis, G6PD, other hemolysis, sepsis, albumin <3.0,
  clinical instability <24h) with explicit note that GA <38w is handled by
  the per-week curve, not by this checkbox — prevents double-counting.

No data changes. Cite: Kemper et al., Pediatrics 2022;150(3):e2022058859, Box 2.
2026-04-22 13:11:34 +02:00
github-actions[bot]
2709595793 Release v6.10.2 2026-04-22 10:41:25 +00:00
Daniel
456101a28a fix(bilirubin): AAP 2022 per-week thresholds, exact match to peditools
The previous tables grouped all ≥38-week infants into one table (using
38w values) and 36-37 into another. AAP 2022 actually has separate
phototherapy curves per completed week for 35, 36, 37, 38, 39, 40+.

Worst real-world impact: a 40-week infant at 72h of life got the 38w
threshold (18.8 mg/dL) instead of the correct 40w threshold
(19.8 mg/dL) — a 1.0 mg/dL error at exactly the clinical decision point.
Borderline infants could be started on phototherapy unnecessarily, or
the reverse (miss a true threshold) depending on the direction.

Replaced the block with 18 distinct per-week tables extracted directly
from peditools.org/bili2022 API:
  - Phototherapy no-risk: 35, 36, 37, 38, 39, 40+ (all differ)
  - Phototherapy with-risk: 35, 36, 37, 38+ (38-41 identical)
  - Exchange (both risk states): 35, 36, 37, 38+ (38-41 identical)

Selection logic updated to map gaNum → correct table. HTML dropdown
label clarified to "completed weeks" with a one-line note that days
don't change the curve per AAP 2022.

Validation: exhaustive roundtrip — 7 GA weeks × 85 hours × 2 risk
profiles = 1190 cases, all match peditools within 0.01 mg/dL (zero
mismatches). See commit message footer.
2026-04-22 12:41:15 +02:00
github-actions[bot]
c4da879336 Release v6.10.1 2026-04-22 03:52:10 +00:00
Daniel
508530eda8 fix(neonatal): replace rounded Fenton 2013 LMS with peditools-validated values
The previous LMS table (L rounded to 2 decimals, ~0 near term) underfit
the skew of Fenton 2013 and drifted ~0.05 z-score units from peditools
and Epic at term. Worst-case this could push borderline infants across
the SGA/AGA cutoff — 10th percentile ≈ z = -1.28, a 0.05-SD drift is
enough to flip the classification.

New LMS: empirically fit against 6 probe weights per week at
peditools.org/fenton2013 (widely-used Fenton 2013 calculator). Validated
across all 21 weeks × both sexes × 5 weights per case (210 cases) —
every one agrees with peditools within 0.01 z-score units, mean
difference 0.002.

Example — 40 5/7 wk male, 3070 g:
  BEFORE: z = -1.38  (matched only one of three external sources)
  AFTER:  z = -1.42  (matches Epic -1.43 and third-source -1.44)
  Peditools at integer 40w: z = -1.10 (exact match with new table at
    integer-week input)

LMS fit RMSE < 0.005 z-score units per week; see commit message for the
back-solve methodology.
2026-04-22 05:52:00 +02:00
github-actions[bot]
887ef04de7 Release v6.10.0 2026-04-22 01:59:19 +00:00
Daniel
42e59fa958 feat(security): OpenBao-backed secret injection via entrypoint
Adds an optional secret-fetch step at container boot. When OPENBAO_ADDR,
OPENBAO_ROLE_ID, and OPENBAO_SECRET_ID are set, the entrypoint
authenticates to OpenBao via AppRole, pulls kv/ped-ai/prod, and exports
each key as a process env var before exec'ing node. When OPENBAO_ADDR
is unset the entrypoint is a no-op — the legacy .env flow continues to
work unchanged (e2e container, local dev, rollback).

Changes:
- docker-entrypoint.sh: new — AppRole login + KV fetch + env inject +
  exec. Fails fast on missing/invalid creds; unsets bootstrap vars
  before launching node so they don't linger in the process env.
- Dockerfile: multi-stage copy of /bin/bao from openbao/openbao:2.5.3
  (multi-arch handled automatically by buildx manifest-list resolution).
  Adds jq for JSON parsing. Wires ENTRYPOINT to the script; CMD
  remains ["node", "server.js"].
- .env.example: documents the three vault-bootstrap variables at the
  top and notes that everything below is vault-sourced when OPENBAO_ADDR
  is set.

Rollout is two-phase for safety: rebuild image with unchanged .env
(proves no regression in legacy mode), then add the three OpenBao vars
and restart to cut over to vault-sourced secrets. Rollback at any point
is blanking OPENBAO_ADDR in .env + restart.
2026-04-22 03:59:10 +02:00
github-actions[bot]
f1802d66f4 Release v6.9.0 2026-04-21 23:01:09 +00:00
Daniel
250646110f fix(security): timing-safe forgot-password + redact log file writer
Two independent PHI-leak hardenings folded together:

1. forgot-password timing oracle
   The hit path previously did SELECT + token gen + UPDATE + SMTP send
   before responding; the miss path returned after the SELECT. An
   attacker could distinguish registered emails by response latency
   (SMTP RTT is hundreds of ms). Response is now sent immediately after
   Turnstile, with the DB and email work fired-and-forgotten in a
   background async block. Hit and miss take identical wall-clock time.

   Also hardened req.body.email to tolerate missing/non-string input
   instead of throwing 500.

2. logger.file redaction
   logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log
   without going through redact(). Current callers are metadata-only and
   safe, but any future caller writing logger.error('boom', req.body)
   would silently drop PHI to disk. Route both message and optional data
   through redact() — same helper the audit path already uses. Benign
   startup messages pass through unchanged; SSN/phone/email/DOB patterns
   are tokenised, long note-body-shaped text is truncated.
2026-04-22 01:01:00 +02:00
Daniel
6dc7870a1b feat(security): AES-256-GCM at-rest encryption for encounters and memories
Extends the existing crypto helper (already used for audio backups and the
Nextcloud token) to cover every column that can hold PHI:

- saved_encounters.transcript, .generated_note, .partial_data
- user_memories.content (templates + Dragon-style corrections)
- user_memories.name (auto-derived from original snippet on corrections,
  so effectively PHI)

Reads decrypt transparently. Legacy plaintext rows continue to work —
decryptString passes non-enc1: values through unchanged — so no migration
is required; rows re-encrypt on their next save.

The encounters list query previously used LEFT(transcript, 200) for a
preview. With ciphertext that slice is meaningless, so the route now
fetches the full columns, decrypts in Node, then slices. At 7-day auto-
delete the row count is bounded and the cost is a handful of GCM
decrypts per list call.

user_memories ORDER BY moved from (category, name) to (category, id)
since SQL can no longer order on encrypted names.

Closes the HHS breach-notification safe-harbor gap on at-rest PHI.
2026-04-22 01:01:00 +02:00
github-actions[bot]
cc035e7d8d Release v6.8.0 2026-04-21 20:20:21 +00:00
Daniel
07d7b42efc feat(wellvisit): add Expected Reflexes section to By Visit Age
Adds an age-appropriate reflex reference after the Expected Growth /
Feeding block for each well-visit age. Each entry shows the reflex
name, an expected-status chip (Present / Fading / Integrated /
Up-going / Down-going), and a short clinical note covering how to
elicit it, when it should fade, and what abnormal persistence means.

Covers primitive reflexes for newborn-6mo (rooting, sucking, Moro,
palmar/plantar grasp, tonic neck, stepping, Galant, tongue thrust,
Babinski), the transition at 6-18mo (protective extension, parachute,
plantar-response switch), and adult-pattern DTRs/frontal-release
screening from age 2 through 21.
2026-04-21 22:20:07 +02:00
Daniel
f39f906fa5 fix(admin): restore SSO settings loading on admin panel
loadOidcConfig() was called from the first IIFE in admin.js but declared
in the second IIFE. Function declarations don't cross IIFE boundaries,
so the call threw ReferenceError and left the SSO form empty with
"Disabled" selected — even when SSO was configured and working. Moved
the call into the tabChanged handler of the IIFE where the function
lives.
2026-04-21 22:19:50 +02:00
Daniel
d8504392a5 docs: add Testing section (unit + Playwright e2e) 2026-04-21 02:14:54 +02:00
Daniel
29f37b331e test(e2e): stage 2 — auth-gated pages (11 tabs × 2 viewports, 22 tests)
Adds a second containerized instance of the app with Turnstile + SMTP
disabled so Playwright can log in without a bot challenge.

- docker-compose.e2e.yml: pediatric-ai-scribe-e2e on port 3553. Shares
  postgres + pgdata with main so seeded test users (*@ped-ai.test) persist.
- Test user: e2e-user@ped-ai.test (created once via /api/auth/register
  against the e2e container — SMTP is off so register auto-verifies).
- Tests log in once per worker via /api/auth/login (module-scoped token
  cache) then inject the ped_auth cookie into each test's browser context.
  This avoids the 10-per-15-min login rate-limit.
- Mobile viewport opens the sidebar via #btn-menu-toggle before clicking
  tab buttons (which are hidden behind the hamburger <=768px).

Coverage: encounter, wellvisit, chart, vaxschedule, catchup, learning,
dictation, settings, calculators, faq + landing-page-after-login. Each
test clicks the tab, waits for the lazy component to render (>100 chars),
and asserts a known anchor string is present.

Total suite: 128 tests passing (53 desktop + 53 mobile Bedside/top-calcs +
11 desktop + 11 mobile auth-gated).
2026-04-21 01:48:17 +02:00
Daniel
146ac73da2 test(e2e): run full suite at mobile viewport too (Pixel 5)
Adds a second Playwright project so every calculator test runs at both
Desktop Chrome and Pixel 5 (~375 px). Catches mobile layout regressions
automatically. 106 tests passing (53 × 2 viewports).
2026-04-20 23:21:32 +02:00
github-actions[bot]
ed8948e539 Release v6.7.0 2026-04-20 21:20:01 +00:00
Daniel
fe7b3687ee test(e2e): add 27 Playwright smoke tests for top-row calculator tabs
Covers BP / BMI / BSA / Weight-Based Dosing / Growth / Bilirubin / Vitals /
Resus Meds / GCS / Equipment. Each tab gets: panel-loads test + one
full input→calculate→result flow. Extras for BP (Clear), Dose (max cap),
Growth (sub-pill), Bili (Bhutani sub-pill), GCS (motor change, infant switch),
Equipment (empty re-select hides).

All 53 e2e tests pass (26 Bedside + 27 top-calculators).
2026-04-20 23:19:50 +02:00
Daniel
7e7d469172 fix: scope CORS middleware to /api so static assets aren't rejected
ES module script tags (<script type="module">) always send an Origin
header on fetch, even for same-origin requests. The global cors()
middleware was rejecting /js/bedside/index.js with 500 in the e2e
harness because the container's internal origin
(http://pediatric-ai-scribe:3000) is not in APP_URL/CORS_ORIGINS.

Production was unaffected (real users hit APP_URL, which is allowed),
but the fix is architecturally correct either way: CORS belongs on
the API boundary, not on static file serving. All protected routes
are under /api/*.

Unblocks the bedside smoke suite — now 26/26 green.
2026-04-20 23:19:50 +02:00
Daniel
2742a2a130 feat: C — extract Bedside reference into ES modules
Split the Bedside clinical reference section out of calculators.js
(~1220 lines) into 20 focused ES modules under public/js/bedside/.
Each module owns one clinical topic (cardiac, seizure, sepsis, burns,
etc.) and exports an init() wired up by bedside/index.js. Shared
helpers live in shared.js and also set window._EM for back-compat
with the 4 remaining call sites in calculators.js.

Load order: classic defer calculators.js first, then module script
bedside/index.js. Handlers bind at DOM-ready; runtime _EM lookups
resolve after both are evaluated.

Makes bedside content editable per-section, shrinks calculators.js
from 4111 to 2891 lines, and keeps the existing Playwright smoke
suite (e2e/tests/bedside-smoke.spec.js) as the behavioral contract.
2026-04-20 23:19:50 +02:00
github-actions[bot]
28118c4493 Release v6.6.0 2026-04-20 02:23:33 +00:00
Daniel
f26687df50 feat: B — extract drug data to public/data/drugs.json (schema v1.0)
Moved 43 weight-based drug entries out of calculators.js string literals
into a structured JSON reference. Renderers now iterate JSON → S.drugRow.

Sections extracted (43 drugs):
- anaphylaxis (7), sedation (11), agitation (11), antiemetics (7), seizure (7)

Out of scope: seizure refractory drips (compound strings), NRP, antimicrobial
empiric regimens (already structured), airway/cardiac/tox/trauma/respiratory
/sepsis/burns sections (fine as-is or no drugs).

New:
- public/data/drugs.json — { version, last_reviewed, sections.<key>.drugs[] }
- public/js/drugs-loader.js — fetches JSON on boot, exposes window._DRUGS +
  window._DRUGS_READY Promise. Non-fatal: each calc function has a matching
  *_FALLBACK constant so a 404 on drugs.json doesn't break anything.

Schema:
- dose_mg_per_kg | dose_mg_per_kg_low/high (for ranges) | max_mg | unit |
  route | notes | source | optional special-cases (weight bands, age-dep text)

Added one unit test asserting drugs.json loads + has all 5 sections with
non-empty drugs arrays. 37/37 unit tests + 26/26 Playwright e2e tests pass.
2026-04-20 04:23:24 +02:00
github-actions[bot]
9603a8fcf8 Release v6.5.0 2026-04-20 01:51:30 +00:00
Daniel
abdbaa3507 feat: A1+A2 — Playwright smoke suite + index.html mtime-based caching
Safety net for upcoming refactors:
- 26 Playwright smoke tests via @playwright/test 1.50.0 in an official
  Playwright container (no host Node needed). Covers every Bedside sub-pill,
  the age→weight estimator, dose calculators (seizure/sepsis/anaphylaxis/
  burns/airway), and interactive widgets (lightbox, vent SVG).
- `npm run e2e` wrapper runs tests inside mcr.microsoft.com/playwright:v1.50.0-noble
  on the ped-ai_default Docker network so no host port mapping is needed.
- public/e2e-harness.html + public/js/e2e-bootstrap.js load the calculators
  component without the SPA auth wall (scripts external to satisfy CSP).

Server:
- server.js now re-reads public/index.html on mtime change instead of
  caching at boot. Fixes the "edit HTML, restart container" friction.
- CSP upgradeInsecureRequests disabled in helmet config; Caddy still
  enforces HTTPS at the reverse-proxy layer in production.
2026-04-20 03:51:22 +02:00
github-actions[bot]
30cfc9700b Release v6.4.0 2026-04-20 00:49:55 +00:00
Daniel
ea3a3533e6 feat: Bedside clinical reference module + age→weight estimator + dose-math unit tests
Calculators:
- Bedside tab consolidating emergency protocols (Neonatal+Apgar+NRP, Airway/RSI,
  Cardiac Arrest/PALS, Respiratory, O2 & Ventilation, Status Epilepticus,
  Sepsis & Fever with PECARN/Aronson/Rochester/Step-by-Step, Anaphylaxis,
  Procedural Sedation, Agitation, Antiemetics, Antimicrobials, Burns with
  Lund-Browder body-parts TBSA + Parkland, Toxicology, Trauma).
- Global age→weight estimator at top of Calculators tab (APLS + Best Guess).
- Pressure-time waveform SVG teaching graphic for Ventilation.
- Algorithm image lightbox (fullscreen, Esc/tap-to-close).
- Every weight-based dose shows mg/kg inline for clinician verification.
- Drug tables wrapped in overflow-x:auto for mobile.

Infrastructure:
- Pure dose math extracted to public/js/calc-math.js (dual-export Node+browser).
- 36 unit tests in test/calc-math.test.js via node:test (zero new deps).
- "npm test" added to package.json.
2026-04-20 02:49:42 +02:00
github-actions[bot]
936ecbd113 Release v6.3.1 2026-04-19 19:26:56 +00:00
Daniel
e5f7167b8d fix: auth/API logging to Loki, TTS voice auto-detection, STT ElevenLabs support
- Add logger.audit/access calls to auth route (login, login_failed,
  login_blocked, register, password_changed, 2fa_backup_code_used,
  2fa_backup_codes_regenerated) — these previously only wrote to DB
  via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
  ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
  (Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
  to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
  redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
  isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
  endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
2026-04-19 21:26:49 +02:00
github-actions[bot]
b09276faf5 Release v6.3.0 2026-04-19 00:17:21 +00:00
Daniel
2f6e5a7d8f feat: neonatal calculator, DOCX/PPTX/ODT/EPUB support, gateway-agnostic URL helper, TTS/STT fixes
- Add neonatal assessment calculator: GA classification (extremely preterm through
  post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
  birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
  generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
  LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
  gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
  auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
  discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
2026-04-19 02:17:06 +02:00
Daniel
857ed341f5 docs: rewrite architecture, authentication, configuration, deployment, ai-providers, speech, database, learning-hub, migrations, developer-guide for public audience
- Drop first/second-person voice; reference-style prose throughout
- Remove stale information; align with current code (argon2id primary, hybrid cookie/Bearer auth, sliding 24h idle, AES-256-GCM PHI at rest, backup codes, node-pg-migrate, collation-drift guard, multi-arch Docker, auto-version pipeline)
- Preserve all technical accuracy and code examples
- Remove any remaining references to separate PedsHub Quiz app
- Keep consistent tone across files (tables + code blocks, imperatives where needed)
- api-reference.md and developer-guide.md route tables expanded to reflect current routes (billing, sessions)
2026-04-15 00:26:38 +02:00
Daniel
957ba531bc docs: clarify com.pedshub.scribe is the Android applicationId, not a quiz-app ref 2026-04-15 00:13:17 +02:00
github-actions[bot]
895caa2093 Release v6.2.1 2026-04-14 22:10:45 +00:00
Daniel
231a86509f fix: verify auto-version → android + docker pipeline end-to-end 2026-04-15 00:10:35 +02:00
Daniel
7ad0c84789 ci: multi-arch Docker via native runners + Node 24 opt-in + PAT for tag-trigger chain
docker-publish.yml:
  - Rewrote as matrix build + manifest merge.
  - amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm (free for public
    repos). No QEMU — argon2 and every other native dep compile on
    their target CPU, no more SIGILL / exit 132.
  - Per-arch GHA cache scopes so builds don't thrash each other.
  - Final step merges both digests under one tag (vX.Y.Z + latest),
    publishing a real multi-arch manifest. `docker pull` from either
    arch gets the right variant automatically.

auto-version.yml, version-bump.yml:
  - Checkout now uses `secrets.RELEASE_PAT || secrets.GITHUB_TOKEN`.
    With RELEASE_PAT set, the tag push this workflow does DOES
    trigger downstream (android-release, docker-publish). Without
    it, falls back to GITHUB_TOKEN (no downstream trigger, what we
    have today).

All workflows (auto-version, version-bump, android-release,
docker-publish):
  - Added FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' so actions
    still on Node 20 runtime (checkout/cache/setup-*) opt in to
    Node 24 early. GitHub makes Node 24 default 2026-06-02 and
    removes Node 20 2026-09-16.

To finish the chain (one-time user step): create a fine-grained
PAT with "Contents: Read and write" on this repo and add as
RELEASE_PAT secret. After that `feat:` / `fix:` commits auto-tag
AND auto-build with zero manual intervention.
2026-04-15 00:05:34 +02:00
Daniel
fbc7890378 docs: strip PedsHub Quiz refs from mobile-build + terser CONTRIBUTING
mobile-build.md:
  - Removed "PedsHub Quiz" sections. That app lives in a separate
    repo (quiz/mobile/) and has its own build pipeline. Docs here
    are PedScribe-only now.
  - Reorganized around CI as the primary flow, local build as
    fallback. Added explicit secret names, JDK requirement, single-
    quote-password caveat, QEMU/argon2 note.
  - File-map section at the end so the native sources are
    discoverable without grepping.

CONTRIBUTING.md:
  - Cut the narrative prose. Dev-facing tables + single-line
    commands only. Decision-tree removed (the table suffices).
  - Release pipeline and mobile build link out rather than
    duplicating content.
2026-04-14 23:54:41 +02:00
github-actions[bot]
9085bb6bb6 Release v6.2.0 2026-04-14 21:51:17 +00:00
Daniel
f5a10419de docs: add CONTRIBUTING.md + .gitmessage template for conventional commits
Drops a cheat sheet (CONTRIBUTING.md) in repo root so anyone — you,
future maintainers — has the full commit-prefix table one glance
away. Covers which prefixes trigger a release and which don't.

Also adds .gitmessage that you can optionally wire into git as the
default commit template:

  git config --local commit.template .gitmessage

Opens the cheat sheet in your editor every time you `git commit`
without -m. Remove it with `git config --local --unset commit.template`.

This commit uses `docs:` prefix so it does NOT trigger a release —
proving the auto-version workflow's filter works.
2026-04-14 23:51:09 +02:00
Daniel
9bfece8532 feat: auto-version workflow — tags managed by commit messages
Adds .github/workflows/auto-version.yml that fires on every push to
main, parses commit messages since the last semver tag, and decides
whether to cut a new release:

  feat:      → minor bump   (new feature, backward-compatible)
  fix:       → patch bump   (bug fix)
  feat!:     → major bump   (breaking change)
  BREAKING CHANGE in body → major bump
  docs/chore/refactor/style/test/ci → no release

If any commit since the last tag matches feat/fix/BREAKING, the
workflow bumps versions across package.json, mobile/package.json,
mobile/android/app/build.gradle, commits the change as
"Release vX.Y.Z", tags it, and pushes. The tag push then fires the
existing android-release and docker-publish workflows.

You no longer need to remember "what version am I on?" — just commit
with a conventional-commits prefix and push. Docs-only or refactor
commits don't create releases. Add [skip ci] to any commit message
to skip this workflow for that commit.
2026-04-14 23:47:37 +02:00
Daniel
7b39c6c615 CI: fix docker multi-arch crash + add one-click version-bump workflow
docker-publish.yml:
  - Dropped linux/arm64 from the platforms matrix. The amd64 GitHub-
    hosted runner builds arm64 under QEMU emulation, which fails at
    native argon2 compile with SIGILL (exit 132). Your production
    box is x86, so arm64 isn't needed. Add it back with a native
    ARM runner the day you deploy to ARM hardware.

version-bump.yml (new):
  - Manual Actions trigger. Click "Run workflow" → pick patch / minor /
    major (or type a custom X.Y.Z). The workflow computes the next
    semver from the current package.json version, updates all three
    version sites (package.json, mobile/package.json, Android
    versionName + versionCode), commits "Release vX.Y.Z", tags it,
    and pushes. The tag push then fires android-release.yml and
    docker-publish.yml automatically — APK + Docker image published
    with no local commands required.

Typical flow now:
  Actions → "Version bump & release" → Run workflow → patch
    ↓
  Bump + tag in ~5 s
    ↓
  Parallel: android APK build (~2 m), docker image push (~4 m)
    ↓
  Both assets show up on the new release; Obtanium + docker-hub
  subscribers see the update automatically.
2026-04-14 23:44:47 +02:00
Daniel
d0009e94ed release.sh: drop node dependency, use sed for version bump 2026-04-14 23:40:38 +02:00
Daniel
b485eec828 Release v6.1.1 2026-04-14 23:40:10 +02:00
Daniel
63b8110993 Add mobile/.gitignore (should have been in prior cleanup commit) 2026-04-14 23:39:26 +02:00
Daniel
ef341671e2 Untrack Capacitor-generated files + node_modules in mobile/
The mobile/ wrapper had 1700+ node_modules files tracked, plus the
Capacitor-regenerated artifacts that get rewritten on every
`npx cap sync android` (capacitor.build.gradle, capacitor.config.json,
capacitor.plugins.json, capacitor.settings.gradle, the cordova-android-
plugins subtree). Every local dev or CI sync caused noisy drift that
blocked scripts/release.sh from running.

Added mobile/.gitignore covering node_modules, cap-sync outputs,
Android build outputs, .jks/.apk/.aab files, and .DS_Store.
Kept package-lock.json tracked for reproducible npm install.

No logic changes — only stopped tracking files that are always
regenerated.
2026-04-14 23:35:23 +02:00
Daniel
0471aee5a5 CI: GitHub Actions workflow to auto-build signed Android APK on tag push
On every v*.*.* tag push the workflow:
  1. Checks out the repo
  2. Sets up JDK 17 + Node 20 + Android SDK (cached between runs)
  3. Runs npm install + npx cap sync android in mobile/
  4. Restores the signing keystore from ANDROID_KEYSTORE_BASE64 secret
  5. Builds a signed release APK via gradle
  6. Renames to pedscribe-X.Y.Z.apk
  7. Creates/updates the matching GitHub release with the APK attached
     and make_latest=true so the /releases/latest URL always points to
     the newest build (Obtanium and the login-page link pick it up
     automatically)

Required repo secrets (set via gh secret set ... or the GitHub UI):
  ANDROID_KEYSTORE_BASE64   base64 -w0 of the .jks file
  ANDROID_KEYSTORE_PASSWORD keystore password
  ANDROID_KEY_ALIAS         key alias (pedscribe)
  ANDROID_KEY_PASSWORD      key password (same as keystore in our setup)

Typical release flow after this lands:
  scripts/release.sh 6.1.1 --push      (laptop, 5 sec)
  ── Actions builds APK in ~8-10 min ──
  ── Release updates automatically with signed APK ──
  ── Obtanium clients notice on next poll ──
2026-04-14 23:24:17 +02:00
Daniel
73398e91ed Version alignment + release script — single source of truth
Aligns every version string in the repo to 6.1.0:
  - package.json: 6.0.0 → 6.1.0
  - mobile/package.json: 1.0.0 → 6.1.0
  - mobile/android/app/build.gradle: versionCode 1 → 610,
      versionName "1.0" → "6.1.0"
  - server.js: hardcoded "v6.0" → reads root package.json at boot
  - /api/health/detailed now reports APP_VERSION from package.json

Adds scripts/release.sh — a one-command bump:
  scripts/release.sh 6.1.1                # local bump + tag
  scripts/release.sh 6.1.1 --push         # + git push
  scripts/release.sh 6.1.1 --push --gh    # + GitHub release (uploads
                                            APK if already built)

Updates all three version sites, commits "Release v6.1.1",
creates annotated tag, optionally pushes and opens a release.
versionCode encoded as MAJ*100000 + MIN*1000 + PATCH so patch
updates always increment monotonically.
2026-04-14 23:18:47 +02:00
Daniel
77bd7c5b1c Hide APK download link on the native Android app
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
The "Download Android app (APK)" link on the login page is pointless
when the user is already inside the Capacitor app. Wrapped the link
in id="apk-download-link" and added a native-app-only hide pass in
auth.js that runs against a short array of web-only element IDs.

Add more entries to that array as other web-only UI appears, so the
mobile wrapper can diverge cleanly from the web UI without branching
the HTML.
2026-04-14 22:47:59 +02:00
Daniel
b4704944cb Replace all 'Johns Hopkins Kids Kard' citations with 'Harriet Lane Handbook' 2026-04-14 22:41:10 +02:00
Daniel
43ee0e7ab5 Bhutani: swap eyeballed values for pre-digitized table from codingace.net
Replaces my best-effort image readings with the pre-digitized
JavaScript data arrays extracted from codingace.net's open
Bhutani calculator (their arrays were embedded in the page
source, apparently digitized from the original Figure 2 at
6-hour granularity through 72 h).

Cross-checked against the AAP 2004 CPG reproduction of the same
Bhutani chart (Southern Health Manitoba clinical policy PDF).
Classifications at several spot-check points (24/36/41/72 h at
varying TSB) match expected zones.

User's reference case (41 h of life, TSB 9.7 mg/dL):
  p95 ≈ 13.6, p75 ≈ 11.4, p40 ≈ 9.1
  → Low-Intermediate Zone  ✓  (matches clinician expectation)

Data source now properly cited in both the in-code block comment
and the on-card footer text. Tool still documents that AAP 2022
is the correct tab for phototherapy decisions.
2026-04-14 13:05:54 +02:00
Daniel
6db52eeec4 Bhutani: replace unsourced values with image-read Stanford nomogram
The previous bhutaniZones table was introduced in the initial
calculator commit (61cf096, 2026-04-09) without any source citation
and was systematically ~0.5-1 mg/dL below the published Bhutani 1999
curves — borderline patients got pushed into the next-higher zone.

New values read directly from the Stanford Medicine Newborn Nursery
reproduction of Bhutani 1999 Figure 2:
  https://med.stanford.edu/newborns/professional-education/jaundice-and-phototherapy/bhutani-nomogram.html

Uncertainty: ±0.3 mg/dL (values eyeballed from a 556 px rendered
graph, not a published table). This is called out explicitly in
both the in-code comment and the Bhutani tab footer, which also
points clinicians to the AAP 2022 tab for actual phototherapy
decisions.

Spot-check: 41h of life, TSB 9.7 mg/dL
  Before: p75=9.33 → High-Intermediate Zone (wrong)
  After:  p75=10.1 → 9.7 below p75, above p40 → Low-Intermediate ✓

No interpolation or decision logic changed — only the lookup data
and its citation.
2026-04-14 13:02:26 +02:00
Daniel
a514405261 Vitals: simplify source to 'Harriet Lane Handbook' 2026-04-14 12:48:06 +02:00
Daniel
ef01eca8ca Citations: move Harriet Lane label to Vitals; restore honest Bhutani cite
Vitals — updated source attribution to "Harriet Lane — Johns Hopkins
Children's Center Kids Kard" (was just Kids Kard). Both the card
subtitle and the intro line.

Bhutani — reverted my incorrect "Harriet Lane" citation (the values
in our table were never transcribed from Harriet Lane). Restored the
original attribution to the 1999 paper. Data itself is unchanged from
the pre-session state; treat it as unverified pending a clinician-
supplied source.
2026-04-14 12:43:17 +02:00
Daniel
b27c79e8ae Bhutani tab: cite Harriet Lane (Johns Hopkins Kids Kard) as source 2026-04-14 12:40:04 +02:00
Daniel
868ed53bbc Revert "Fix Bhutani nomogram values — correct percentile tables"
This reverts commit 48e0749435823376efa581e33db34f09d3123b52.
2026-04-14 12:35:36 +02:00
Daniel
d1b6da4291 Fix Bhutani nomogram values — correct percentile tables
Previous table was ~0.5-1.0 mg/dL below the published Bhutani 1999
nomogram at every reference point, which pushed borderline patients
into the next-higher zone. Coarse 12-hour granularity made the
interpolation error worse between reference points.

Corrected to the PediTools-vetted values (same source we use for
AAP 2022) with 6-hour granularity. Source: Bhutani VK et al.,
Pediatrics 1999;103(1):6-14.

Example: 41h of life, TSB 9.7 mg/dL
  Before: p75=9.33 → TSB above p75 → "High-Intermediate Zone" (wrong)
  After:  p75=10.25 → TSB below p75, above p40 → "Low-Intermediate Zone"
          (matches the published nomogram)

No code-path changes — only the lookup data.
2026-04-14 12:32:07 +02:00
Daniel
290724c883 Revert "Bilirubin chart: BiliTool/PediTools-style visual polish (no data changes)"
This reverts commit e6d90f4ba686d73aaf3958e68b08bb2d1c4026de.
2026-04-14 12:27:14 +02:00
Daniel
c11cfe45b7 Revert "Bili chart: shorter labels + wider right padding to stop clipping"
This reverts commit 110924807c412cd47c56eccf3ce9ee4053f8a7a4.
2026-04-14 12:27:14 +02:00
Daniel
1c106e71db Bili chart: shorter labels + wider right padding to stop clipping
Replaced "Phototherapy" with the clinical abbreviation "Photo Tx"
(fits in the right margin without cut-off). Exchange label stays.
Bumped the layout right-padding from 40 px to 88 px so even the
longest label ("95th (High-Risk)" on the Bhutani chart) prints
fully inside the canvas on narrow viewports.
2026-04-14 12:21:42 +02:00
Daniel
e8a2283fae Bilirubin chart: BiliTool/PediTools-style visual polish (no data changes)
Pure visual improvements to renderBiliChart. Interpolation, lookup
tables, and threshold math are all untouched.

AAP chart now has three-zone shading matching BiliTool's convention:
  - Faint green tint below the phototherapy curve (safe zone)
  - Amber band between phototherapy and exchange (treatment zone)
  - Unshaded above the dashed crimson exchange line (danger)
  Phototherapy line: solid orange 2.4 px; Exchange: dashed crimson.

renderBiliChart common improvements:
  - Right-edge label on each threshold line ("Phototherapy", "Exchange",
    or "95th (High-Risk)" for Bhutani) with white halo — identifiable
    without a legend, like PediTools
  - Legend removed (replaced by the inline labels)
  - X-axis auto-ranges to fit the actual data with tick every 12 h
  - Y-axis tick every 5 mg/dL for clean BiliTool-style gridlines
  - 40 px right padding so labels don't clip
  - Patient dot shrunk from r=8 to r=5 with a 1.8 px white ring,
    redrawn on top of every label + its TSB value printed next to it
  - Bhutani chart inherits all the same improvements without changing
    its own zone/fill setup
2026-04-14 12:17:32 +02:00
Daniel
7a7fd5b4eb Growth charts: back to 7 percentile lines (drop 5th and 95th)
Reduces label crowding at the chart extremes. Clinical meaning
preserved: 3rd and 97th remain as the abnormal-threshold dashed
outermost lines, 10/25/50/75/90 give the mid trend. The bidirectional
label spread, leader lines, and dot-on-top rendering from the prior
pass all still apply to the 7-line layout.

Fills updated for new indexes (3↔97, 10↔90, 25↔75).
2026-04-14 07:16:38 +02:00
Daniel
02f4bea747 Growth charts: bidirectional label spread + leaders + dot-on-top
Labels at the top (97/95/90) crowd just as much as the bottom
trio — prior forward-only pass only spread the bottom. Now:
  - Forward pass pushes items DOWN when crowded from above
  - Backward pass pulls items UP when still crowded from below
  - Min gap bumped 13 → 15 px for breathing room
  - Labels clamped to stay inside chart top/bottom
  - Thin leader line drawn from native curve position to the label
    when the two diverge by more than 2 px, so you can still see
    which line a nudged label belongs to
  - Patient dot redrawn on top of all labels at the very end of
    the plugin so a top-region label can never cover the dot
    (Chart.js's afterDatasetsDraw fires after dataset rendering,
    so the native dot was being painted over).
2026-04-14 07:10:20 +02:00
Daniel
aa1261da4e Growth charts: fix label ordering + shrink patient dot
Label plugin rewritten:
  - Collect all percentile-line labels with their native screen Y
  - Sort top-to-bottom (97th ... 3rd) so order is always correct
  - Walk the sorted list and enforce 13px min vertical gap by
    pushing down (never reordering)
  - Second-pass pull-back if the stack would clip off the chart
    bottom
  Prior logic could nudge "10th" past "3rd" because it moved labels
  independently without respecting their natural screen order.

Patient dot: radius 8 → 5 (hover 11 → 7). The 8px ring was
dominating the chart; 5px is still clearly visible with the 1.8px
white border but no longer obscures adjacent curves.
2026-04-14 07:06:49 +02:00
Daniel
1a176f082a Growth charts: restore full 9-percentile Epic/CDC line set
Reverted from the 7-line reduced set back to the full clinical set:
3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th — matching what
Epic and the printed AAP/CDC/WHO charts display.

Kept the mobile-readability improvements from the prior pass:
  - Each percentile is a distinct hue (red / deep-orange / orange /
    amber / green / blue / violet / purple / pink)
  - Outer 3rd + 97th are dashed (abnormal-threshold convention); 5th
    and 95th use a tighter dash; rest solid
  - 50th remains bold green to anchor the center
  - Label plugin's white halo + vertical nudge keeps 3/5/10 and
    90/95/97 label stacks legible
  - Patient dot still on top, white-ringed

Fill bands updated for 9 indexes (3↔97, 5↔95, 10↔90, 25↔75).
2026-04-14 07:02:46 +02:00
Daniel
2cb99b263f Growth charts: 7 distinct-color percentile lines + readable mobile labels
Reduced reference curves from 9 to 7 (dropped 5th and 95th — they
crowd the 3rd/10th and 90th/97th labels on small screens without
adding clinical value; 3rd and 97th are the US/WHO standard
abnormal thresholds).

Each percentile now gets a distinct hue:
  3rd  red     50th green (bold)    90th violet
  10th orange  75th blue            97th pink
  25th amber                        (3rd/97th dashed)

Label plugin improvements:
  - Bolder 11px font (was 10px)
  - White halo stroke behind text so labels stay legible when the
    line they sit on is also colored
  - Vertical nudge when two labels would overlap — keeps adjacent
    percentiles readable on mobile aspect ratios
  - Solid fill color (strips alpha from borderColor)

Patient dot:
  - Moved to order: -1 (drawn on top of everything, including labels
    and fill bands)
  - White 2.5px border ring so it's visible even when it lands
    exactly on a colored curve
  - Slightly larger hover radius (11 → was 10)
2026-04-14 07:01:53 +02:00
Daniel
de0562060b Growth charts: label each percentile curve (3rd, 50th, 97th, …)
Adds a Chart.js plugin that draws the percentile label at the right
end of each reference line, matching the convention on printed
WHO/CDC growth charts. Lets clinicians identify lines at a glance
without using the legend. Canvas gets 30px right-padding so the
labels don't get clipped.
2026-04-14 06:55:12 +02:00
Daniel
e32e9977f5 Cache-busting version stamps + client-side encounter version tracking
1. Build-ID cache busting (server.js):
   - Compute a BUILD_ID at boot: git HEAD short hash if available,
     else /app/BUILD_ID file, else random-on-boot.
   - On first request for /, rewrite every local /js/*.js and
     /css/*.css reference in index.html to include ?v=BUILD_ID.
     Cached once at startup so subsequent renders are free.
   - X-Build-Id response header + GET /api/build expose it for
     debugging.
   - Eliminates the "works after hard-refresh" class of bugs: every
     deploy gets a new build ID, so browsers fetch fresh JS/CSS on
     the very next page load.

2. Optimistic encounter locking wired into the client
   (public/js/encounters.js):
   - On resumeEncounter(): stash enc.version into
     window._encounterVersions[id]
   - On saveEncounter(): send expected_version in the POST body
     when we have one.
   - Server returns 409 if another tab/device wrote first → user
     sees "Someone else edited this encounter. Reload to see the
     latest version." instead of silently clobbering the prior save.
   - On success, remember the new server-assigned version for the
     next save.
2026-04-14 05:40:42 +02:00
Daniel
2f3e608c88 Fix: local-auth users lose Password/2FA/Sessions after refresh
Previous check was strict (canLocalAuth !== true → hide). On a
transient /me hiccup or when the boot cache lagged, a legit local
user saw empty Settings with none of the sections they should see.

Inverted the predicate: hide only when canLocalAuth === false
(explicit SSO-only signal from the server). Undefined/missing now
defaults to show — local-auth users never lose their own UI.
Still hides correctly for the documented SSO-only case because the
/me endpoint sets the flag to false explicitly for those users.
2026-04-14 05:32:30 +02:00
Daniel
5b0c296a88 Prompt-injection wrap: remaining AI routes
Applied the <UNTRUSTED_*> delimiter + INJECTION_GUARD pattern to:
  - src/routes/sickVisit.js  (chief complaint, transcript, dictation,
                               ROS, physical exam, diagnoses, style hints)
  - src/routes/wellVisit.js  (SSHADESS answers + full well-visit context)
  - src/routes/chartReview.js (PMH + all visit content + labs)
  - src/routes/hospitalCourse.js (all notes/H&P/ED + clarification
                                   & update endpoints)
  - src/routes/milestones.js (narrative + summary)

Each wraps patient-derived text in <UNTRUSTED_*>…</UNTRUSTED_*>
tags and appends the INJECTION_GUARD system instruction that tells
the model to treat wrapped content strictly as data. Operator-
supplied `additionalInstructions` stays unwrapped (trusted).
2026-04-14 05:31:54 +02:00
Daniel
7a06a4aa63 Batch of security + scale fixes
Age parser (src/routes/billing.js):
  - Now sums year + month + week + day matches so "4 yr 11 mo"
    (59 months) correctly maps to the 5-11y billing bracket instead
    of being billed as 1-4y. Added bounds sanity check.

Graceful SIGTERM shutdown (server.js):
  - Closes the HTTP listener first, then drains batched audit queues,
    then ends the Postgres pool. 9-second hard deadline to beat
    Docker's 10-second SIGKILL. Previously an in-flight note save
    during a container restart could truncate the write.

Explicit LLM fallback opt-in (src/utils/ai.js):
  - The OpenRouter / LiteLLM silent fallback now requires admin
    setting `ai.allow_model_fallback = true` (default: false). If
    primary fails and fallback is disabled, the error is surfaced
    to the caller. Prevents silent spillover from a BAA-covered
    primary to a non-covered fallback.

Prompt injection delimiters (src/utils/promptSafe.js):
  - Wraps user transcripts, dictations, refine-instructions, and
    pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
    appends an explicit system instruction telling the model to
    treat the wrapped content as data rather than commands.
  - Applied to soap.js, hpi.js, refine.js. Extend to other AI
    routes incrementally.

Cross-tab logout sync (public/js/authFetch.js, auth.js):
  - BroadcastChannel('pedscribe-auth') — logout in one tab posts
    a message; all sibling tabs clear state and reload, dropping
    any PHI-containing UI immediately.

Backup code race-free consumption (src/routes/auth.js):
  - tryConsumeBackupCode() now uses a Postgres transaction with
    SELECT ... FOR UPDATE so concurrent login attempts using the
    same code serialize. First wins, second sees the already-
    shortened array.

Optimistic encounter locking (migrations/...add-encounter-version):
  - saved_encounters.version INTEGER NOT NULL DEFAULT 1
  - POST /api/encounters/saved accepts an expected_version and
    rejects with 409 if the row has advanced. Falls back to
    last-write-wins if the client doesn't pass one (backward compat).

Audit log batching (src/utils/auditQueue.js):
  - Audit / api_log / access_log writes are buffered in memory and
    flushed every 1s or every 50 entries via one multi-row INSERT.
    Under load this reduces DB pressure by ~50x. On SIGTERM the
    shutdown path drains the queue before exiting.
2026-04-14 05:24:40 +02:00
Daniel
8db25f39be Enforce server-side LLM model whitelist + scope idle timeout to writes
Two findings from review:

1. callAI() previously accepted any model string from the client.
   POST /api/hpi with { model: "openai/o1" } would call the reasoning
   model regardless of whether the operator enabled it. Added
   getAllowedModelIds() in src/utils/models.js (60s TTL DB-backed
   cache) and a guard at the top of callAI() that rejects with
   "model_not_permitted" when the requested ID isn't in the active
   roster. No model supplied → silent fallback to DEFAULT_MODEL.

2. Middleware was updating user_sessions.last_activity on every
   request, including GETs. Client-side polling (/api/auth/me
   heartbeats, dashboard refreshes, log tail calls) kept sessions
   alive indefinitely, defeating the 24h sliding idle policy. Now
   only POST/PUT/DELETE/PATCH count as "user activity". GETs are
   read-only and often automated — they no longer extend the
   session. Idle enforcement still runs on every method, so a
   24h-idle user still gets kicked on their next GET.
2026-04-14 05:15:55 +02:00
Daniel
a2b1b262cb Fix: revoked sessions now actually log the other device out
The server-side revoke was always working — it deletes user_sessions
rows, and middleware correctly returned 401 on the revoked device's
next /api/* request. The bug was entirely client-side: individual
fetch handlers swallowed the 401 (rendering "no sessions found" or
empty data) and nothing redirected to the login screen. So the
revoked device looked like it stayed signed in.

Added public/js/authFetch.js: a global fetch interceptor that
watches every /api/* response. On 401 from a non-auth endpoint
(i.e. not /login, /register, /logout, /me, etc.), it clears any
cached token/user state and reloads the page. The reload's boot
flow lands on /api/auth/me → 401 → login screen as usual.

Guarded against false positives: only triggers when the app believes
the user is currently logged in (AUTH_TOKEN set or main-app visible)
so a pre-login 401 doesn't accidentally flash the screen.

Loaded before auth.js in index.html.
2026-04-14 05:10:16 +02:00
Daniel
fcf11ec326 Hide Active Sessions for SSO-only users
Follows the same pattern as Change Password and 2FA sections —
hidden by default in the HTML, revealed only when canLocalAuth=true.

Why: revoke technically deletes the PedScribe session row and clears
the cookie on that device, but the SSO user can re-auth instantly
because their IdP session is still live. Surfacing a "revoke" button
that the IdP will immediately undo is misleading. SSO users now see
only the SSO-relevant sections of Settings.
2026-04-14 05:07:07 +02:00
Daniel
64ac4ff6bb Add node-pg-migrate for versioned schema changes + better mobile UA labels
Infrastructure only — no existing data or tables modified.

  src/db/migrate.js           — programmatic runner, fires at boot after
                                 the existing idempotent initDatabase()
  migrations/1744600000000...  — intentionally empty example, documents
                                 the file shape. Registered in the new
                                 pgmigrations tracking table so it won't
                                 rerun.
  .node-pg-migraterc.json     — CLI config (migrations-dir, utc naming)
  docs/migrations.md          — workflow + conventions
  package.json                — migrate:up/down/new/status npm scripts
                                 (status is a direct pgmigrations query
                                 since node-pg-migrate v7 lacks a status
                                 subcommand)

src/utils/sessions.js:
  - parseUserAgent now recognizes the Capacitor wrapper (UA suffix
    "PedScribe-Android" / "PedScribe-iOS") and labels sessions
    "PedScribe (Android)" instead of "Chrome on Android".

Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
2026-04-14 05:06:19 +02:00
Daniel
040218a7bf Fix critical auth bug: set httpOnly cookie on local login/register
After the hybrid auth migration, web users log in but the
setAuthCookie() helper was never actually called in /login or
/register — only in the OIDC callback. Result: local sign-in worked
until the first page reload, then the user appeared logged out. The
Settings page's Active Sessions list came up empty because
/api/sessions received no auth.

Added setAuthCookie(res, token) calls on successful:
  - /register (auto-verified first admin path)
  - /login (after TOTP / backup code verification)

Mobile is unaffected — it uses Bearer from Keychain and always has.
2026-04-14 04:55:12 +02:00
Daniel
ed015f3774 Fix local-auth sections not showing for normal users + backup-code modal signature
settings load2FAStatus():
  - Explicit credentials: 'same-origin' on the /me fetch (was relying
    on fetch defaults, which can behave oddly in some browsers/edges)
  - Fall back to window.CURRENT_USER (cached at login) if /me fails,
    so local-auth users still see their password/2FA sections after
    a transient error. Keeps cache in sync on each successful fetch.

enterApp():
  - Cache the logged-in user object on window.CURRENT_USER so modules
    that need the canLocalAuth flag don't have to re-fetch /me.

2FA regenerate modal:
  - Previous call passed a wrong-shape options object to showConfirm.
    Updated to the correct (message, callback, opts) signature with
    input:true, inputType:'password', placeholder, required.

OIDC email_verified check:
  - Accept boolean true or string 'true' for robustness. Some IdPs
    serialize ID-token booleans as strings.
2026-04-14 04:43:07 +02:00
Daniel
b6753d5bc9 Hide change-password + 2FA by default, show only when canLocalAuth=true
Sections were briefly visible for SSO-only users before load2FAStatus
resolved and hid them. Flipped the default: both sections now carry
style="display:none" in the HTML and are revealed only when the /me
fetch confirms the user has a real password hash.

SSO-only users never see the sections, even for a flash.
2026-04-14 04:38:28 +02:00
Daniel
f9732f25d0 Server-side SSO/local-auth enforcement + OIDC account-link hardening
Endpoint guards (defense-in-depth over hidden UI):
  - POST /api/auth/change-password: 400 with SSO-aware message if
    the caller's stored password is not a real bcrypt/argon2 hash.
    Prior behaviour was to fail at passwords.verify() with an
    ambiguous "current password is incorrect".
  - POST /api/auth/setup-2fa: 400 with same SSO-aware message for
    SSO-only accounts. Prior behaviour allowed TOTP setup on an
    account where it could never actually trigger (user never logs
    in locally).

OIDC account-link safety (src/routes/oidc.js):
  - Auto-link to an existing local account now requires the IdP to
    assert email_verified=true in the ID token (or userinfo). If
    absent/false, the callback redirects with ?error=email_unverified.
    Prevents an attacker at a misconfigured IdP from taking over a
    local account by claiming an email they don't own.
  - If an existing user already has oidc_sub set and the incoming
    sub is different, refuse with ?error=sub_mismatch. Prior
    behaviour silently did nothing, hiding a potential attack.
  - Audit 'oidc_linked' written on first successful link.

Frontend:
  - Added user-facing messages for the two new SSO error codes.
2026-04-14 04:35:00 +02:00
Daniel
97f60876c5 Idle timeout observability + cut write frequency in half + hide local-auth UI for SSO-only users
Middleware:
  - Log to console.warn + audit_log when a session is killed for
    inactivity. Shows up in Grafana/Loki so you can see how often
    users actually get kicked. Audit action: 'session_idle_timeout'
  - last_activity throttle bumped 5 min → 10 min — halves DB writes
    per active user. Idle precision slop widens to 24h00-24h10;
    still invisible in practice.

Per-user local-auth visibility:
  - /api/auth/me now returns user.canLocalAuth: true when the stored
    password is a real bcrypt / argon2 hash, false for the random
    blob OIDC auto-creates for SSO-only users.
  - Settings page hides "Change Password" and "Two-Factor
    Authentication" sections when canLocalAuth is false — those UIs
    are meaningless for users whose sign-in lives at the IdP.
  - Password hash is not leaked in the /me payload.

Mobile (restating existing behaviour for clarity): no idle check,
365-day JWT in Keychain/Keystore, never auto-logs-out. Only logout
triggers are: manual logout, password change, admin revoke, JWT hit
365d, or app uninstall.
2026-04-14 04:32:53 +02:00
Daniel
c411e5f16f Sliding 24h idle timeout (web) + persistent mobile + 2FA backup codes
Session model:
  Web     — 24h sliding idle timeout enforced server-side via
             user_sessions.last_activity. 30-day JWT + cookie are a
             safety net; middleware is the real clock. Cookie is
             re-set on active use so browsers match the sliding window.
  Mobile  — 365-day JWT, no idle timeout (stays persistent via Keychain
             / Keystore). Detected via User-Agent ("PedScribe" /
             "Capacitor") or X-Client: mobile header.

2FA backup codes:
  - 10 single-use codes generated when 2FA is first enabled
  - Stored as bcrypt hashes in new users.totp_backup_codes column
  - Consumed atomically on successful login fallback (when TOTP fails)
  - Regenerate endpoint (POST /api/auth/2fa/backup-codes) requires
    current password; invalidates prior codes
  - Count endpoint (GET /api/auth/2fa/backup-codes/count) powers a
    "N codes remaining" indicator on the 2FA settings card
  - Modal shows codes exactly once with Copy + Download .txt actions
  - Codes cleared when 2FA is disabled

New files:
  src/utils/platform.js — isMobileClient() helper

Schema migration (idempotent):
  ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT
2026-04-14 04:24:54 +02:00
Daniel
6c98e36511 Session lifetime: 7 days → 24 hours
Shortens both JWT expiresIn and httpOnly cookie maxAge to 24h in
auth.js (local + register + reset flows) and oidc.js (SSO callback).

Rationale: shorter absolute session window for a PHI-adjacent app.
No sliding idle refresh — user re-logs in once a day.
2026-04-14 04:17:54 +02:00
Daniel
4a29c496f6 Mobile app hardening — security + Android 14 compat
capacitor.config.json:
  - webContentsDebuggingEnabled: true → false
    (was leaving Chrome DevTools able to attach to released builds)
  - allowMixedContent: true → false
    (API is HTTPS-only; no need to permit cleartext loads)
  - server.allowNavigation: ["*"] → restricted to pedshub.com /
    peds.danvics.com origins
    (prevents WebView following an attacker-controlled redirect)

AndroidManifest.xml:
  - android:allowBackup="false" + data_extraction_rules.xml
    (Android system backup would otherwise copy EncryptedSharedPreferences
     containing the auth token into Google Cloud backups)
  - Removed USE_BIOMETRIC permission (feature removed earlier)

AudioRecordingService.java:
  - startForeground(id, notif, TYPE_MICROPHONE) on Android 14+
    (without the explicit type Android 14 kills the service with
     MissingForegroundServiceTypeException)
  - WakeLock cap: 1h → 8h (still bounded, onDestroy releases early)

MainActivity.java:
  - Removed dead biometric code path and androidx.biometric imports

mobile/package.json:
  - Dropped @aparajita/capacitor-biometric-auth — orphan dependency
2026-04-14 04:15:27 +02:00
Daniel
5a700a2a27 Hybrid auth: cookie-only on web, Keychain Bearer on mobile
Runtime split driven by window.Capacitor.isNativePlatform():

  Web browser
    - No token in localStorage / sessionStorage — XSS can't read it
    - Server-set httpOnly cookie carries the session
    - fetch() default credentials='same-origin' sends the cookie
    - getAuthHeaders() returns Content-Type only, no Authorization
    - Middleware already falls back to cookie when Bearer is absent

  Capacitor native (iOS / Android)
    - Unchanged — Bearer token lives in Keychain / Keystore via the
      capacitor-secure-storage-plugin SecureStorage wrapper
    - Bearer header still sent on every request

enterApp() / clearSession() / getAuthHeaders() all now branch on
isNativeApp(). Legacy localStorage entries from the dual-mode era
are wiped on clearSession() for users migrating in.

Rollback: git reset --hard pre-httponly-only-2026-04-14
2026-04-14 04:11:55 +02:00
Daniel
0bbecb76f9 Maintenance CLI + unpin postgres digest
Adds `npm run maint:check` (health report) and `npm run maint:reindex`
(REINDEX DATABASE + REFRESH COLLATION VERSION + ANALYZE) for post-
upgrade maintenance, modelled after Nextcloud's occ maintenance.
Documented in README.

Also relaxes postgres image from digest pin back to tag-pin
(pgvector/pgvector:pg16) — the auto-REINDEX-on-drift check in
database.js and the COLLATE "C" protection on critical indexes
make the digest pin redundant while blocking ordinary `compose
pull` updates.
2026-04-14 04:00:13 +02:00
Daniel
cd2513d361 Pin critical auth indexes to COLLATE "C" (ICU-drift immune)
idx_users_email and idx_sessions_token_hash now use byte-order
collation so a future ICU library bump cannot silently corrupt the
indexes the way it did this week. The columns themselves retain
their default collation; only the index comparison is C, which is
safe for these because:

  - users.email is lowercased ASCII in practice
  - user_sessions.token_hash is SHA-256 hex (pure ASCII)

Both are used for equality lookups only, never ORDER BY. Migration
is idempotent, gated on app_settings.migration.text_indexes_c.

Slug indexes on learning_* tables left at default for now — those
are also ASCII in practice but under lighter load; the startup
drift check + auto-REINDEX covers them.
2026-04-14 03:55:09 +02:00
Daniel
9423ffc3a7 Collation-drift guard + lookup-miss visibility
Root cause of recent "invalid credentials on correct password" was
a silent btree index corruption: pgvector/pgvector:pg16 was pulled
with a different ICU library than the one used to build existing
indexes. Queries returned 0 rows even though matching heap rows
existed. Postgres logged nothing (corrupt index → empty result set
is a "successful" query) and the login path never logged unknown-
user attempts (enumeration protection).

Three defenses:

  1. Pin postgres image by digest in docker-compose.yml so a
     silent pull can't change ICU under our feet.
  2. Startup collation-drift check in src/db/database.js:
     compares pg_database.datcollversion to the library's actual
     version and, on mismatch, runs REINDEX DATABASE + ALTER
     DATABASE REFRESH COLLATION VERSION. Logs "Collation versions:
     aligned" on clean boot.
  3. Server-side console.warn on login lookup-miss (no email, no
     audit row — preserves enumeration protection but gives
     Grafana/Loki a signal for unusual miss rates).
2026-04-14 03:52:29 +02:00
Daniel
43d26fd306 Login: remove temporary debug logging
Root cause for "invalid credentials" on correct password was a
corrupt btree index (idx_users_email) causing user lookups to miss
existing rows. Fixed by REINDEX DATABASE. Keeping a typed catch
around passwords.verify() so any future verify throw is logged
cleanly instead of bubbling as 500.
2026-04-14 03:48:56 +02:00
Daniel
11f53102ee Growth/BMI results: percentiles to 2 decimal places
Percentile displays in growth charts, BMI, and mid-parental height
now show 2 dp (e.g. "37.42th") instead of 1 dp ("37.4th") for more
precision at tail percentiles.
2026-04-14 03:37:40 +02:00
Daniel
7cc8a1fa99 Growth chart: accept explicit 0 in any age field
Previously any form of zero total ("0 days", all blank) rejected
with "Enter age". Newborns at birth are a legitimate entry —
distinguish blank-all (error) from explicit-zero (valid).
2026-04-14 03:33:10 +02:00
Daniel
df592d401b Growth chart age: three boxes (yr/mo/day), any combination
Replace single text input with three number fields — years, months,
days — that all combine into fractional months. Fill any subset:
leave years blank for a newborn, leave months blank for "2 years",
enter just days for a 10-day-old.

Live hint below ("= 2 yr 5 mo (29 mo total)") still shows the
interpreted total. Parser from prior commit retained on window
for reuse elsewhere.
2026-04-14 03:21:27 +02:00
Daniel
c392e73cfe Growth chart: flexible age input with smart parser
Replace [years] + [months dropdown] with a single text field that
accepts:
  3y / 3 years / 3 yr
  29m / 29 months / 29 mo
  2y5m / 2 years 5 months / 2 yr 5 mo
  3.5 years / 36 (plain number = months)
  15 days / 2 weeks / 3y 2m 10d

Enables fractional ages so newborns can be plotted accurately
(WHO/CDC growth curves are continuous — "0 months" means at birth,
not a 0-27 day bucket, so a 15-day-old should plot at ~0.5 months).

Live hint below the field shows how the input was interpreted
("= 2 yr 5 mo (29 mo total)").
2026-04-14 03:17:16 +02:00
Daniel
dc5f8ae758 Dockerfile: add build tools for argon2 native compile
argon2 requires node-gyp + python3 + g++ + make to build its C
extension. Added as a virtual .build-deps package so it's compiled
during npm install, then purged to keep the Alpine image slim.
2026-04-14 03:09:38 +02:00
Daniel
74c5cde8e1 Stop leaking e.message to clients across all routes
88 occurrences of res.status(500).json({ error: e.message }) (or
err.message) swept to generic 'Request failed'. Server-side
console.error / logger.error calls are untouched, so the full detail
still lands in logs and Grafana.

Covers: admin, adminConfig, adminMilestones, chartReview, documents,
encounters, hospitalCourse, hpi, learningAdmin, learningAI, learningHub,
logs, memories, milestones, oidc, refine, sessions, sickVisit, soap,
userPreferences, wellVisit.

Also extends .gitignore to exclude .env.backup-* files.
2026-04-14 03:04:24 +02:00
Daniel
7c45367c02 Security hardening: PHI encryption, argon2, DOMPurify, SRI
- App-layer AES-256-GCM crypto helper (src/utils/crypto.js)
- Nextcloud tokens encrypted at rest; transparent migration on next use
- Audio backups encrypted at rest (version byte 0x01 envelope); legacy
  rows still decrypt as-is until overwritten
- argon2id password hashing via src/utils/passwords.js with bcrypt
  fallback; bcrypt hashes rehashed to argon2id on next successful login.
  argon2 package is optional — server keeps running with bcrypt only
  until npm install adds the native dep
- PHI redactor for audit log details (src/utils/redact.js) — strips SSN,
  phone, email, DoB, long IDs; caps at 500 chars; detects note bodies
- DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in
  Learning Hub content rendering
- SRI integrity hashes added for Font Awesome CSS and Chart.js
- Magic-byte file-type verification on document uploads
  (src/utils/fileType.js)
- Generic 500 error responses via src/utils/errors.js applied to
  nextcloud and audioBackups; full detail still logged server-side
- DATA_ENCRYPTION_KEY env documented in .env.example

Deploy: requires rebuild of the container image to pick up the new
files and `npm install` (adds argon2). Existing users keep working
because bcrypt stays available and crypto helpers pass through
plaintext when the key is not yet set in dev.
2026-04-14 02:49:38 +02:00
Daniel
e625c634b6 Security hardening: low-risk easy wins
- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)

Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
2026-04-14 02:42:32 +02:00
Daniel
c736782c15 Add hardware-backed secure storage for mobile auth token
Web still uses localStorage; Capacitor native app now routes
token/user/session-id through capacitor-secure-storage-plugin
(iOS Keychain, Android EncryptedSharedPreferences / Keystore).

A thin SecureStorage wrapper detects Capacitor at runtime and
falls back to localStorage elsewhere, keeping a single auth.js
codebase for both targets.

To activate on mobile: cd mobile && npm install && npx cap sync android
2026-04-14 02:33:32 +02:00
Daniel
82b8fa0e0e Add APK download link on login page
Links to GitHub releases/latest for Android APK download.
2026-04-14 02:29:38 +02:00
Daniel
011fae9b7a Enhance audit logging: user agent, session ID, PHI access tracking
Loki logs now include:
- User agent string (browser/device identification)
- Session ID (ties actions to specific login session)
- Status field (success/failure)

New logging:
- encounter_load: logged when user opens a saved encounter (with label)
- copy_to_clipboard: logged when user copies note content (PHI access)
- Client event endpoint: POST /api/logs/client-event (auth required)

Encounter save/delete/load all include the encounter label for
patient identification in audit trail.

HIPAA audit trail now covers: who, what, when, from where, which
device, which session, what patient data, success/failure.
2026-04-11 06:17:05 +02:00
Daniel
ffa6b818db Add full hour-by-hour exchange transfusion thresholds for all GA groups
Exchange transfusion data (AAP 2022) now covers GA 35, 36, and 38+ weeks
with and without risk factors, hour-by-hour from 12-96h (510 more data
points). Total bilirubin data: 1020 data points (6 photo + 6 exchange
tables x 85 hours each). No interpolation needed for any hour.
2026-04-11 06:03:48 +02:00
Daniel
ab1ac25611 Bilirubin: full hour-by-hour AAP 2022 data (510 data points)
Replace interpolated thresholds with exact hour-by-hour values
extracted from PediTools API for every hour from 12-96h:
- 6 phototherapy tables (GA 35/36/38 x with/without risk factors)
- 85 data points per table = 510 total values
- No interpolation needed — exact AAP 2022 nomogram values
- Exchange transfusion thresholds for GA 38 (with/without risk)
2026-04-11 06:01:00 +02:00
Daniel
1b5faa3a01 Update bilirubin to exact AAP 2022 values, add exchange transfusion
Phototherapy thresholds updated with exact values extracted from
PediTools (validated against AAP 2022 nomograms):
- Separate tables for GA 35, 36, and 38+ weeks
- With and without neurotoxicity risk factors
- Hour-specific values at 12, 24, 36, 48, 60, 72, 84, 96, 120h

Previous approximations were 1-3 mg/dL too low (conservative but
inaccurate). New values match the published AAP 2022 curves exactly.

Exchange transfusion thresholds added for GA 38+ weeks (with/without
risk factors). Displayed alongside phototherapy threshold in results.

GA selection expanded: 35, 36, 37, 38, 39, 40+ weeks.
Chart now shows both phototherapy and exchange transfusion lines.

Also: fixed Loki port conflict (3100->3101), added logs.pedshub.com.
2026-04-11 05:50:19 +02:00
Daniel
5bf55499a4 Add pause/stop buttons to SOAP note recording
- Add Pause and Stop buttons (hidden until recording starts)
- Record button hides during recording (same pattern as encounter)
- Pause: suspends MediaRecorder + speech recognition, shows Resume
- Resume: handles MediaRecorder state recovery if browser killed it
- Stop: triggers the record button's stop flow
- Native mobile: haptic feedback + keep-awake + foreground service
- Recognition respects pause state (doesn't restart during pause)
2026-04-11 05:19:12 +02:00
Daniel
6ed2778a12 Add Glasgow Coma Scale calculator and equipment sizing reference
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
GCS Calculator:
- Child/Adult and Infant versions with toggle
- Eye opening (4), Verbal (5), Motor (6) dropdowns
- Auto-calculates total score with severity classification
  (Mild 13-15, Moderate 9-12, Severe/Coma 3-8)
- Infant-modified verbal and motor scales per Kids Kard
- Updates on every dropdown change (no button needed)

Equipment Sizing (Johns Hopkins Kids Kard):
- Select age/weight group (premie through 16+)
- Shows: BVM, oral/nasal airway, blade, ETT, LMA, Glidescope,
  IV catheter, central line, NGT/OGT, chest tube, Foley
- All values from Johns Hopkins Children's Center Kids Kard
- ETT formulas shown as reference
2026-04-11 05:01:57 +02:00
Daniel
6daf08982e Increase API rate limit to 200 req/min (Turnstile errors were exhausting 60/min limit)
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
2026-04-11 04:47:08 +02:00
Daniel
9f39f0b822 Fix vital signs selector, add resuscitation medications calculator
Vital Signs:
- Fix age selector not responding (replaced setTimeout with event
  delegation on parent panel — works reliably with hidden panels)
- Update values to Johns Hopkins Kids Kard data (8 age groups:
  premie, 0-3mo, 3-6mo, 6-12mo, 1-3yr, 3-6yr, 6-12yr, >12yr)
- Each age group shows: HR awake/sleeping, RR, SBP, DBP, temp,
  SpO2, weight range, and clinical pearls

Resuscitation Medications (new calculator tab):
- Enter patient weight, calculates all 13 PALS medication doses
- Adenosine, Amiodarone, Atropine, Calcium Chloride/Gluconate,
  Dextrose (weight-based concentration), Epinephrine (arrest/anaphylaxis),
  Hydrocortisone, Insulin, Lidocaine, Magnesium, Naloxone, Bicarb
- Color-coded by category (cardiac/metabolic/reversal)
- Max dose capping, route, special notes per medication
- Source: Johns Hopkins Kids Kard / AHA PALS 2020
2026-04-11 04:42:23 +02:00
Daniel
09aaeefee1 Fix vital signs age selector: add setTimeout for DOM readiness 2026-04-11 04:28:49 +02:00
Daniel
a5f073dcdd Interactive vital signs selector with clinical notes per age group
Replace static vital signs table with interactive age group dropdown.
Each selection shows: HR (awake/sleeping), RR, SBP, DBP, temperature,
SpO2 target, weight range, and age-specific clinical notes.

10 age groups: preterm through 18 years. Values from Harriet Lane
Handbook 23rd Edition. Includes AAP 2017 BP classification thresholds
for ages 13+, ETT sizing formulas, and clinical pearls (orthostatic
testing, febrile tachycardia, athletic bradycardia, etc.).

Full reference table preserved as collapsible "View All Age Groups".
2026-04-11 04:19:39 +02:00
Daniel
79fee2d4f2 Remove biometric prompt (will implement properly with token-based auth later) 2026-04-11 03:57:40 +02:00
Daniel
e1ce374809 Native Android: biometric auth, foreground service bridge, mic fix
Major Android native improvements:

Biometric authentication:
- Native AndroidX BiometricPrompt on app launch (2nd launch onwards)
- Supports fingerprint, face, iris, and device PIN/password fallback
- Gracefully skips if no biometric hardware or first launch
- Uses SharedPreferences to track first launch

Microphone permission:
- Added MODIFY_AUDIO_SETTINGS permission (required for WebView audio)
- Added androidScheme: "https" in Capacitor config (getUserMedia requires
  secure context)
- WebChromeClient properly grants WebView permission after Android
  runtime permission is obtained
- Handles pending permission request across the async flow

Background recording bridge:
- NativeRecording JavaScript interface exposed to WebView
- startForegroundService() / stopForegroundService() callable from JS
- Web app calls these on recording start/stop in liveEncounter.js
- AudioRecordingService keeps CPU awake + shows notification when recording
- Recording survives screen lock via foreground service + wake lock

Also:
- USE_BIOMETRIC permission added to manifest
- androidx.biometric:biometric dependency added to build.gradle
- Haptic fallback to navigator.vibrate when Capacitor plugins unavailable
2026-04-11 03:42:23 +02:00
Daniel
4b1afd1f44 Fix WebView mic: grant both Android runtime + WebView permissions
The WebView has its own permission layer separate from Android runtime
permissions. Both must be granted. Now when the web page requests mic
access, the WebChromeClient checks if Android permission exists, grants
the WebView request if yes, or requests Android permission first then
grants the pending WebView request in the callback.
2026-04-11 03:37:45 +02:00
Daniel
6d3b0693d8 Fix Android mic permission, simplify launcher, remove broken biometric
- MainActivity: request RECORD_AUDIO permission at app start via
  ActivityCompat (not WebChromeClient override which broke Capacitor bridge)
- Simplify launcher: remove server reachability check (was failing in
  WebView), just save URL and navigate directly
- Remove biometric auth from launcher (Capacitor plugins need ES module
  bundler, not available in plain HTML). Biometric can be added later
  via the web app with proper Capacitor runtime.
- Add webContentsDebuggingEnabled for development
2026-04-11 03:34:47 +02:00
Daniel
7f8ddfff53 Fix Android: auto-grant WebView mic permission, match status bar color
- MainActivity: override WebChromeClient to auto-grant WebView
  permission requests (microphone, camera) so the Android runtime
  permission dialog shows instead of WebView silently denying
- Add colors.xml with PedScribe blue (#2563eb / #1d4ed8)
- Update styles.xml: set statusBarColor and navigationBarColor to
  match app theme (fixes brown/mismatched bar at top)
- Change base theme to NoActionBar (removes action bar)
2026-04-11 03:29:15 +02:00
Daniel
6b69315d99 Fix launcher: simplify server check for Android WebView compatibility
WebView blocks no-cors fetch and image probes differently than browsers.
Simplified to a normal fetch that treats CORS errors as 'server reachable'
(CORS error = server responded, just blocked the origin).
2026-04-11 03:23:00 +02:00
Daniel
47844ff29b Add Loki + Grafana monitoring stack, ntfy notifications, biometric auth
Monitoring:
- docker-compose.monitoring.yml — opt-in Loki + Grafana stack
- Loki config with 6-year retention (HIPAA compliant)
- Grafana auto-provisioned with Loki datasource + PedScribe dashboard
  (login activity, failed logins, clinical actions, API calls, log viewer)
- Logger ships to Loki in parallel with PostgreSQL (fire-and-forget)
- Labels: app=pedscribe, type=audit|api_call|access, category, action

Usage: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
Grafana at localhost:3003 (admin/pedscribe)

Notifications:
- ntfy push support (src/utils/notify.js)
- Notifications on: login, password change, registration
- Self-hosted, no Firebase dependency

Mobile:
- Biometric auth on app launch (Face ID/Touch ID/fingerprint)
- PIN/password fallback, auto-prompt, skip option
2026-04-11 03:00:52 +02:00
Daniel
c7038d9db1 Add biometric auth, ntfy push notifications, mobile improvements
Mobile:
- Add biometric authentication (Face ID/Touch ID/fingerprint) on app launch
  with PIN/password fallback, auto-prompts on launch, skip option
- Add @aparajita/capacitor-biometric-auth plugin

Backend:
- Add ntfy push notification support (src/utils/notify.js)
  Self-hosted, no Firebase dependency, uses user's existing ntfy instance
- Notifications for: new login, password changed, new registration (admin)
- Topic format: pedscribe-user-{id} for users, pedscribe-admin for admins
- Env: NTFY_URL, NTFY_TOKEN (optional)
2026-04-11 02:35:07 +02:00
Daniel
11d4880337 Mobile app: haptics, deep linking, share intent, push notifications, keep-awake
Native improvements:
- Add haptic feedback on recording start (heavy) and stop (medium)
- Add keep-screen-awake during recording (nativeKeepAwake)
- Add isNativeApp() detection helper
- Android: deep linking (pedscribe:// + https://app.pedshub.com)
- Android: share intent for text/plain and application/pdf
- iOS: deep linking (pedscribe:// URL scheme)
- iOS: remote-notification background mode
- Add Capacitor plugins: haptics, keyboard, push-notifications,
  screen-orientation, share

Updated README with complete build/deploy instructions,
App Store listing suggestions, and icon generation guide.
2026-04-11 02:28:30 +02:00
Daniel
91d04f852f Fix mobile app bugs: package name, deprecated API, server check
- Fix AudioRecordingService ACTION_STOP to use com.pedshub.scribe
- Fix deprecated stopForeground(true) to STOP_FOREGROUND_REMOVE
- Fix launcher.js testServer: prevent double callback, fix onerror
  always reporting success (now correctly fails on unreachable servers)
- Update service comment from TWA to Capacitor
2026-04-11 02:22:32 +02:00
Daniel
7d55e64ca1 Add Capacitor native mobile app (PedScribe) for iOS + Android
New mobile/ directory with Capacitor project:
- Configurable server URL launcher (default: app.pedshub.com)
- Android: foreground service + wake lock for background recording
  (AudioRecordingService preserved from existing TWA)
- iOS: background audio mode + microphone permission
- App ID: com.pedshub.scribe
- Both platforms initialized and synced

Existing android/ TWA project untouched — this is a separate project.
Build: cd mobile && npx cap open android (or ios)
2026-04-11 02:18:06 +02:00
Daniel
639a5d2873 Add automatic ICD-10 and CPT billing code suggestions
New feature: after generating any clinical note, the app automatically
suggests relevant billing codes displayed as clickable chips below the output.

Backend (src/routes/billing.js):
- POST /api/suggest-codes endpoint analyzes note text
- Extracts diagnoses from Assessment section via regex
- Looks up ICD-10 codes: local common pediatric map (40+ conditions)
  first, then NLM Clinical Tables API for unknown terms
- Suggests CPT E/M codes based on note type, visit complexity,
  ROS/PE system counts, and MDM level estimation
- Supports: outpatient (new/established), well visit (age-based),
  ED, inpatient (admit/subsequent/discharge)

Frontend (public/js/app.js):
- suggestBillingCodes() renders collapsible card with ICD-10 and CPT chips
- Click any chip to copy the code to clipboard
- Shows E/M level assessment (diagnosis count, ROS, PE, MDM complexity)
- Disclaimer: "Suggestions only. Always verify codes."

Integration: called after note generation in all 6 tabs
(encounter, SOAP, sick visit, well visit, hospital course, chart review)
2026-04-11 01:50:17 +02:00
Daniel
e7eb695049 Add pediatric calculators: BP, BMI, growth, bilirubin, vitals, BSA, dosing
Calculators tab with 7 tools:
- BP Percentile (AAP 2017) with age/sex/height classification
- BMI Percentile (CDC 2000) with extended obesity classification
  (Class 1/2/3 using % of 95th percentile per CDC 2022)
- Growth Charts: weight-for-age, length-for-age, head circumference,
  weight-for-length (WHO/CDC LMS), Fenton preterm (22-50 weeks)
- Bilirubin: AAP 2022 phototherapy threshold + Bhutani nomogram
  with Nelson Table 137.1 risk factors for severe hyperbilirubinemia
- Vital Signs by Age (Harriet Lane) with quick reference formulas
  (estimated weight, min SBP, ETT size, maintenance fluids 4-2-1)
- Body Surface Area (Mosteller formula)
- Weight-Based Dosing with max cap and volume calculation

Fix growth chart sub-tab navigation (pills scoped separately from
top-level nav to prevent panel disappearing)
2026-04-09 17:56:30 +02:00
Daniel
3d5b77721c Replace all browser dialogs with modern modal, add OIDC admin UI
- Add reusable showConfirm() modal component (supports plain confirm,
  input prompt, danger styling, Enter key)
- Replace ALL 18 confirm() and prompt() calls across 8 JS files with
  showConfirm() modal: admin user actions, session revoke, document
  delete, template delete, milestone management, transcription settings
- Fix broken admin reset-password (btn was undefined in scope)
- Add OIDC/SSO configuration UI to Admin Panel (issuer, client ID/secret,
  button label, disable local auth toggle, callback URL display)
2026-04-09 02:43:23 +02:00
Daniel
719fe0533f Fix session revocation bug that could log out current device
- Fix: DELETE all other sessions query used empty string fallback when
  req.sessionId was undefined, causing id != '' to match ALL rows
  (including current session). Now skips deletion if sessionId unknown.
- Fix: Revoke All endpoint returns error if current session not identified
- Fix: var confirm shadowing window.confirm in password change handler
2026-04-09 02:33:00 +02:00
Daniel
85f9af4ffc Remove prompt() dialogs, breach warnings, cost display; fix 2FA disable UI
- Replace browser prompt() with inline UI for: 2FA disable (password field),
  admin password reset (inline input), admin test email (inline input)
- Remove all password breach warning UI (login, register, settings)
  Backend HIBP check endpoint remains but is no longer called from frontend
- Remove model cost display from dropdown and header badge
- Hide empty cost-badge element in header
- Fix model dropdown to flat list (no category grouping)
2026-04-09 02:27:55 +02:00
Daniel
55f8e172e6 FAQ page, dep security patches, model dropdown and UI fixes
- Add FAQ tab with accordion sections: Getting Started, AI & Models,
  Voice & Transcription, Saving & Export, Privacy & Security,
  Well Visit & Sick Visit, Learning Hub, Troubleshooting
- Documents how AI learns from physician edits (correction tracker)
- Fix FAQ accordion (CSP was blocking inline script, moved to app.js)
- Patch all 5 npm vulnerabilities: nodemailer 8.0.5, xmldom, basic-ftp,
  path-to-regexp (npm audit now reports 0 vulnerabilities)
- Remove model category grouping from dropdown (flat list, no optgroups)
- Fix model dropdown dark background on options (white bg, dark text)
- Update FAQ model guidance to reflect admin-managed model selection
2026-04-09 01:56:11 +02:00
Daniel
09193538fb v6.2: Session management, password change, audit logging, refine context, UI fixes
Security:
- Add session management: users can view/revoke active sessions in Settings
- Add password change in Settings (requires current password, HIBP check)
- Force logout all sessions on password reset
- Fix logout to destroy server-side session (was only clearing cookie)
- Add trust proxy for correct client IP in rate limiting and audit logs
- Add CORS support for multiple domains (CORS_ORIGINS env var)
- Add HIBP breach check endpoint and inline warnings on password fields

Audit logging:
- Add audit logging to all 24 PHI-handling endpoints across 13 route files
- Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud
- All fire-and-forget (no response delay)

AI improvements:
- Refine now includes original source material (transcript, notes, labs)
  so AI can reference the full input when modifying output
- Add correction tracking (trackAIOutput) to sick visit and well visit tabs
- Fix sickvisit missing from encounter save noteIdMap

UI fixes:
- Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay)
- Fix encounter recording: hide record button during recording (was showing two stop buttons)
- Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons
- Enlarge AI instructions textarea in Learning Hub CMS

Domain:
- Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
2026-04-08 20:27:45 +02:00
Daniel
b3b54c9a6c Add developer guide, expand admin model management docs
- New docs/developer-guide.md: full walkthrough of frontend SPA architecture,
  backend middleware stack, database layer, AI integration, settings system,
  how to add features/routes/tables, key design decisions, file references
- Expand ai-providers.md: detailed admin model management (add custom models
  with ID/name/cost/category, discover from provider, enable/disable, set default)
- Update README docs index
2026-04-04 23:02:02 +02:00
Daniel
869fa14a77 v6.1: Turnstile bot protection, LiteLLM provider, PPTX tables, audio backup fixes, docs
- Add Cloudflare Turnstile to login, register, and password reset forms
- Switch AI provider to LiteLLM, transcription to OpenAI Whisper
- Change domain to scribe.pedshub.com
- Fix PPTX export: add tables, bold/italic, numbered lists, code blocks, blockquotes
- Fix announcement banner close button (CSP was blocking inline onclick)
- Fix auth middleware: empty Bearer token now falls through to cookie auth
- Fix audio backups: only save on transcription failure, stop auto-deleting on success
- Soften AI correction injection to prevent model hallucination from correction history
- Fix LiteLLM TTS model name handling (no incorrect openai/ prefix)
- Expand AI instructions textarea in Learning Hub CMS
- Update README for v6 with all features and providers
- Add comprehensive docs/: architecture, API reference, database schema,
  authentication, AI providers, speech, learning hub, configuration, deployment
2026-04-04 22:56:24 +02:00
ifedan-ed
783679a3f7 feat: Add model search, testing, and TTS/STT/embedding management to admin
- Fix model search for all providers: Bedrock now falls back to built-in
  list (with live ListFoundationModels attempt), Azure returns built-in list
- Add Test button on every model row (built-in, discovered, custom) that
  sends a live prompt and shows response + latency in a toast
- Add TTS management section: search voices from provider API (Google TTS
  voices.list, LiteLLM /v1/models, ElevenLabs /v1/voices), Set as Default
  writes tts.voice/tts.model to DB, runtime respects DB override
- Add STT management section: search models from provider (Gemini, Whisper,
  LiteLLM, OpenAI, local), Set as Default writes stt.model to DB, runtime
  respects DB override in transcribe.js
- Add Embedding models section: search from provider (LiteLLM, Vertex,
  OpenAI), Set as Default writes embeddings.model+dimensions to DB,
  embeddings.js respects DB override
- Add record-and-transcribe STT test (browser MediaRecorder)
- Add TTS synthesize-and-play test (returns base64 audio)
- Add embedding generate test (shows dims + vector sample)
- Expand PUT /config/:key(*) whitelist to include tts., stt., embeddings.
- Add @aws-sdk/client-bedrock as optional dependency for live Bedrock discovery
2026-04-03 19:55:11 +00:00
ifedan-ed
8bd5cbd690 v2.2: Remove milestone admin UI, add CMS content refresh button
REMOVED:
- Milestone editing UI from Admin Panel (per user request)
- Milestones will be managed via hardcoded static data only
- Kept backend routes and database support for future use

ADDED:
- Refresh button in Learning Hub CMS content list
- Manual refresh for AI-generated content updates
- Better discoverability of content refresh functionality

FIXES:
- AI learning content now has visible refresh button
- Users can manually refresh content list after AI generation
- Cleaner admin panel without milestone management clutter

NOTE:
- Developmental milestones still work via static fallback
- Edit milestones by modifying public/js/milestonesData.js
- Backend API still supports milestone management if needed later
2026-04-01 18:16:00 +00:00
ifedan-ed
fdf29b5ed7 v2.1: Add visible bulk import UI for developmental milestones
NEW FEATURES:
- Bulk Import button in Admin Panel → Developmental Milestones section
- "Import Default Milestones Data" button appears when database is empty
- "Re-import All" button to clear and re-import all static data
- Visible notice when no milestones exist with one-click import

IMPROVEMENTS:
- Auto-shows empty state notice when database has no milestones
- Backend bulk-import endpoint now supports clearExisting parameter
- Imports ALL age groups from static data (birth to 11 years)
- Better UX - admin doesn't need CLI to populate milestone data

FIXES:
- Makes milestone admin editing feature discoverable and usable
- No need to manually run import script anymore
2026-04-01 18:06:42 +00:00
ifedan-ed
dc2e000e88 v4: Fix milestones display + add OpenID auth + 100MB PDF support
FIXES:
- Milestones now show correctly on encounter page (use static fallback if DB empty)
- Static data preserved as MILESTONES_DATA_STATIC for compatibility
- Database-driven milestones still work (admin can edit via CMS)

NEW FEATURES:
- OpenID Connect (OIDC) authentication support (PocketID, Keycloak, Azure AD, etc.)
- Comprehensive setup guide: OPENID_SETUP.md
- Auto-linking existing users by email on SSO login
- Multiple PDF upload support in Learning Hub (up to 10 files)
- 100 MB per file limit (was 20 MB)
- Full PDF content used for AI generation
- Embeddings use first ~8K chars for semantic search

IMPROVEMENTS:
- Updated UI to show multiple file selection with list
- Drag-and-drop supports multiple files
- Better file upload validation and error handling
- Added clarifying comments about embedding truncation
2026-04-01 17:59:51 +00:00
ifedan-ed
540347c015 v6: Use transformers.js v2.0.0 (proven worker compatibility)
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
2026-04-01 00:32:23 +00:00
Daniel Onyejesi
ba6724083c Bump docker-compose to v6 2026-03-31 20:09:42 -04:00
ifedan-ed
17557fa0f8 Version 5.0.0 - Browser Whisper fix with self-hosted v2.6.2
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
2026-03-31 23:32:07 +00:00
ifedan-ed
39d9a1f9e8 Update docs for v3 truly self-hosted setup 2026-03-31 23:12:46 +00:00
ifedan-ed
0dc6812f38 FIX: Browser Whisper - 100% self-hosted, zero CDN dependencies
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
FINAL WORKING SOLUTION:

Previous attempts failed because:
- transformers.js v2.17.2 is ES module-only
- Module workers require complex CSP and external imports
- importScripts() doesn't work with ES modules

Solution:
- Use transformers.js v2.6.2 (has worker-compatible UMD build)
- Bundle library + models, serve entirely from our server
- Classic worker with importScripts() - no CSP issues

What's self-hosted:
-  transformers.min.js (760KB) - at /models/transformers.min.js
-  Whisper models (42MB) - at /models/Xenova/whisper-tiny.en/

Worker loads:
1. importScripts('/models/transformers.min.js') - OUR SERVER
2. Loads models from /models/ - OUR SERVER
3. ZERO external network calls
4. Works in any network (firewalled, air-gapped, etc.)

This is the production-ready, truly offline solution.
2026-03-31 23:12:21 +00:00
ifedan-ed
932ddc3b0a Fix Browser Whisper: Use ES module worker with CDN library
Issue: transformers.js is an ES module package and cannot be loaded
with importScripts() in classic workers.

Solution:
- Changed to module worker (type: 'module')
- Import transformers.js from CDN as ES module
- Models (42MB) still served from local server at /models/

Trade-off:
- Library (900KB): Loads from cdn.jsdelivr.net once, cached
- Models (42MB): Self-hosted, served from /models/ (no CDN)

This is necessary because:
1. @xenova/transformers is ES module-only (package.json: "type": "module")
2. ES modules cannot use importScripts()
3. Module workers require HTTPS for imports
4. CDN is HTTPS and cacheable

If CDN is blocked:
- Use Web Speech API (with privacy warnings)
- OR use Server Transcription (Vertex AI/AWS)

Models remain self-hosted as they're 40MB+ and contain the AI.
2026-03-31 22:55:28 +00:00
ifedan-ed
3c6acb3eb5 Version 3.0.0 - Milestones admin + transcription options 2026-03-31 22:12:58 +00:00
ifedan-ed
d53b469717 Add comprehensive transcription options documentation 2026-03-31 21:58:17 +00:00
ifedan-ed
196f4432f0 Add Web Speech Recognition option for real-time streaming
Provides two transcription options:

1. Browser Whisper (Offline, Batch) - RECOMMENDED
   - 100% offline, zero network calls
   - HIPAA-compliant, audio never leaves device
   - Highest accuracy (Whisper)
   - Processes after recording (batch mode)
   - Models self-hosted, bundled in v2

2. Web Speech API (Real-time, Streaming) - EXPERIMENTAL
   - Real-time transcription (see words as you speak)
   - Uses browser's built-in speech recognition
   - ⚠️ Sends audio to cloud (Chrome/Edge → Google)
   - ⚠️ NOT HIPAA-compliant
   - Requires user consent with clear warnings

Features:
- Settings UI for both options
- Clear privacy warnings for Web Speech
- Mutual exclusion (only one active at a time)
- Browser detection shows which provider is used
- Confirmation dialog before enabling Web Speech

Use Cases:
- Clinical/HIPAA: Use Browser Whisper only
- Personal/Non-clinical: Can use Web Speech for real-time feedback
- Maximum privacy: Browser Whisper (offline)
- Maximum speed: Web Speech (if privacy not required)

Implementation:
- speechRecognition.js: Web Speech API wrapper
- transcriptionSettings.js: Settings UI handler
- Privacy info displayed per browser

User can choose based on their privacy vs. speed preference.
2026-03-31 21:57:24 +00:00
ifedan-ed
a7dd08c9d1 Add admin dashboard for developmental milestones management
Features:
- Admin can add, edit, and delete developmental milestones via dashboard
- Milestones stored in PostgreSQL (developmental_milestones table)
- Client-side loads milestones from API instead of static file
- Import script to migrate existing static data to database
- Organized by age group and domain
- Supports sorting and filtering

Admin UI:
- New section in Admin panel for milestone management
- Filter by age group
- Add/Edit modal with validation
- Delete with confirmation
- Auto-complete for age groups and domains

API Endpoints:
- GET /api/milestones-data - Public endpoint for authenticated users
- GET /api/admin/milestones - List all milestones (admin only)
- GET /api/admin/milestones/meta - Get age groups and domains
- POST /api/admin/milestones - Create milestone
- PUT /api/admin/milestones/:id - Update milestone
- DELETE /api/admin/milestones/:id - Delete milestone
- POST /api/admin/milestones/bulk-import - Bulk import

Usage:
1. Run import script: node scripts/import-milestones.js
2. Access Admin dashboard → Developmental Milestones section
3. Add/Edit/Delete milestones as needed
2026-03-31 20:55:41 +00:00
ifedan-ed
2d1723f14a Change version to v2.0
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
2026-03-31 20:51:50 +00:00
ifedan-ed
9d817cd9f5 v18: Self-hosted Browser Whisper (zero CDN dependencies)
BREAKING FIX: Browser Whisper now fully self-contained

Previous issue:
- Loaded transformers.js from cdn.jsdelivr.net
- Downloaded models from cdn-lfs.huggingface.co
- Failed in corporate/clinical networks with firewall
- Stuck at "Initializing..." with no progress

Solution:
- Bundle transformers.js library (~876KB)
- Bundle Whisper tiny.en model (~42MB)
- Serve everything from local server
- Works in ANY network environment

Changes:
- whisperWorker.js: Load transformers from /models/ instead of CDN
- Dockerfile: Download models during Docker build
- Add download script for local dev
- Add comprehensive setup documentation

Docker image size: +~42MB (one-time cost, runtime benefit)

Tested: Works on unrestricted and firewalled networks
2026-03-31 20:02:11 +00:00
ifedan-ed
b9ceca8f20 v17: Production release with all fixes
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
Complete Feature Set:
 Vertex AI Embeddings - Semantic search for Learning Hub
 Voice Preferences - Per-user STT model + TTS voice selection
 Browser Whisper - Optional client-side transcription with graceful CDN fallback
 TTS Preview - Working for all voices including server default
 Audio Backups - Automatic recording backup with 24h retention
 S3 Documents - Upload/manage documents (AWS, B2, MinIO)
 Learning Hub - AI content generation from PDFs/Nextcloud

Fixed Issues:
- TTS preview button now working (correct event listener)
- Browser Whisper shows clear warning if CDN blocked
- Server default voice preview working
- Graceful fallback to server transcription
- User-friendly error messages throughout

Documentation:
- FEATURES_EXPLAINED.md - Complete feature guide
- BROWSER_WHISPER_TROUBLESHOOTING.md - CDN blocking troubleshooting
- EMBEDDINGS_SETUP.md - Vector search setup guide

Production Ready:
- All features tested
- Clear error handling
- Graceful degradation
- HIPAA-compliant options available
2026-03-31 16:20:41 +00:00
ifedan-ed
c38ce9445e v16: Make Browser Whisper CDN failure graceful with clear warnings
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
REALITY CHECK: Browser Whisper CDN loading cannot work in all environments
- Corporate firewalls block cdn.jsdelivr.net
- Network proxies filter JavaScript CDN
- Workers + importScripts + cross-origin = blocked by CSP/CORS

SOLUTION: Graceful degradation
- Clear user-friendly error messages
- Automatic fallback to server transcription
- Warning banner in Settings if CDN blocked
- Comprehensive troubleshooting documentation

Changes:
- browserWhisper.js: Show toast on worker error, fallback gracefully
- app.js: Display CSP warning banner on preload failure
- settings.html: Add warning about network/firewall requirements
- BROWSER_WHISPER_TROUBLESHOOTING.md: Complete guide for users

Key Message:
Browser Whisper is OPTIONAL. Server transcription (Google/AWS/OpenAI)
is the primary method and works everywhere. Browser Whisper is a
privacy-focused bonus feature that requires CDN access.

User Experience:
- If CDN works: Great! Browser Whisper available
- If CDN blocked: No problem! Server transcription works perfectly
- Clear messaging: User knows what to expect
2026-03-31 16:18:46 +00:00
ifedan-ed
d1f44c2f41 Fix TTS preview for 'Server default' voice option
- Allow empty voice value to preview server default
- Display 'server default' in preview text
- Clears user preference (sets to null) when testing default
2026-03-31 16:15:41 +00:00
ifedan-ed
0d685070d1 v16: TTS Preview + Browser Whisper fixes with correct CSP
Critical fixes from v15:
- TTS Preview: Fixed event listener (tabChanged not tab-loaded)
- Browser Whisper: Fixed CSP to allow CDN loading (unsafe-eval + jsdelivr)
- Worker: Added error handling and logging for importScripts
- Voice Preferences: Multiple init paths with fallbacks
- Debug logging throughout for troubleshooting

Changes:
- server.js: CSP allows unsafe-eval, cdn.jsdelivr.net in connectSrc
- voicePreferences.js: Correct event name, immediate init fallback
- whisperWorker.js: Try-catch on importScripts, better errors
- app.js: Enhanced preload error handling

This version should actually work - previous bugs were:
1. Wrong event name prevented TTS preview init
2. CSP blocked worker CDN loading
2026-03-31 16:04:25 +00:00
ifedan-ed
88036a45c4 v15.1: CRITICAL FIX - TTS Preview + Browser Whisper actually working now
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
ROOT CAUSES FOUND AND FIXED:
1. TTS Preview not working: voicePreferences.js listening for wrong event
   - Was: 'tab-loaded' (never dispatched)
   - Now: 'tabChanged' (correct event name used by app.js)
   - Added immediate init if page already loaded
   - Added 500ms delay for DOM readiness

2. Browser Whisper CDN blocked: CSP too restrictive
   - Added 'unsafe-eval' to scriptSrc (required by transformers.js)
   - Added cdn.jsdelivr.net to connectSrc (worker importScripts)
   - Added childSrc directive for worker script loading
   - Better error messages in worker

3. Worker loading errors: Now logged with specific reasons
   - importScripts wrapped in try-catch
   - Posts error message to main thread
   - Verifies transformers object exists after load

Testing:
- TTS Preview should now work when clicking Settings tab
- Browser Whisper should load from CDN (or show specific error)
- Console logs will show exact init sequence
2026-03-31 16:00:10 +00:00
ifedan-ed
ee3729eb57 v15: Fix TTS preview + Browser Whisper preload with extensive debugging
BREAKING FIXES:
- TTS Preview: Added event.preventDefault(), console logging, proper init check
- Browser Whisper: Complete console logging pipeline, error handling, progress tracking
- Voice Preferences: DOMContentLoaded fallback, explicit button click handlers
- Whisper Worker: Console logs at every step, better error messages

Debugging Features:
- Console logs show: button clicks, init events, progress updates, errors
- Progress tracking: [WhisperWorker] Progress: model.bin 47%
- Error messages: Specific failure reasons (not generic failures)
- Timeout warnings: 30s check for stuck downloads

Audio Backup Confirmed:
- Deletes immediately on successful transcription (line 621-624 app.js)
- NOT after 24 hours - 24h is server retention limit for failed transcriptions
- User was correct - this is working as designed

How to Debug:
1. Open DevTools → Console (F12)
2. Click button
3. Watch for [VoicePrefs] or [BrowserWhisper] logs
4. Check Network tab for actual downloads
5. Report what you see in console
2026-03-31 15:28:38 +00:00
ifedan-ed
9016af8fe2 Fix TTS preview + Browser Whisper preload, add comprehensive docs
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
Fixes:
- TTS preview: Better error handling, console logging, empty value check
- Browser Whisper: Add progress logging, 30s timeout warning, better UX
- Voice preferences: Clearer error messages

New Documentation:
- FEATURES_EXPLAINED.md: Complete guide to all v14 features
  - Audio backups explained (works every recording, not just on failure)
  - S3 integration setup guide (AWS, B2, MinIO)
  - Learning Hub default path explained (AI file picker starting folder)
  - Browser Whisper troubleshooting (download progress tracking)
  - TTS preview debugging steps
  - Comprehensive troubleshooting guide
2026-03-31 15:13:53 +00:00
ifedan-ed
364b686619 Add per-user voice preferences (STT model + TTS voice selection)
- NEW: User preferences for STT model and TTS voice
- Database: stt_model and tts_voice columns in users table
- UI: Voice Preferences section in Settings with dropdowns
- API: /api/user/preferences (GET/POST) + /preferences/options
- Transcribe: Respects user's STT model (Google, LiteLLM)
- TTS: Respects user's TTS voice (Google, LiteLLM, OpenAI, ElevenLabs)
- Preview: Test TTS voice before saving
- Available models/voices auto-detected from provider config
2026-03-31 14:47:00 +00:00
ifedan-ed
5c157cf6aa Add embeddings setup documentation 2026-03-31 14:37:46 +00:00
ifedan-ed
ea213d8baf Add Vertex AI embeddings + semantic search for Learning Hub
- New: Vector search with pgvector extension (cosine similarity)
- Embeddings: Vertex AI text-embedding-005 (768 dims, HIPAA-eligible)
- 3 search modes: keyword, semantic, hybrid (best of both)
- Auto-generate embeddings on content create/update
- Admin endpoints: /api/admin/learning/embeddings/generate (backfill), /status
- User endpoints: /api/learning/search/semantic, /search/hybrid
- Falls back to OpenAI embeddings if Vertex not configured
- Supports LiteLLM proxy routing

Models tested:
- vertex_ai/text-embedding-005 (768 dims, English+code) 
- vertex_ai/gemini-embedding-001 (3072 dims, multilingual) 
- vertex_ai/text-multilingual-embedding-002 (768 dims) 
2026-03-31 14:36:49 +00:00
ifedan-ed
9e43e12cfc Update docker-compose to use v14 2026-03-31 14:21:25 +00:00
ifedan-ed
d181430d7a v14: Browser Whisper transcription (WebAssembly, client-side, HIPAA-safe) 2026-03-31 14:16:06 +00:00
Daniel Onyejesi
d1a7c97ecc Add browser-side Whisper transcription (local, zero network, HIPAA-safe)
- whisperWorker.js: Web Worker running @xenova/transformers Whisper in WASM
- browserWhisper.js: main-thread manager — audio→Float32 conversion, worker lifecycle
- transcribeAudio() checks BrowserWhisper.isEnabled() first, falls back to server
- Settings UI: enable/disable, model picker (tiny/base/small), pre-download button
- CSP: add wasm-unsafe-eval, cdn.jsdelivr.net, HuggingFace CDN domains
- Default: whisper-tiny.en (~39MB, ~2-3s per clip)
2026-03-31 07:30:17 -04:00
Daniel Onyejesi
0ce2735315 Fix Read aloud stop button: findReadButton now finds data-action=speak buttons
The button was always returning null because it searched for onclick=speakText
but all output cards use data-action="speak" data-target="id". Now checks
data-action first so the button correctly toggles to Stop during playback.
2026-03-30 21:37:00 -04:00
Daniel Onyejesi
f13eb05218 Emails: true markdown/Resend style — plain white, no card, clean type
TTS: prefix model with openai/ so LiteLLM routes correctly

Email: horizontal rules instead of card border, spacious padding,
wordmark + divider + body + divider + footer. Reads like a doc.
TTS: tts-1 becomes openai/tts-1 automatically unless already prefixed.
2026-03-30 20:53:11 -04:00
Daniel Onyejesi
58094f6298 Clean email templates (Linear/Resend style) + LiteLLM Gemini STT
Emails: white card, clean typography, dark button, no gradients.
Same minimal aesthetic as Linear/Resend/Notion emails.
Verify page responses also updated to match.
2026-03-30 20:51:11 -04:00
Daniel Onyejesi
61ed414785 Fix LiteLLM STT: use chat/completions with Gemini audio instead of broken /audio/transcriptions
LiteLLM /audio/transcriptions gives 'Unmapped provider' for Vertex AI Chirp.
The correct approach: use /v1/chat/completions with a Gemini model and send
audio as base64 input_audio content block — Gemini natively understands audio.
Set LITELLM_STT_MODEL to your Gemini model name (e.g. gemini-2.5-flash).
2026-03-30 19:52:58 -04:00
ifedan-ed
6b6bf728d5 v13: Increase JSON limit to 10MB, client-side size check for chart review
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
- Raise express.json limit from 1MB to 10MB — handles large chart reviews
  with many notes (50 full clinic notes ≈ 600KB, well within new limit)
- Client-side: warn user if payload >8MB, friendly toast if >30 notes
- Bump to v13.0.0
2026-03-30 22:41:17 +00:00
ifedan-ed
22d9a8ec29 Update package-lock.json
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
2026-03-30 20:39:11 +00:00
ifedan-ed
841fe0c264 Fix chart review: prompt selection by top-level type, include per-visit labs
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
- Bug 1: When user selected "Outpatient" review type but had any subspecialty
  visit cards filled in, the backend ignored the top-level type and switched to
  the subspecialty prompt. Fixed: top-level type dropdown is now definitive.
  Per-visit note types only control data formatting/labeling, not prompt selection.

- Bug 2: Labs entered in a visit card were silently dropped for outpatient and
  subspecialty visits (only ED visit labs were included). Fixed: per-visit labs
  now appear immediately after their visit content, labeled with the visit date.

- Improved lab labeling: visit labs are labeled "Labs from this visit (date)"
  and the separate labs section is labeled "ADDITIONAL LABS (not tied to a
  specific visit)" so the AI clearly distinguishes them.
2026-03-30 20:30:07 +00:00
ifedan-ed
ca645fe941 v12: LiteLLM voice support, Vertex AI, model discovery, APK crash fix
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
- LiteLLM: chat, TTS (tts-1), STT (whisper-1) via proxy
- Google Vertex AI: direct chat, Gemini STT, Google Cloud TTS
- Admin model management: discover/search/toggle/custom models
- TTS shows actual provider in toast (not hardcoded ElevenLabs)
- APK crash fix: proper PNG splash + mipmap icons
- Server-side audio backups with gzip compression
- Expandable AI correction viewer
- Zero-config browser speech recognition
- Bump to v12.0.0
2026-03-30 15:38:59 +00:00
ifedan-ed
1c23f2dc12 Show TTS provider in toast, support full LiteLLM model paths
- TTS response now includes X-TTS-Provider header (google-tts, litellm/model, elevenlabs)
- Frontend reads header and shows actual provider in toast instead of hardcoded "Adam/ElevenLabs"
- CORS exposes X-TTS-Provider header so frontend can access it
- Updated .env.example: clarify that LITELLM_TTS_MODEL and LITELLM_STT_MODEL
  can be either the model_name alias OR the full provider/model path depending
  on your LiteLLM config (important for BAA compliance routing)
2026-03-30 13:37:20 +00:00
ifedan-ed
567450b51e Fix LiteLLM STT default: use whisper-1 instead of vertex_ai/chirp
vertex_ai/chirp does not work via LiteLLM's audio transcription proxy.
Changed default LITELLM_STT_MODEL from vertex_ai/chirp to whisper-1.
Updated .env.example documentation to match.
2026-03-30 13:28:49 +00:00
Daniel Onyejesi
a2263d9530 Fix STT/TTS properly per LiteLLM docs
STT: Vertex AI Chirp not supported via LiteLLM proxy (confirmed by docs).
     Now uses Gemini directly (transcribeGoogle.js) — auto-detected when
     GOOGLE_VERTEX_PROJECT is set, fallback to AWS then OpenAI.

TTS: LiteLLM Vertex TTS DOES work but requires the model_list ALIAS
     (tts-1) not the underlying path (vertex_ai/text-to-speech).
     Also pass voice param — LiteLLM supports Google Cloud voice names.
     Auto-detected when LITELLM_API_BASE is set.
2026-03-30 07:28:47 -04:00
Daniel Onyejesi
b40dc5584b Fix STT: AWS Transcribe takes priority over LiteLLM in auto-detect
LiteLLM's atranscription has a routing bug with Vertex AI Chirp proxy.
AWS Transcribe is already configured and working. Auto-detect now prefers
AWS over LiteLLM. Use TRANSCRIBE_PROVIDER=litellm to force LiteLLM.
2026-03-29 22:38:06 -04:00
Daniel Onyejesi
b856a6c1da Fix STT/TTS model paths: use exact vertex_ai/ paths for LiteLLM routing
LiteLLM aliases (whisper-1, tts-1) don't resolve in audio endpoints —
only chat completions support alias routing. Use exact paths:
- STT default: vertex_ai/chirp (was whisper-1)
- TTS default: vertex_ai/text-to-speech (was tts-1)
Override via LITELLM_STT_MODEL / LITELLM_TTS_MODEL in .env.
2026-03-29 22:28:32 -04:00
Daniel Onyejesi
e2e7943dcb v10: TTS/STT axios fixes, better error logging, stop button
- TTS: switch to axios, drop voice param (configured in LiteLLM per model)
- STT: log full LiteLLM error body so 500s are diagnosable in logs
- TTS: same error detail logging
- Fix 'ElevenLabs unavailable' toast to generic 'TTS unavailable'
- Add red Stop button to encounter recording UI
2026-03-29 22:12:02 -04:00
Daniel Onyejesi
e6091c299f Fix TTS axios/Vertex, generic toast, add stop button to encounter
- TTS: switch from OpenAI SDK to axios (same fix as STT), drop voice
  param since it's configured inside LiteLLM per model
- Fix 'ElevenLabs unavailable' toast shown even when provider is LiteLLM
- Add dedicated red Stop button to encounter recording UI
2026-03-29 22:09:56 -04:00
Daniel Onyejesi
98fddca1e5 Fix LiteLLM STT: remove prompt/response_format unsupported by Vertex Chirp
Vertex AI Chirp via LiteLLM rejects/hangs when 'prompt' and
'response_format' are included — these are OpenAI Whisper-only params.
Send only file + model for LiteLLM/Chirp.
2026-03-29 21:54:12 -04:00
Daniel Onyejesi
0e6a853f86 Revert STT auto-detect: LiteLLM handles audio when LITELLM_API_BASE is set 2026-03-29 21:48:44 -04:00
Daniel Onyejesi
96dc40fd0b Fix STT auto-detection: don't route to LiteLLM unless LITELLM_STT_MODEL is set
Having LITELLM_API_BASE for AI text was auto-routing audio transcription
through LiteLLM even when the proxy has no Whisper model configured,
causing silent hangs. Now LiteLLM STT only activates when LITELLM_STT_MODEL
is explicitly set. Falls back correctly to AWS Transcribe when configured.
2026-03-29 21:42:24 -04:00
Daniel Onyejesi
ce7d0e749d Fix LiteLLM STT: use axios directly instead of OpenAI SDK
OpenAI SDK's audio.transcriptions.create() hangs with LiteLLM
(no timeout, SDK-level incompatibility with multipart handling).
Use axios + form-data directly with 120s timeout — same approach
as ElevenLabs TTS. Handles both {text:"..."} and plain string responses.
2026-03-29 21:32:30 -04:00
Daniel Onyejesi
f8d865a0a9 Bump to v10 — new tag forces server to pull updated image 2026-03-29 20:14:36 -04:00
Daniel Onyejesi
e513298f6a Fix: bump SW cache to v12, switch JS/CSS to network-first
Old pedscribe-v11 cache was serving stale admin.js to browsers
even after server updates. New cache name forces old SW to
deactivate and all clients to get fresh JS on next load.
Also switch JS/CSS from stale-while-revalidate to network-first
so code fixes are picked up immediately.
2026-03-29 20:02:34 -04:00
Daniel Onyejesi
ae3ec64c92 Fix model management: empty LiteLLM list, always reload panel, clear-all
- LITELLM_MODELS = [] — no hardcoded models, global selector now only
  shows what admin has actually added via Search API
- getAvailableModelsWithOverrides: for LiteLLM returns only custom list
- Remove toggle safety check — admin can disable any/all models freely
- Admin panel always reloads on tab open (was cached, showing stale data)
- Add 'Clear all models' button for LiteLLM to wipe and start fresh
- Add POST /config/models/clear-all endpoint
2026-03-29 19:32:09 -04:00
Daniel Onyejesi
65e0317ae6 Add LiteLLM STT and TTS support
- TRANSCRIBE_PROVIDER=litellm routes audio to LiteLLM /audio/transcriptions
- TTS_PROVIDER=litellm routes to LiteLLM /audio/speech
- Both auto-detect when LITELLM_API_BASE is set (no extra config needed)
- LITELLM_STT_MODEL (default: whisper-1), LITELLM_TTS_MODEL (default: tts-1)
- LITELLM_TTS_VOICE (default: alloy) — alloy/echo/fable/onyx/nova/shimmer
- ElevenLabs still works if ELEVENLABS_API_KEY is set and TTS_PROVIDER=elevenlabs
- Health endpoint now reports tts provider
2026-03-29 19:11:16 -04:00
Daniel Onyejesi
6bb062561f Fix admin model management: route ordering, LiteLLM built-ins, auto-select
- Root cause: PUT /config/:key(*) wildcard was registered before
  /config/models/toggle and /config/models/default, intercepting them
  and returning "value is required" (body had modelId not value)
- Fix: move all model-specific PUT routes before the wildcard
- LiteLLM: return empty built-in list with discovery hint (hardcoded
  models don't match user's proxy — must use Search API)
- After adding a discovered model: auto-select it in the default dropdown
- GET /config/models now returns defaultModel so dropdown pre-selects it
2026-03-29 18:42:09 -04:00
Daniel Onyejesi
29f1a9b860 v9.1: Add Google Vertex AI + LiteLLM support, admin model management panel
- Add Vertex AI provider (Gemini models via @google-cloud/vertexai SDK)
- Add LiteLLM proxy support (OpenAI-compatible, routes to any provider)
- Admin panel: model search/discover from provider API, enable/disable, custom models, set default
- New endpoints: /config/models/discover, /config/models/add-discovered, /config/models/default
- Updated models.js with VERTEX_MODELS and LITELLM_MODELS lists
- Updated health endpoint with vertex + litellm status
2026-03-29 10:32:45 -04:00
ifedan-ed
a1e5830192 Fix APK crash: replace XML splash with PNG, add real mipmap launcher icons
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
Root cause: TWA LauncherActivity.onCreate calls Bitmap.createBitmap on the
splash drawable — the XML layer-list with only a color fill had 0x0 intrinsic
dimensions, causing IllegalArgumentException: "width and height must be > 0".

Fixes:
- Replace splash.xml with splash.png (384x384 blue circle with P logo)
- Add proper PNG launcher icons at all 5 density buckets (mdpi through xxxhdpi)
- Change android:icon from @drawable to @mipmap for proper icon resolution
2026-03-29 11:07:10 +00:00
ifedan-ed
296dd1f8f1 Server-side audio backups with compression, viewable AI corrections
Audio Backups:
- New audio_backups table in PostgreSQL (bytea, gzip compressed)
- POST /api/audio-backups — upload with gzip compression (level 6)
- GET /api/audio-backups — list user's backups
- GET /api/audio-backups/:id/audio — download decompressed audio
- DELETE /api/audio-backups/:id — delete backup
- Auto-cleanup every hour (24h expiry)
- Frontend saves to server first, falls back to IndexedDB
- Settings shows source badge (server/local) per backup

AI Corrections:
- Corrections list is now expandable — click to view original vs corrected
- Shows red "Original" and green "Corrected to" sections
- Click arrow to expand/collapse each correction
- Date shown on each correction
2026-03-29 10:56:31 +00:00
ifedan-ed
3ff31868f7 Fix Android crash: use resource references for TWA colors instead of inline hex
The TWA LauncherActivity crashed with Resources$NotFoundException (0xffffffff)
because android:value with hex color strings is not supported by
androidbrowserhelper — it expects android:resource pointing to color resources.

- Created res/values/colors.xml with all app colors
- Changed AndroidManifest.xml to use android:resource="@color/..."
- Updated styles.xml to reference color resources
2026-03-29 10:47:27 +00:00
ifedan-ed
da81abcffc Security: remove auth debug logging that exposed emails and responses
Removed console.log statements in auth.js that logged email addresses
and auth API responses to browser console. Final cleanup for v9.
2026-03-29 10:41:35 +00:00
ifedan-ed
56d99e67b3 Remove admin model management panel — models use global selector instead
The admin model management (enable/disable, custom models, default override)
had persistent rendering issues. Removed the UI panel — models are managed
via the global model selector in the header, which works reliably. Backend
API endpoints for model config are retained for future use.
2026-03-29 02:30:29 +00:00
ifedan-ed
e58876ddd9 Zero-config speech: skip upload when no transcription API, use browser speech directly
- Add GET /api/transcribe/status endpoint — returns whether any server
  transcription provider (Whisper/AWS/Local) is configured
- Frontend checks status on login via checkTranscribeStatus()
- When no provider configured: recording stops instantly, keeps live
  Web Speech API text, shows friendly toast — no error, no upload wait
- Works in encounter, dictation, and SOAP tabs
- App now works fully out-of-the-box with just an AI provider key
2026-03-29 02:09:53 +00:00
ifedan-ed
fe632985c1 Fix: admin models loading, clear refine/instructions on New, bigger HPI areas, unique labels
- Admin models: reset modelsLoaded flag on error so retry works
- Admin default model: fix redundant fetch race condition
- clearTab: now clears refine inputs, instructions, and demographic fields
- Encounter/dictation clear buttons: also clear refine input
- SOAP: instructions textarea already cleared by clearTab (soap-instructions)
- Encounter HPI: bigger transcript (400px) and output (600px) text areas
- Unique label enforcement: 409 error if saving with duplicate label
2026-03-29 01:35:45 +00:00
ifedan-ed
32618032b0 Revert chunk size to 8KB — larger sizes cause AWS deserialization errors
Keep 8KB CHUNK_SIZE (proven stable) but replace 10ms setTimeout delay
with a microtask break every 16 chunks. This avoids the AWS SDK
"Deserialization error: inspect {error}.\$response" while still
eliminating the ~1.25s/MB artificial delay from the old 10ms sleep.
2026-03-29 01:21:43 +00:00
ifedan-ed
bc22f80e25 Optimize transcription speed: remove artificial delays, add timing
- AWS Transcribe: remove 10ms delay between chunks (was adding ~1.25s/MB),
  increase chunk size from 8KB to 32KB (AWS max per frame)
- Add detailed timing logs (ffmpeg, streaming, total) for diagnostics
- OpenAI Whisper: use response_format='text' for faster response parsing
- Frontend: show transcription time in toast, request 16kHz sample rate,
  increase bitrate to 32kbps Opus (better quality, still small files)
- Return duration in API response for all providers
2026-03-29 00:59:30 +00:00
ifedan-ed
92b1d25f19 Fix APK signing: use apksigner directly instead of broken r0adkll action
The r0adkll/sign-android-release@v1 hardcodes build-tools 29.0.3 which
isn't available. Now uses apksigner from the latest installed build-tools
directly with zipalign + sign + verify steps.
2026-03-29 00:51:42 +00:00
ifedan-ed
67362212f6 Fix APK build: add appcompat dependency for Theme.AppCompat 2026-03-29 00:46:51 +00:00
ifedan-ed
ed9c767300 Fix APK build: use Gradle setup action, generate proper wrapper
The gradlew stub and missing gradle-wrapper.jar caused CI build to fail.
Now uses gradle/actions/setup-gradle@v4 to install Gradle, then generates
wrapper before building. Also renames signed APK and uploads both signed
and unsigned to GitHub Releases.
2026-03-29 00:41:42 +00:00
ifedan-ed
1478ce7d86 Add SHA256 fingerprint to assetlinks.json for TWA domain verification 2026-03-29 00:32:58 +00:00
ifedan-ed
1ff0f9760d v9: APK hardening, service worker caching, admin model validation, Docker v9
- APK: Add WAKE_LOCK, BOOT_COMPLETED, ACCESS_NETWORK_STATE permissions
- APK: Disable allowBackup for medical data security
- APK: AudioRecordingService now acquires wake lock, has stop action in notification
- Serve /.well-known/assetlinks.json for TWA domain verification
- Service worker: cache app shell, stale-while-revalidate for assets, network-first for API
- Admin model management: validate model ID format, prevent built-in conflicts, audit toggle actions, prevent disabling all models
- Bump version to v9.0.0, Docker tag to v9
2026-03-28 23:53:39 +00:00
Daniel Onyejesi
4e5b6fed5a Add admin model management dashboard — enable/disable, custom models, default override
- Full model management UI in admin panel: toggle models on/off, add custom
  model IDs (any OpenRouter/Bedrock ID), set admin-configured default model
- /api/models now returns admin-set default model, frontend respects it
- Toggle switch CSS for clean enable/disable UX
- Backend already had the API endpoints, this adds the missing UI
2026-03-28 22:07:16 +00:00
Daniel Onyejesi
7661d4a147 v10: Local Whisper transcription, bigger text areas, flexible AI memory
- Add local Whisper (whisper.cpp / faster-whisper) as transcription provider
  Set TRANSCRIBE_PROVIDER=local with configurable model size and binary path
- Upgrade all refine/instruction inputs to resizable textareas across
  encounter, dictation, hospital course, chart review, well visit, sick visit
- Make AI memory injection flexible: physician preferences and corrections
  are now actively applied (not just "formatting reference"), while still
  overridable by current prompt instructions
2026-03-28 22:00:30 +00:00
Daniel Onyejesi
b498c18fce Set TWA default host to peds.danvics.com, simplify APK workflow 2026-03-28 21:12:25 +00:00
Daniel Onyejesi
3b7994c2c1 v9: Major feature update — audio backup, SOAP save, Dragon memory, S3 docs, CI/CD, APK
Phase 1 — Critical Fixes:
- Fix SOAP instructions not clearing on Clear button
- Show transcription provider (AWS/OpenAI) in UI toast
- Fix silent transcription failures in dictation and SOAP modules
- Add IndexedDB audio backup system (24hr retention, retry from Settings)
- Prevent duplicate encounter saves with idempotency keys
- Add Save/Load/New bar to SOAP note generator

Phase 2 — Features:
- Dragon-like AI memory: auto-track user corrections, inject into prompts
- Per-section template categories (SOAP, HPI, well visit, sick visit)
- Bigger textarea for SOAP instructions
- S3 document upload/management (AWS S3, Backblaze B2, MinIO compatible)
- Faster transcription via lower bitrate recording (16kbps opus)

Phase 3 — APK & CI/CD:
- GitHub Actions: Docker build+push on version tags
- GitHub Actions: TWA APK build for Obtainium auto-updates
- Android TWA project with foreground service for background recording
- Enhanced PWA manifest with shortcuts and maskable icons
2026-03-28 21:08:32 +00:00
Daniel Onyejesi
51cd366c96 Security fixes: remove SSO token from URL, add prompt boundaries
- OIDC callback now passes only ?sso=ok flag, token stays in
  httpOnly cookie (prevents token leaking to logs/referrer/history)
- Frontend auth.js uses cookie-based auth for SSO flow
- Add [PHYSICIAN TEMPLATES] boundary markers around physicianMemories
  in all 5 generation routes to mitigate prompt injection
- Consistent boundary format across wellVisit, sickVisit, hpi, soap,
  hospitalCourse
2026-03-25 19:25:49 -04:00
Daniel Onyejesi
8e509a7166 Remove Firefox speech notice, fix auth.js SSO flow race condition
- Remove Firefox speech recognition notice (not needed)
- Fix missing closing brace that made speech recognition unreachable
- Fix auth.js SSO token handling to prevent brief auth screen flash
2026-03-25 19:04:20 -04:00
Daniel Onyejesi
8e544ad5b9 Add OpenID Connect SSO + Firefox speech notice
OIDC/SSO:
- New /api/auth/oidc route with PKCE for secure authorization
- Supports Azure AD, Okta, Keycloak, PocketID, Google, any OIDC provider
- Admin configurable: issuer, client ID/secret, button label
- Option to disable local auth (force SSO only)
- Auto-creates users on first SSO login, links existing by email
- SSO button on login page, hidden until admin enables OIDC

Firefox:
- Show info toast on first recording that live preview requires
  Chrome/Edge; server-side transcription still works in all browsers
2026-03-25 18:54:44 -04:00
Daniel Onyejesi
ce4ef822ba Fix AWS Transcribe: reduce chunk size from 32KB to 8KB
AWS Transcribe rejects audio event frames over ~16KB with a
cryptic "Deserialization error" / "Your stream is too big" message
hidden inside the SDK error object. Reducing to 8KB per chunk
fixes both Standard and Medical Transcribe streaming.
2026-03-25 18:41:05 -04:00
Daniel Onyejesi
a36cd9a299 Improve Transcribe error diagnostics, add minimum audio check
- Log $response status/headers/body on deserialization errors
- Add 10ms delay between audio chunks to prevent stream overload
- Skip transcription if audio < 0.5s (too short for recognition)
- Cleaner error logging with dedicated logTranscribeError helper
2026-03-25 18:29:48 -04:00
Daniel Onyejesi
42b002eea8 Add detailed error logging for Transcribe Medical failures
Log error name, AWS metadata, and root cause to diagnose
the "non-retryable streaming request" error.
2026-03-25 18:24:37 -04:00
Daniel Onyejesi
57642bfc74 Add Medical→Standard fallback and better error logging for AWS Transcribe
When Medical Transcribe fails (wrong IAM permissions, region not
supported), automatically falls back to Standard Transcribe instead
of returning an error. Logs the specific failure reason.
2026-03-25 18:10:21 -04:00
Daniel Onyejesi
8ce40503d8 Fix missing error handling in nextcloud disconnect, update docker-compose to v8
- Add try-catch to /nextcloud/disconnect route (was crashing on DB errors)
- Update docker-compose.yml image tag from v7 to v8
- Remove unused SESSION_SECRET from .env.example
2026-03-25 17:53:48 -04:00
Daniel Onyejesi
2877cc5d6c Wire physician memories/templates into all AI generation routes
Previously only well-visit and sick-visit used saved physician
templates. Now HPI encounter, HPI dictation, SOAP, and hospital
course all fetch getUserMemoryContext() and pass physicianMemories
to the backend so the AI learns from saved templates/preferences.
2026-03-25 17:35:30 -04:00
Daniel Onyejesi
80085db579 v8.0.0: Fix speech recognition repeating text, enable AWS Transcribe Medical
- Add deduplication logic to prevent Chrome Speech API from repeating
  sentences during long recording sessions (all 4 recording modules)
- Enable AWS Transcribe Medical with PRIMARYCARE specialty in .env
- Bump version to 8.0.0
2026-03-25 17:24:28 -04:00
Daniel Onyejesi
e39cfc1c76 Add ffmpeg audio conversion fallback for AWS Transcribe
- transcribeAWS.js: convert browser WebM/Opus → PCM 16kHz mono via
  ffmpeg before sending to AWS Transcribe — PCM is unambiguous and
  most reliable; gracefully falls back to ogg-opus if ffmpeg absent
- Dockerfile: install ffmpeg (apk add ffmpeg) so Docker image works
  out of the box with AWS Transcribe
- README: document Amazon Transcribe setup, ffmpeg requirement,
  Transcribe Medical specialty options, and env vars reference
2026-03-25 20:35:24 +00:00
Daniel Onyejesi
14497b3270 Add Amazon Transcribe streaming (no S3) with Medical specialty support
- New src/utils/transcribeAWS.js: streams audio directly to AWS
  Transcribe without requiring an S3 bucket
- Supports AWS_TRANSCRIBE_MEDICAL=true for Transcribe Medical
  (better clinical accuracy: drug names, diagnoses, procedures)
- AWS_TRANSCRIBE_SPECIALTY configures specialty (default PRIMARYCARE)
- transcribe.js auto-selects AWS when AWS_BEDROCK_REGION is set,
  or can be forced with TRANSCRIBE_PROVIDER=aws|openai
- Falls back to OpenAI Whisper when AWS is not configured
- Add @aws-sdk/client-transcribe-streaming as optional dependency
- Update .env.example with transcription configuration docs
2026-03-25 20:26:10 +00:00
Daniel Onyejesi
e0757310c8 v7: fix speech recognition repetition, HTML injection, long-session guard
- Fix word repetition: use sessionFinals pattern so each browser SR
  session starts fresh; no overlap when recognition auto-restarts
- Fix HTML injection / '>' parse error: escape < > & in live transcript
  innerHTML before inserting speech recognition text
- Add 24 MB blob guard: fall back to live SR transcript if audio file
  is too large for Whisper API (long sessions)
- Bump version to 7.0.0, update docker-compose image tag to v7
2026-03-25 20:07:10 +00:00
470 changed files with 66000 additions and 37525 deletions

View file

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

View file

@ -0,0 +1,184 @@
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

@ -0,0 +1,45 @@
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"

16
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,16 @@
## 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,6 +21,7 @@ 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: "!contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
if: "github.server_url == 'https://github.com' && !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,6 +14,7 @@ env:
jobs:
build-apk:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
permissions:
contents: write

34
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,34 @@
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,6 +24,7 @@ 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 }}
@ -80,6 +81,7 @@ 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

30
.github/workflows/security.yml vendored Normal file
View file

@ -0,0 +1,30 @@
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,6 +30,7 @@ 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,5 +37,7 @@ e2e/node_modules/
e2e/test-results/
e2e/playwright-report/
# Codex CLI marker
.codex
.firecrawl/
# Refactored test stack stays local for now

View file

@ -1,174 +0,0 @@
# 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

@ -1,240 +0,0 @@
# 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 |
|---|---|
| `android-release.yml` | signed APK on GitHub release, `make_latest=true` |
| `.forgejo/workflows/android-apk.yml` | signed APK on Forgejo release (`pedscribe-<tag>.apk`), optional Google Play internal track upload |
| `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) |
## Local dev

View file

@ -1,976 +0,0 @@
# 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: download Whisper models for browser-based transcription
# curl: HTTP helper used by the OpenBao entrypoint and health/debug tooling
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
RUN apk add --no-cache ffmpeg curl jq
@ -30,22 +30,6 @@ 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 \
@ -56,4 +40,3 @@ 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,347 +0,0 @@
# 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;"`

407
README.md
View file

@ -1,78 +1,103 @@
# Pediatric AI Scribe v6
# Ped-AI
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.
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.
## Features
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
### 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
### 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
- 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.
### 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
### 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
- 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.
---
### 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
```
Edit `.env` — at minimum set:
The default compose exposes the app on `127.0.0.1:3552` and starts:
```env
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex
LITELLM_API_BASE=https://your-litellm.example.com
LITELLM_API_KEY=sk-...
- `pediatric-ai-scribe` for the Node app.
- `pedscribe-db` for Postgres with pgvector.
- `ped-ai-redis` for operational Redis state.
OPENAI_API_KEY=sk-... # for Whisper transcription (if not using LiteLLM STT)
JWT_SECRET=<64-char random> # openssl rand -hex 32
DB_PASSWORD=<strong password>
APP_URL=https://your-domain.com
```
### 2. Start
Health check:
```bash
docker compose up -d
curl -fsS http://127.0.0.1:3552/api/health
```
App runs on **port 3552**. First user to register becomes admin.
Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix.
### 3. Admin CLI
The first registered user becomes an admin unless registration has already been configured differently.
## Core Environment
Set real values in `.env` before production use.
```env
APP_URL=https://your-domain.example
JWT_SECRET=<64-char-random-secret>
DB_PASSWORD=<strong-database-password>
AI_PROVIDER=litellm
LITELLM_API_BASE=https://your-litellm.example/v1
LITELLM_API_KEY=<key>
TRANSCRIBE_PROVIDER=litellm
LITELLM_STT_MODEL=whisper-1
REDIS_URL=redis://ped-ai-redis:6379
```
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.
## Admin CLI
```bash
docker exec pediatric-ai-scribe node admin-cli.js list-users
@ -83,270 +108,70 @@ docker exec pediatric-ai-scribe node admin-cli.js toggle-registration
docker exec pediatric-ai-scribe node admin-cli.js stats
```
---
## Maintenance
## 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:
The app checks Postgres collation drift on startup and can reindex text indexes after image or OS-library changes.
```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 `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
```
---
Run the reindex command after major Postgres image changes, restoring a dump from another distro, or seeing lookup behavior that suggests collation/index drift.
## Testing
Two layers, both zero-config after the initial setup.
### Unit tests — pure dose math (Node built-in)
Run the Node test suite:
```bash
npm test
```
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**.
Run syntax checks for touched files when doing focused backend work:
```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
node --check server.js
node --check src/routes/transcribe.js
```
# Then run the full suite (runs inside an official Playwright container)
Run the Playwright smoke suite against the e2e compose stack:
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
npm run e2e
```
The runner script (`scripts/e2e.sh`) uses `mcr.microsoft.com/playwright` so you
don't need Node or browsers on the host.
## Deployment Notes
**Test environment:**
- 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.
- `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.
## Documentation
**Viewing failures** — Playwright writes `e2e/test-results/<test-name>/`
with:
Primary references:
- `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
- `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.
Everything but the specs and config is gitignored under `e2e/`.
Some deep `docs/logic/` files still describe historical implementation details. Prefer runtime code and tests when documentation conflicts with current behavior.
**Files:**
## Clinical Safety
- `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.
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.

View file

@ -1,279 +0,0 @@
# 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
View file

@ -1,24 +0,0 @@
# 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?

View file

@ -1,73 +0,0 @@
# 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...
},
},
])
```

View file

@ -1,21 +0,0 @@
{
"$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"
}

View file

@ -1,22 +0,0 @@
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,
},
},
])

View file

@ -1,13 +0,0 @@
<!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

File diff suppressed because it is too large Load diff

View file

@ -1,47 +0,0 @@
{
"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

Before

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -1,101 +0,0 @@
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

@ -1,30 +0,0 @@
// ============================================================
// 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

@ -1,111 +0,0 @@
// ============================================================
// 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

@ -1,143 +0,0 @@
// ============================================================
// 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

@ -1,117 +0,0 @@
// ============================================================
// 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

@ -1,159 +0,0 @@
// ============================================================
// 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

@ -1,151 +0,0 @@
// ============================================================
// 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

@ -1,176 +0,0 @@
// ============================================================
// 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

@ -1,215 +0,0 @@
// ============================================================
// 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

@ -1,212 +0,0 @@
// ============================================================
// 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

@ -1,97 +0,0 @@
// ============================================================
// 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

@ -1,83 +0,0 @@
// ============================================================
// 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" />;
}

View file

@ -1,150 +0,0 @@
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

@ -1,90 +0,0 @@
// ============================================================
// 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);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,290 +0,0 @@
// ============================================================
// 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.' },
];

View file

@ -1,44 +0,0 @@
@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;
}

View file

@ -1,45 +0,0 @@
// 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

@ -1,124 +0,0 @@
// ============================================================
// 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

@ -1,59 +0,0 @@
// ============================================================
// 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

@ -1,57 +0,0 @@
// ============================================================
// 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

@ -1,99 +0,0 @@
// ============================================================
// 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);
}

View file

@ -1,8 +0,0 @@
// 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

@ -1,117 +0,0 @@
// ============================================================
// 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;
}

View file

@ -1,10 +0,0 @@
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>,
)

View file

@ -1,103 +0,0 @@
// ============================================================
// 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

@ -1,337 +0,0 @@
// ============================================================
// 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

@ -1,499 +0,0 @@
// ============================================================
// 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>
);
}

View file

@ -1,310 +0,0 @@
// ============================================================
// 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

@ -1,204 +0,0 @@
// ============================================================
// 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

@ -1,641 +0,0 @@
// ============================================================
// 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

@ -1,366 +0,0 @@
// ============================================================
// 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

@ -1,616 +0,0 @@
// ============================================================
// 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

@ -1,88 +0,0 @@
// ============================================================
// 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

@ -1,197 +0,0 @@
// ============================================================
// 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>
);
}

View file

@ -1,76 +0,0 @@
// ============================================================
// 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

@ -1,171 +0,0 @@
// ============================================================
// 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

@ -1,156 +0,0 @@
// ============================================================
// 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

@ -1,153 +0,0 @@
// ============================================================
// 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>
);
}

View file

@ -1,57 +0,0 @@
// ============================================================
// 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

@ -1,198 +0,0 @@
// ============================================================
// 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

@ -1,482 +0,0 @@
// ============================================================
// 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

@ -1,471 +0,0 @@
// ============================================================
// 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

@ -1,67 +0,0 @@
// ============================================================
// 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

@ -1,224 +0,0 @@
// ============================================================
// 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>
);
}

View file

@ -1,165 +0,0 @@
// ============================================================
// 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

@ -1,91 +0,0 @@
// ============================================================
// 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

@ -1,83 +0,0 @@
// ============================================================
// 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

@ -1,332 +0,0 @@
// ============================================================
// 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

@ -1,146 +0,0 @@
// 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

@ -1,248 +0,0 @@
// 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

@ -1,165 +0,0 @@
// 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

@ -1,185 +0,0 @@
// 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

@ -1,107 +0,0 @@
// ============================================================
// 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

@ -1,34 +0,0 @@
// 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

@ -1,61 +0,0 @@
// 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

@ -1,462 +0,0 @@
// ============================================================
// 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

@ -1,328 +0,0 @@
// ============================================================
// 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

@ -1,363 +0,0 @@
// ============================================================
// 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

@ -1,311 +0,0 @@
// ============================================================
// 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

@ -1,118 +0,0 @@
// ============================================================
// 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>;

View file

@ -1,572 +0,0 @@
// ============================================================
// 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;
}

View file

@ -1,37 +0,0 @@
{
"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"
]
}

View file

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

View file

@ -1,24 +0,0 @@
{
"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"]
}

View file

@ -1,36 +0,0 @@
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/',
});

View file

@ -6,13 +6,31 @@ services:
- "127.0.0.1:3552:3000"
env_file:
- .env
environment:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
REDIS_URL: redis://ped-ai-redis:6379
LOKI_URL: http://monitoring-loki:3100
LITELLM_API_BASE: http://litellm:4000
TTS_PROVIDER: litellm
LITELLM_TTS_MODEL: local-kokoro-tts
LITELLM_TTS_VOICE: sherpa/kokoro:am_adam
LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
volumes:
- scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
container_name: pediatric-ai-scribe
networks:
- default
- danvics_mcp
- danvics_monitoring
- danvics_speech
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
@ -28,7 +46,7 @@ services:
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@ -40,6 +58,34 @@ services:
retries: 5
start_period: 10s
redis:
image: redis:8-alpine
command: redis-server --appendonly yes
restart: unless-stopped
container_name: ped-ai-redis
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- default
- danvics_mcp
volumes:
pgdata:
scribe-logs:
redis-data:
clinical-assistant-mcp-data:
external: true
name: mcp-server_mcp-data
networks:
danvics_mcp:
external: true
danvics_monitoring:
external: true
danvics_speech:
external: true

90
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,90 @@
# Architecture
This document is the current high-level map for Ped-AI. It is intentionally shorter and more operational than the older deep-dive files under `docs/logic/`.
## System Shape
Ped-AI is a self-hosted Express application with a browser frontend, PostgreSQL storage, Redis operational state, LiteLLM model routing, and optional MCP-backed clinical retrieval.
| Area | Owner | Notes |
|---|---|---|
| Web app | Ped-AI | Auth, UI, clinical workflows, admin settings, notes, Learning Hub, bedside tools |
| Database | PostgreSQL | Users, sessions, settings, saved app data, audit/API/access logs |
| Operational cache | Redis | Prompt suggestions, lightweight state, queue groundwork; not clinical answer caching |
| Model gateway | LiteLLM | Text, speech, image, embedding model discovery and routing |
| Clinical retrieval | MCP service | Nextcloud access, indexing, search, rerank, source metadata |
| Reverse proxy | Caddy or equivalent | TLS and public routing |
## Request Flow
Normal app request:
```txt
browser
-> reverse proxy
-> Express middleware
-> auth/session check when protected
-> route handler
-> PostgreSQL/Redis/provider calls as needed
-> JSON or HTML fragment response
```
Clinical Assistant request:
```txt
browser
-> Ped-AI clinical assistant route
-> MCP semantic search for indexed clinical sources
-> Ped-AI builds grounded answer prompt
-> LiteLLM chat model
-> Ped-AI returns answer plus source metadata
-> browser renders markdown, citations, and source cards
```
Ped-AI owns the user workflow and rendering. MCP owns retrieval and indexed source metadata. LiteLLM owns model routing.
## Runtime Boundaries
| Boundary | Main Risk | Current Direction |
|---|---|---|
| Browser to Ped-AI | XSS, stale shell, session handling | Sanitized rendering, httpOnly cookie for web, cache busting |
| Ped-AI to PostgreSQL | schema drift, slow queries | migrations, maintenance checks, indexes where needed |
| Ped-AI to Redis | unavailable operational state | Redis is useful but should not hold required clinical answers |
| Ped-AI to LiteLLM | provider downtime, wrong model mode | metadata-based model discovery and timeouts |
| Ped-AI to MCP | retrieval latency/failure | explicit MCP client layer and graceful fallback messages |
| MCP to Nextcloud | stale indexed metadata | scanner/indexer updates source metadata over time |
## Source Of Truth
| Data | Source Of Truth |
|---|---|
| User accounts and sessions | Ped-AI PostgreSQL |
| Admin app settings | Ped-AI PostgreSQL `app_settings` |
| Clinical source documents | Nextcloud and MCP index |
| Clinical source title/path shown to users | MCP result metadata, especially indexed `file_path` |
| Clinical answer text | Generated per request; intentionally not cached |
| Model availability | LiteLLM metadata and configured fallbacks |
## Deployment Shape
Production usually runs:
```txt
Caddy/TLS
-> pediatric-ai-scribe container
-> pedscribe-db container
-> ped-ai-redis container
-> LiteLLM endpoint
-> MCP endpoint
```
The app should stay private behind the reverse proxy. Do not expose PostgreSQL, Redis, MCP internals, or provider keys publicly.
## Design Principles
- Keep Ped-AI stateless enough to run more than one app container.
- Keep clinical answer generation live and source-grounded; do not cache final clinical answers.
- Prefer model capability metadata over model-name regexes.
- Prefer indexed file names and paths over embedded PDF metadata for source titles.
- Keep renderer fixes narrow and tested because LLM markdown is messy.
- Keep old frontend globals working until the affected feature is intentionally converted to ESM.

View file

@ -0,0 +1,97 @@
# Clinical Assistant
The Clinical Assistant is a retrieval-grounded assistant for pediatric clinical reference questions. It is not the same as the app's note-generation/HPI workflow.
## Responsibilities
| Component | Responsibility |
|---|---|
| Browser UI | question input, source display, markdown/citation rendering, export |
| Ped-AI backend | settings, MCP search call, answer prompt construction, model call |
| MCP server | Nextcloud access, indexing, vector search, rerank, source metadata |
| LiteLLM | model routing and provider abstraction |
## Request Flow
```txt
User asks a question
-> browser posts to Ped-AI
-> Ped-AI calls MCP `nc_semantic_search`
-> MCP returns source excerpts and metadata
-> Ped-AI builds an answer prompt with source constraints
-> LiteLLM model returns answer text
-> browser renders answer and source cards
```
## Source Rules
- Prefer MCP `file_path` basename for displayed source titles when present.
- Do not relabel one source as another requested source.
- If the user names a source and retrieval does not return it, say that before using other sources.
- Use citations only for returned source numbers.
- Unknown citation numbers should remain plain text instead of being guessed.
## Table And Markdown Rendering
LLM output is not guaranteed to be valid markdown. The browser renderer defensively handles common problems:
- adjacent citation clusters,
- missing closing bracket in narrow citation cases,
- smashed bullet lists,
- inline headings,
- malformed pipe tables,
- bare source numbers in source/citation table columns,
- orphan markdown emphasis markers,
- code blocks that must not be modified.
Renderer fixes must be narrow. Do not add broad repairs that turn arbitrary clinical numbers into citations.
## Image Routing
Table lookup requests should stay in retrieval flow.
Examples that should use retrieval:
```txt
show me the table
show me Table 13.1
summarize the developmental table
```
Explicit visual creation/display requests can use image flow.
Examples:
```txt
create an infographic
generate a diagram
show me the image/figure
```
## Caching Policy
Clinical answer response caching is intentionally disabled. Redis can support prompt suggestions and operational metadata, but final answers should be generated from current retrieval context.
## Settings
Important settings include:
| Setting | Purpose |
|---|---|
| `clinical_assistant.chat_model` | Chat model used for answers |
| `clinical_assistant.image_model` | Image model used for explicit image generation |
| `clinical_assistant.search_limit` | Number of MCP results requested |
| `clinical_assistant.context_chars` | Context characters requested from MCP |
| `clinical_assistant.system_behavior` | Admin-editable assistant behavior guidance |
## Testing Priorities
Add or update tests when changing:
- citation rendering,
- source title cleanup,
- named-source provenance behavior,
- table rendering,
- image intent routing,
- MCP result normalization,
- model discovery or settings behavior.

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