Compare commits
445 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f556d50a09 | ||
|
|
3fb4c10f2b | ||
|
|
4613a27879 | ||
|
|
f31afcdbf4 | ||
|
|
a814d2a2c2 | ||
|
|
bca107846e | ||
|
|
524ad40d49 | ||
|
|
82ed46d01b | ||
|
|
bee9361c1d | ||
|
|
2ca969e099 | ||
|
|
80139d9a82 | ||
|
|
f7cfc6695d | ||
|
|
b0e1f4969a | ||
|
|
d02b9e2771 | ||
|
|
c7921ab822 | ||
|
|
fb3e4d4135 | ||
|
|
cb63729656 | ||
|
|
2cef65fb1f | ||
|
|
629dea808e | ||
|
|
8dca18292a | ||
|
|
1f5e4aabac | ||
|
|
97ddd87449 | ||
|
|
e1266c6d38 | ||
|
|
6e8fae72e7 | ||
|
|
2c287bd1b3 | ||
|
|
f871384063 | ||
|
|
977ebfc037 | ||
|
|
212ce7dd95 | ||
|
|
b29c6f7717 | ||
|
|
8f51e56723 | ||
|
|
7fe0a0e7ec | ||
|
|
71655aa7e9 | ||
|
|
f01ca5a094 | ||
|
|
52544e9116 | ||
|
|
046b07a84a | ||
|
|
cddc1a4d79 | ||
|
|
a176e1b014 | ||
|
|
83d9a77160 | ||
|
|
baf0020981 | ||
|
|
795ad9ffae | ||
|
|
8d69fe57a5 | ||
|
|
90cdf17bd9 | ||
|
|
1b3ea569b7 | ||
|
|
79037fa775 | ||
|
|
2a3631d067 | ||
|
|
ea12b9a46f | ||
|
|
2387e6f136 | ||
|
|
39d77116ac | ||
|
|
6f5782734f | ||
|
|
bb31c6f515 | ||
|
|
113f004230 | ||
|
|
05f8b00401 | ||
|
|
0503a25d0b | ||
|
|
e84f19b5cb | ||
|
|
493a1d230c | ||
|
|
d108bdc091 | ||
|
|
416fff624a | ||
|
|
1cbe248450 | ||
|
|
b7f9da6600 | ||
|
|
4a9a518134 | ||
|
|
ba180d7dde | ||
|
|
446a3b33d0 | ||
|
|
aeb31a2d15 | ||
|
|
210ec06fe5 | ||
|
|
a6807ef7a4 | ||
|
|
a08524d95f | ||
|
|
467593a109 | ||
|
|
92351e1ab5 | ||
|
|
dc62cc880d | ||
|
|
d97d92f8f2 | ||
|
|
41598e0fb6 | ||
|
|
7858484cb0 | ||
|
|
6d13765fc4 | ||
|
|
d8e9ba149e | ||
|
|
d71482037f | ||
|
|
de3e1c0a15 | ||
|
|
2fec33dc4d | ||
|
|
df87d93306 | ||
|
|
e731780c7b | ||
|
|
300b40b181 | ||
|
|
27c3e07d98 | ||
|
|
9167532a14 | ||
|
|
db2ecca45d | ||
|
|
6da5565f89 | ||
|
|
04e736eb2e | ||
|
|
ea52890908 | ||
|
|
9ca5365daf | ||
|
|
bc0b43151d | ||
|
|
b6ebca0e6f | ||
|
|
1756043125 | ||
|
|
03ee07f92f | ||
|
|
20fc6798a9 | ||
|
|
59fa229b59 | ||
|
|
38667608b1 | ||
|
|
02f348ddb3 | ||
|
|
ffffe17b30 | ||
|
|
d48a19a891 | ||
|
|
c458c4b4ff | ||
|
|
e05720083a | ||
|
|
90938f8ec1 | ||
|
|
289b0197e7 | ||
|
|
0102c9cbc6 | ||
|
|
3be21a137b | ||
|
|
548c39a883 | ||
|
|
b40941e4d5 | ||
|
|
48ee92fc5d | ||
|
|
d4a3c8fd60 | ||
|
|
ca9be8bd85 | ||
|
|
34f198edc0 | ||
|
|
c52f7664b9 | ||
|
|
784d1a2e21 | ||
|
|
9b5f01a19b | ||
|
|
ae0ca83ccb | ||
|
|
c2eb550048 | ||
|
|
41a05a6b5e | ||
|
|
b75be521ab | ||
|
|
951d102ac0 | ||
|
|
bbd03bfeed | ||
|
|
d79e578863 | ||
|
|
903cd7eca8 | ||
|
|
d8b8f9bdcb | ||
|
|
f566455108 | ||
|
|
6a081bb53b | ||
|
|
3c4ca84b5f | ||
|
|
e4ea553bee | ||
|
|
4a14a71151 | ||
|
|
1ed1a37161 | ||
|
|
116bd941e1 | ||
|
|
326fb726a1 | ||
|
|
f54b293d39 | ||
|
|
7c700ed7f5 | ||
|
|
10f39fc4ff | ||
|
|
a8f364f177 | ||
|
|
4f3f5d2f05 | ||
|
|
49a2bed0d0 | ||
|
|
569363754f | ||
|
|
70f6aa4ac6 | ||
|
|
c8436d5e4c | ||
|
|
27ffbfdd77 | ||
|
|
3d7fea8639 | ||
|
|
d4c85ad638 | ||
|
|
b9270414b0 | ||
|
|
18b219dbff | ||
|
|
21fb631fb5 | ||
|
|
5e5c219d33 | ||
|
|
fb339325ee | ||
|
|
d600c98153 | ||
|
|
198fd8e809 | ||
|
|
566d5c7e8d | ||
|
|
5e926de010 | ||
|
|
f35dd65f2f | ||
|
|
b2f3539c5e | ||
|
|
ced5d6fc6b | ||
|
|
18109f8bcf | ||
|
|
dfdd2c8740 | ||
|
|
f134d0e80c | ||
|
|
e4a95ad72e | ||
|
|
cc50fb8c59 | ||
|
|
098da5d5e3 | ||
|
|
ed76107a2b | ||
|
|
c39630792b | ||
|
|
6c9ef4cef9 | ||
|
|
605e76b14c | ||
|
|
d50640ad81 | ||
|
|
6dcfbcd456 | ||
|
|
81884f8a36 | ||
|
|
6c25e8ff05 | ||
|
|
31163e1894 | ||
|
|
96306673a3 | ||
|
|
63fe53a029 | ||
|
|
2310c43aea | ||
|
|
81c627bec7 | ||
|
|
33ca4e65d9 | ||
|
|
c8e911cb64 | ||
|
|
96c4565b9c | ||
|
|
0710c4de8e | ||
|
|
cf1d88f36b | ||
|
|
b82db99ebc | ||
|
|
b53aa34248 | ||
|
|
4f129b24e1 | ||
|
|
67b7667e04 | ||
|
|
2872f1d063 | ||
|
|
dccb3b4bcb | ||
|
|
7ed8a2365b | ||
|
|
9106d85f98 | ||
|
|
df1a6613dd | ||
|
|
fd5108e7b3 | ||
|
|
9961688bfa | ||
|
|
8c9c03c656 | ||
|
|
2a4269d496 | ||
|
|
a25c36c875 | ||
|
|
2d89f295dd | ||
|
|
fd658739c3 | ||
|
|
9bb4879be1 | ||
|
|
415f67d432 | ||
|
|
3c0de624fc | ||
|
|
d71714b65d | ||
|
|
abc1a64363 | ||
|
|
9c53e19d29 | ||
|
|
1b209b5eb7 | ||
|
|
2e517a67a9 | ||
|
|
8d97b13bf7 | ||
|
|
3884bf673b | ||
|
|
015eaf9945 | ||
|
|
02e7281e52 | ||
|
|
fda5b12143 | ||
|
|
8cfa07dcf5 | ||
|
|
b80a91e40a | ||
|
|
2913b09abd | ||
|
|
bf62d15ad6 | ||
|
|
9bbfb4ce83 | ||
|
|
7d860c5287 | ||
|
|
b7a2e15107 | ||
|
|
abb67bd03a | ||
|
|
8cabe7da4b | ||
|
|
8be268df6a | ||
|
|
1aa785068b | ||
|
|
316a5e0338 | ||
|
|
8efc4e9a56 | ||
|
|
83a78fa8cd | ||
|
|
4e1a870fe2 | ||
|
|
33bfc0bfbc | ||
|
|
31507a4f09 | ||
|
|
b37c565cf0 | ||
|
|
8ea79c7f30 | ||
|
|
651f799c17 | ||
|
|
d859c8c5a9 | ||
|
|
917d4f8115 | ||
|
|
ee4940e0a6 | ||
|
|
c35b05fc5a | ||
|
|
c13ba04955 | ||
|
|
22dd5cc8f4 | ||
|
|
ac5292b015 | ||
|
|
1bb8918b46 | ||
|
|
749aa23e87 | ||
|
|
2709595793 | ||
|
|
456101a28a | ||
|
|
c4da879336 | ||
|
|
508530eda8 | ||
|
|
887ef04de7 | ||
|
|
42e59fa958 | ||
|
|
f1802d66f4 | ||
|
|
250646110f | ||
|
|
6dc7870a1b | ||
|
|
cc035e7d8d | ||
|
|
07d7b42efc | ||
|
|
f39f906fa5 | ||
|
|
d8504392a5 | ||
|
|
29f37b331e | ||
|
|
146ac73da2 | ||
|
|
ed8948e539 | ||
|
|
fe7b3687ee | ||
|
|
7e7d469172 | ||
|
|
2742a2a130 | ||
|
|
28118c4493 | ||
|
|
f26687df50 | ||
|
|
9603a8fcf8 | ||
|
|
abdbaa3507 | ||
|
|
30cfc9700b | ||
|
|
ea3a3533e6 | ||
|
|
936ecbd113 | ||
|
|
e5f7167b8d | ||
|
|
b09276faf5 | ||
|
|
2f6e5a7d8f | ||
|
|
857ed341f5 | ||
|
|
957ba531bc | ||
|
|
895caa2093 | ||
|
|
231a86509f | ||
|
|
7ad0c84789 | ||
|
|
fbc7890378 | ||
|
|
9085bb6bb6 | ||
|
|
f5a10419de | ||
|
|
9bfece8532 | ||
|
|
7b39c6c615 | ||
|
|
d0009e94ed | ||
|
|
b485eec828 | ||
|
|
63b8110993 | ||
|
|
ef341671e2 | ||
|
|
0471aee5a5 | ||
|
|
73398e91ed | ||
|
|
77bd7c5b1c | ||
|
|
b4704944cb | ||
|
|
43ee0e7ab5 | ||
|
|
6db52eeec4 | ||
|
|
a514405261 | ||
|
|
ef01eca8ca | ||
|
|
b27c79e8ae | ||
|
|
868ed53bbc | ||
|
|
d1b6da4291 | ||
|
|
290724c883 | ||
|
|
c11cfe45b7 | ||
|
|
1c106e71db | ||
|
|
e8a2283fae | ||
|
|
7a7fd5b4eb | ||
|
|
02f4bea747 | ||
|
|
aa1261da4e | ||
|
|
1a176f082a | ||
|
|
2cb99b263f | ||
|
|
de0562060b | ||
|
|
e32e9977f5 | ||
|
|
2f3e608c88 | ||
|
|
5b0c296a88 | ||
|
|
7a06a4aa63 | ||
|
|
8db25f39be | ||
|
|
a2b1b262cb | ||
|
|
fcf11ec326 | ||
|
|
64ac4ff6bb | ||
|
|
040218a7bf | ||
|
|
ed015f3774 | ||
|
|
b6753d5bc9 | ||
|
|
f9732f25d0 | ||
|
|
97f60876c5 | ||
|
|
c411e5f16f | ||
|
|
6c98e36511 | ||
|
|
4a29c496f6 | ||
|
|
5a700a2a27 | ||
|
|
0bbecb76f9 | ||
|
|
cd2513d361 | ||
|
|
9423ffc3a7 | ||
|
|
43d26fd306 | ||
|
|
11f53102ee | ||
|
|
7cc8a1fa99 | ||
|
|
df592d401b | ||
|
|
c392e73cfe | ||
|
|
dc5f8ae758 | ||
|
|
74c5cde8e1 | ||
|
|
7c45367c02 | ||
|
|
e625c634b6 | ||
|
|
c736782c15 | ||
|
|
82b8fa0e0e | ||
|
|
011fae9b7a | ||
|
|
ffa6b818db | ||
|
|
ab1ac25611 | ||
|
|
1b5faa3a01 | ||
|
|
5bf55499a4 | ||
|
|
6ed2778a12 | ||
|
|
6daf08982e | ||
|
|
9f39f0b822 | ||
|
|
09aaeefee1 | ||
|
|
a5f073dcdd | ||
|
|
79fee2d4f2 | ||
|
|
e1ce374809 | ||
|
|
4b1afd1f44 | ||
|
|
6d3b0693d8 | ||
|
|
7f8ddfff53 | ||
|
|
6b69315d99 | ||
|
|
47844ff29b | ||
|
|
c7038d9db1 | ||
|
|
11d4880337 | ||
|
|
91d04f852f | ||
|
|
7d55e64ca1 | ||
|
|
639a5d2873 | ||
|
|
e7eb695049 | ||
|
|
3d5b77721c | ||
|
|
719fe0533f | ||
|
|
85f9af4ffc | ||
|
|
55f8e172e6 | ||
|
|
09193538fb | ||
|
|
b3b54c9a6c | ||
|
|
869fa14a77 | ||
|
|
783679a3f7 | ||
|
|
8bd5cbd690 | ||
|
|
fdf29b5ed7 | ||
|
|
dc2e000e88 | ||
|
|
540347c015 | ||
|
|
ba6724083c | ||
|
|
17557fa0f8 | ||
|
|
39d9a1f9e8 | ||
|
|
0dc6812f38 | ||
|
|
932ddc3b0a | ||
|
|
3c6acb3eb5 | ||
|
|
d53b469717 | ||
|
|
196f4432f0 | ||
|
|
a7dd08c9d1 | ||
|
|
2d1723f14a | ||
|
|
9d817cd9f5 | ||
|
|
b9ceca8f20 | ||
|
|
c38ce9445e | ||
|
|
d1f44c2f41 | ||
|
|
0d685070d1 | ||
|
|
88036a45c4 | ||
|
|
ee3729eb57 | ||
|
|
9016af8fe2 | ||
|
|
364b686619 | ||
|
|
5c157cf6aa | ||
|
|
ea213d8baf | ||
|
|
9e43e12cfc | ||
|
|
d181430d7a | ||
|
|
d1a7c97ecc | ||
|
|
0ce2735315 | ||
|
|
f13eb05218 | ||
|
|
58094f6298 | ||
|
|
61ed414785 | ||
|
|
6b6bf728d5 | ||
|
|
22d9a8ec29 | ||
|
|
841fe0c264 | ||
|
|
ca645fe941 | ||
|
|
1c23f2dc12 | ||
|
|
567450b51e | ||
|
|
a2263d9530 | ||
|
|
b40dc5584b | ||
|
|
b856a6c1da | ||
|
|
e2e7943dcb | ||
|
|
e6091c299f | ||
|
|
98fddca1e5 | ||
|
|
0e6a853f86 | ||
|
|
96dc40fd0b | ||
|
|
ce7d0e749d | ||
|
|
f8d865a0a9 | ||
|
|
e513298f6a | ||
|
|
ae3ec64c92 | ||
|
|
65e0317ae6 | ||
|
|
6bb062561f | ||
|
|
29f1a9b860 | ||
|
|
a1e5830192 | ||
|
|
296dd1f8f1 | ||
|
|
3ff31868f7 | ||
|
|
da81abcffc | ||
|
|
56d99e67b3 | ||
|
|
e58876ddd9 | ||
|
|
fe632985c1 | ||
|
|
32618032b0 | ||
|
|
bc22f80e25 | ||
|
|
92b1d25f19 | ||
|
|
67362212f6 | ||
|
|
ed9c767300 | ||
|
|
1478ce7d86 | ||
|
|
1ff0f9760d | ||
|
|
4e5b6fed5a | ||
|
|
7661d4a147 | ||
|
|
b498c18fce | ||
|
|
3b7994c2c1 | ||
|
|
51cd366c96 | ||
|
|
8e509a7166 | ||
|
|
8e544ad5b9 | ||
|
|
ce4ef822ba | ||
|
|
a36cd9a299 | ||
|
|
42b002eea8 | ||
|
|
57642bfc74 | ||
|
|
8ce40503d8 | ||
|
|
2877cc5d6c | ||
|
|
80085db579 | ||
|
|
e39cfc1c76 | ||
|
|
14497b3270 | ||
|
|
e0757310c8 |
384 changed files with 46699 additions and 36686 deletions
|
|
@ -3,7 +3,6 @@
|
|||
!.env.example
|
||||
.git
|
||||
.gitignore
|
||||
.agent-config
|
||||
node_modules
|
||||
data/
|
||||
*.log
|
||||
|
|
|
|||
184
.forgejo/workflows/android-apk.yml
Normal file
184
.forgejo/workflows/android-apk.yml
Normal 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
|
||||
45
.forgejo/workflows/docker-build.yml
Normal file
45
.forgejo/workflows/docker-build.yml
Normal 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
16
.github/pull_request_template.md
vendored
Normal 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 #
|
||||
1
.github/workflows/android-release.yml
vendored
1
.github/workflows/android-release.yml
vendored
|
|
@ -21,6 +21,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
name: Build signed APK
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
|||
2
.github/workflows/auto-version.yml
vendored
2
.github/workflows/auto-version.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/build-apk.yml
vendored
1
.github/workflows/build-apk.yml
vendored
|
|
@ -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
34
.github/workflows/ci.yml
vendored
Normal 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
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
30
.github/workflows/security.yml
vendored
Normal 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
|
||||
1
.github/workflows/version-bump.yml
vendored
1
.github/workflows/version-bump.yml
vendored
|
|
@ -30,6 +30,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
bump:
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
@ -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!
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.1–v5.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.*
|
||||
19
Dockerfile
19
Dockerfile
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
407
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
24
client/.gitignore
vendored
|
|
@ -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?
|
||||
|
|
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -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>
|
||||
3135
client/package-lock.json
generated
3135
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,42 +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",
|
||||
"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 |
|
|
@ -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 |
|
|
@ -1,78 +0,0 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||
import Layout from '@/components/Layout';
|
||||
import Extensions from '@/pages/Extensions';
|
||||
import Faq from '@/pages/Faq';
|
||||
import Dictation from '@/pages/Dictation';
|
||||
import Encounter from '@/pages/Encounter';
|
||||
import Soap from '@/pages/Soap';
|
||||
import SickVisit from '@/pages/SickVisit';
|
||||
import HospitalCourse from '@/pages/HospitalCourse';
|
||||
import ChartReview from '@/pages/ChartReview';
|
||||
import WellVisit from '@/pages/WellVisit';
|
||||
import VaxSchedule from '@/pages/VaxSchedule';
|
||||
import Catchup from '@/pages/Catchup';
|
||||
import Settings from '@/pages/Settings';
|
||||
import Learning from '@/pages/Learning';
|
||||
import PeGuide from '@/pages/PeGuide';
|
||||
import Bedside from '@/pages/Bedside';
|
||||
import Calculators from '@/pages/Calculators';
|
||||
import Admin from '@/pages/Admin';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||
});
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Pediatric AI Scribe — React client</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is the new React tree. The legacy vanilla-JS app still lives at{' '}
|
||||
<a href="/" className="underline">/</a>.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Ported tabs so far:</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="/extensions" className="underline">Extensions & Pagers</Link></li>
|
||||
<li><Link to="/faq" className="underline">FAQ</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename="/app">
|
||||
<Routes>
|
||||
<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="/faq" element={<Faq />} />
|
||||
{/* catch-all falls back to home while more tabs port over */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,139 +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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
<div className="font-semibold">Pediatric AI Scribe</div>
|
||||
<a href="/" className="text-[11px] text-muted-foreground underline">
|
||||
← back to legacy app
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 — 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 — from live encounters or dictation, with OLDCARTS structure SOAP Notes — full SOAP or subjective-only from dictation Hospital Course — from progress notes, in prose, day-by-day, or organ-system format Chart Review — summarize outpatient, subspecialty, or ED visits for precharting Well Visit — complete preventive care notes with SSHADESS, milestones, and vaccines Sick Visit — quick documentation with auto-suggested ROS and PE Milestone Assessment — 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 — 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 — 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 > 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 > Settings > 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 > 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–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 — 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 > 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 > 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 — 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 — one-click copy to clipboard, ready to paste into any EHR Nextcloud — export directly to your Nextcloud instance (configure in Settings) Documents — 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 — 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 > Two-Factor Authentication."
|
||||
},
|
||||
{
|
||||
"q": "How do I manage my active sessions?",
|
||||
"a": "Go to Settings > 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 — use the Logout button instead."
|
||||
},
|
||||
{
|
||||
"q": "What happens when I change my password?",
|
||||
"a": "When you change your password in Settings > 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 — recommended screenings, vaccines, and anticipatory guidance for each visit age Milestones — developmental milestone tracker from birth through 11 years across multiple domains SSHADESS — adolescent psychosocial assessment (Strengths, School, Home, Activities, Drugs, Emotions, Sexuality, Safety) for ages 12+ Visit Note — 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 — within normal limits, no concerns Abnormal — a text box appears so you can describe the finding Not Reviewed / Not Examined — 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 — 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 — CDC 2000 growth reference with extended obesity classification (Class 1, 2, 3 using % of 95th percentile). Shows BMI chart with percentile curves. Growth Charts — 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 — 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 — 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 — Mosteller formula for BSA calculation. Weight-Based Dosing — 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
@ -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
|
|
@ -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 (0–5)',
|
||||
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 (0–4+)',
|
||||
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 (0–9)',
|
||||
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'],
|
||||
['5–6°', '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)'],
|
||||
['2–12 months', '≤ 50 /min (WHO tachypnea cutoff)'],
|
||||
['1–5 years', '≤ 40 /min (WHO tachypnea cutoff)'],
|
||||
['6–11 years', '≤ 30 /min'],
|
||||
['≥ 12 years', '≤ 20 /min (adult pattern)'],
|
||||
],
|
||||
},
|
||||
spo2: {
|
||||
title: 'Pulse oximetry (SpO₂) — at room air',
|
||||
icon: 'fa-heart-pulse',
|
||||
rows: [
|
||||
['≥ 95%', 'Normal'],
|
||||
['92–94%', 'Mild hypoxemia — investigate cause'],
|
||||
['< 92%', 'Moderate hypoxemia — supplemental O₂'],
|
||||
['< 88%', 'Severe — urgent intervention; target ≥ 90% acutely'],
|
||||
],
|
||||
},
|
||||
silverman: {
|
||||
title: 'Silverman–Andersen retraction score (neonatal, 0–10)',
|
||||
icon: 'fa-baby',
|
||||
rows: [
|
||||
['0', 'No respiratory distress'],
|
||||
['1–3', 'Mild — close observation'],
|
||||
['4–6', 'Moderate distress — consider CPAP / support'],
|
||||
['7–10', 'Severe — imminent respiratory failure, intubate'],
|
||||
],
|
||||
},
|
||||
westley: {
|
||||
title: 'Westley croup severity score',
|
||||
icon: 'fa-stethoscope',
|
||||
rows: [
|
||||
['≤ 2', 'Mild — home management, cool mist, oral dexamethasone'],
|
||||
['3–5', 'Moderate — nebulised epinephrine + dexamethasone'],
|
||||
['6–11', 'Severe — admit, continuous monitoring'],
|
||||
['≥ 12', 'Impending respiratory failure — ICU / airway management'],
|
||||
],
|
||||
},
|
||||
murmurGrade: {
|
||||
title: 'Heart-murmur grading (Levine 1–6)',
|
||||
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 (0–4)',
|
||||
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'],
|
||||
['2–3 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: '4th–5th ICS, lower left sternal border', listen: 'Tricuspid regurgitation, VSD, S3/S4, holosystolic murmurs',
|
||||
innocent: 'Still\'s murmur — vibratory, musical, age 3–7 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: '3–7 y (most common in children)',
|
||||
location: 'LLSB, radiating to apex',
|
||||
character: 'Low-frequency vibratory / musical systolic, grade 2–3/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 (2nd–3rd ICS)',
|
||||
character: 'Soft blowing early systolic ejection, grade 1–2/6, higher-pitched',
|
||||
confirm: 'No ejection click. Physiologic split of S2. Louder supine, softer on standing. No radiation.' },
|
||||
{ name: 'Venous hum',
|
||||
age: 'Ages 3–8, 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 2–3/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 < 6–12 months',
|
||||
location: 'Upper LSB, radiates to BOTH axillae and the back',
|
||||
character: 'Soft systolic ejection murmur, grade 1–2/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 S1–S2 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 (120–160 bpm range)',
|
||||
features: 'Same S1–S2 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 3–7 y (this recording is a toddler)',
|
||||
features: 'Low-frequency vibratory / musical systolic, grade 2–3/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.' },
|
||||
];
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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' }),
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 <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 <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 <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 <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 /><1 kg / <28 wk: 2.5 · 1-2 kg / 28-34 wk: 3.0 · 2-3 kg / 34-38 wk: 3.5 · >3 kg / >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₂ <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"><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"><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 <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₂ & 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 >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 cmH₂O 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 cmH₂O · 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 <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 cmH₂O. 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 <30 cmH₂O (ideally <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 & 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 (>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> (<72 h): GBS, E. coli, Listeria. <strong>Late-onset</strong> (>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 <28 d</strong> if hyperbilirubinemia." />
|
||||
<Row name="Ampicillin" dose={<Dose label={f(100, 2000)!.label} />} route="IV" notes="If <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="<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 >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₂ >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 & 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">>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 (>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 <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 >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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,163 +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';
|
||||
|
||||
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
|
||||
|
||||
interface VisitInput { date: string; content: string; labs: string }
|
||||
|
||||
function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; }
|
||||
|
||||
export default function ChartReview() {
|
||||
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>
|
||||
|
||||
<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 && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">Chart Review</h2>
|
||||
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +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';
|
||||
|
||||
type Setting = 'outpatient' | 'inpatient';
|
||||
|
||||
export default function Dictation() {
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [setting, setSetting] = useState<Setting>('outpatient');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = 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, 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('');
|
||||
setResult(null);
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
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">Voice Dictation → HPI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dictate your narrative → AI restructures into polished HPI.
|
||||
Audio-capture UI is a follow-up; this minimal form supports typed/pasted transcripts.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<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="Type or paste your dictation here, then click Generate."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(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>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generate.isPending || !transcript.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 && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">Generated HPI</h2>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(result)}
|
||||
className="text-xs text-muted-foreground underline"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
// ============================================================
|
||||
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter
|
||||
// Minimum-viable port: same shape as Dictation (same endpoint family).
|
||||
// Audio capture + save/load + refine deferred to follow-up commits.
|
||||
// ============================================================
|
||||
|
||||
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';
|
||||
|
||||
type Setting = 'outpatient' | 'inpatient';
|
||||
|
||||
export default function Encounter() {
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [setting, setSetting] = useState<Setting>('outpatient');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = 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, 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';
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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="Type or paste an encounter transcript, then click Generate."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(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 || !transcript.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 && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">Generated HPI</h2>
|
||||
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">
|
||||
Copy
|
||||
</button>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,149 +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';
|
||||
|
||||
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
|
||||
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
|
||||
|
||||
interface NoteEntry { date: string; type: string; content: string }
|
||||
|
||||
export default function HospitalCourse() {
|
||||
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&P → hospital course summary (prose, day-by-day, or organ-system format).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<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&P</span>
|
||||
<textarea className={input + ' min-h-[120px] font-mono text-sm'} value={hAndPContent} onChange={(e) => setHAndPContent(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<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 && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">
|
||||
Hospital Course <span className="text-xs font-normal text-muted-foreground">({result.format})</span>
|
||||
</h2>
|
||||
<button onClick={() => navigator.clipboard.writeText(result.hospitalCourse)} className="text-xs text-muted-foreground underline">Copy</button>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result.hospitalCourse}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,436 +0,0 @@
|
|||
// ============================================================
|
||||
// LEARNING HUB — pediatric education, pearls, and self-assessment
|
||||
// quizzes. Minimum-viable port:
|
||||
//
|
||||
// • 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 (body rendered as pre-wrap text for now — rich HTML via
|
||||
// DOMPurify / Markdown lands as a follow-up; presentations link to
|
||||
// the legacy /#learning/:slug view since Marp rendering is its own port)
|
||||
// • 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 { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
LearningCategoriesOk,
|
||||
LearningCategory,
|
||||
LearningFeedListOk,
|
||||
LearningFeedRow,
|
||||
LearningContentOk,
|
||||
LearningContentFull,
|
||||
LearningQuestion,
|
||||
QuizAnswer,
|
||||
QuizSubmitOk,
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
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' ? (
|
||||
<div className="text-center py-8 space-y-3 bg-muted/30 rounded-md">
|
||||
<div className="text-4xl">📊</div>
|
||||
<div className="text-sm font-medium">{c.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Slide rendering lives in the legacy viewer.
|
||||
</div>
|
||||
<a
|
||||
href={'/#learning/' + encodeURIComponent(c.slug)}
|
||||
className={btnPrimary + ' inline-block'}
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open in legacy viewer
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="whitespace-pre-wrap text-sm leading-relaxed"
|
||||
data-testid="lh-viewer-body"
|
||||
>
|
||||
{c.body || ''}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,101 +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';
|
||||
|
||||
export default function SickVisit() {
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [chiefComplaint, setChiefComplaint] = useState('');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = 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 body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript };
|
||||
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';
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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="Encounter narrative — transcribed or dictated."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(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 || !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 && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">Sick Visit Note</h2>
|
||||
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,119 +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';
|
||||
|
||||
type SoapType = 'full' | 'subjective';
|
||||
|
||||
export default function Soap() {
|
||||
const [patientAge, setPatientAge] = useState('');
|
||||
const [patientGender, setPatientGender] = useState('');
|
||||
const [type, setType] = useState<SoapType>('full');
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [additionalInstructions, setAdditionalInstructions] = useState('');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = 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, 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';
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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="Type or paste the encounter transcript."
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(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 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 || !transcript.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 && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">Generated SOAP</h2>
|
||||
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (0–18 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 & Adolescent Immunization Schedule (2025).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
// ============================================================
|
||||
// WELL VISIT — /api/well-visit/note (minimum-viable single-pane
|
||||
// port; the vanilla tab has 4 sub-panes for byvisit / milestones /
|
||||
// SSHADESS / note — each becomes its own sub-route or tab in a
|
||||
// follow-up commit).
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import type { VisitNoteOk } from '@/shared/types';
|
||||
|
||||
export default function WellVisit() {
|
||||
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 [screenings, setScreenings] = useState('');
|
||||
const [vaccines, setVaccines] = useState('');
|
||||
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const generate = useMutation<VisitNoteOk, Error, any>({
|
||||
mutationFn: (body) => api.post<VisitNoteOk>('/api/well-visit/note', body),
|
||||
onSuccess: (data) => setResult(data.note),
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
generate.mutate({
|
||||
patientAge, patientGender, visitAge,
|
||||
vitals, measurements, parentConcerns,
|
||||
transcript, screenings, vaccines,
|
||||
noteStyle,
|
||||
});
|
||||
}
|
||||
|
||||
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">Well Visit</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Preventive-care note generation. Milestones and SSHADESS sub-tabs land in a follow-up; this first port covers the Visit Note pane.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<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={transcript} onChange={(e) => setTranscript(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<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>
|
||||
|
||||
<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 && (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||
<h2 className="text-sm font-semibold">Well Visit Note</h2>
|
||||
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
|
||||
</header>
|
||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -1,557 +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[];
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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/',
|
||||
});
|
||||
|
|
@ -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
90
docs/ARCHITECTURE.md
Normal 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.
|
||||
97
docs/CLINICAL_ASSISTANT.md
Normal file
97
docs/CLINICAL_ASSISTANT.md
Normal 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.
|
||||
103
docs/DEVELOPMENT.md
Normal file
103
docs/DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Development
|
||||
|
||||
This is the practical guide for changing Ped-AI safely.
|
||||
|
||||
## Local Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
curl -fsS http://127.0.0.1:3552/api/health
|
||||
```
|
||||
|
||||
Run tests from the repository root:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Run a focused syntax check when touching backend entrypoints:
|
||||
|
||||
```bash
|
||||
node --check server.js
|
||||
node --check src/routes/clinicalAssistant.js
|
||||
```
|
||||
|
||||
## Code Map
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `server.js` | Express entrypoint, middleware, static serving, route mounting |
|
||||
| `src/routes/` | API route handlers |
|
||||
| `src/utils/ai.js` | Text model routing through configured providers |
|
||||
| `src/utils/clinicalAnswer.js` | Clinical Assistant answer prompt and source-grounding rules |
|
||||
| `src/utils/clinicalRetrieval.js` | MCP result normalization and source title cleanup |
|
||||
| `src/utils/clinicalMcpClient.js` | MCP streamable HTTP client/session handling |
|
||||
| `src/utils/litellm.js` | LiteLLM API/admin header helpers |
|
||||
| `src/db/database.js` | PostgreSQL pool and compatibility helpers |
|
||||
| `public/js/app.js` | SPA shell, tab loading, shared browser actions |
|
||||
| `public/js/admin.js` | Admin panel logic |
|
||||
| `public/js/assistant/` | Clinical Assistant rendering, sources, images, export, API helpers |
|
||||
| `public/js/learningHub/` | Newer modular Learning Hub frontend code |
|
||||
| `test/` | Node test suite and frontend module regression tests |
|
||||
|
||||
## Change Workflow
|
||||
|
||||
1. Read the relevant route, utility, frontend module, and tests before editing.
|
||||
2. Make the smallest correct change.
|
||||
3. Add or update a regression test when changing clinical rendering, model routing, auth, settings, or source handling.
|
||||
4. Run focused tests first if available.
|
||||
5. Run `npm test` before deploy or commit.
|
||||
6. Deploy with Docker only after tests pass.
|
||||
7. Verify `/api/health` after deploy.
|
||||
|
||||
## Clinical Assistant Changes
|
||||
|
||||
Clinical Assistant changes should usually include tests because small rendering or prompt changes can affect clinical trust.
|
||||
|
||||
High-risk areas:
|
||||
|
||||
- citation linking,
|
||||
- table rendering,
|
||||
- source title cleanup,
|
||||
- named-source provenance rules,
|
||||
- image intent detection,
|
||||
- MCP result normalization,
|
||||
- provider/model selection.
|
||||
|
||||
When a real answer renders badly, save a de-identified example as a fixture or direct test input. Do not make broad global repairs that convert arbitrary numbers into citation links.
|
||||
|
||||
## Frontend Rendering Rules
|
||||
|
||||
Use `textContent` for plain text. Use `innerHTML` only for static templates, sanitized markdown, or HTML built entirely from escaped values.
|
||||
|
||||
Safe patterns:
|
||||
|
||||
```js
|
||||
el.textContent = userText;
|
||||
el.innerHTML = escapeHtml(userText).replace(/\n/g, '<br>');
|
||||
el.innerHTML = sanitizeHtml(renderMarkdown(modelOutput));
|
||||
```
|
||||
|
||||
Unsafe pattern:
|
||||
|
||||
```js
|
||||
el.innerHTML = modelOutput;
|
||||
```
|
||||
|
||||
If a dynamic value enters an HTML string, escape it at the point of insertion. If it is an attribute value, escape quotes too.
|
||||
|
||||
## Deployment Checks
|
||||
|
||||
After deployment:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:3552/api/health
|
||||
docker compose ps pediatric-scribe
|
||||
```
|
||||
|
||||
If the browser still shows old frontend behavior, force-refresh or check the injected `BUILD_ID` asset query string.
|
||||
|
||||
## Documentation Expectations
|
||||
|
||||
Keep docs close to operational truth. If a behavior changes, update the most specific doc in the same change. Prefer short, current docs over long historical explanations.
|
||||
88
docs/MODULE_CONVENTIONS.md
Normal file
88
docs/MODULE_CONVENTIONS.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Module Conventions
|
||||
|
||||
Ped-AI currently uses mixed JavaScript module styles. This is intentional during incremental modernization.
|
||||
|
||||
## Current Convention
|
||||
|
||||
| Area | Module Style | Notes |
|
||||
|---|---|---|
|
||||
| Backend `server.js`, `src/**` | CommonJS | Use `require` and `module.exports` for now |
|
||||
| New frontend modules | ESM | Use `import` and `export` |
|
||||
| Older frontend files | Classic browser globals | Convert only when touching the feature intentionally |
|
||||
| Dual browser/test files | Case-by-case | Keep classic style only when tests or browser globals require it |
|
||||
|
||||
Do not add root-level `"type": "module"` without a full backend migration plan. It would change how every `.js` file is interpreted by Node.
|
||||
|
||||
## CommonJS Example
|
||||
|
||||
```js
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
## ESM Example
|
||||
|
||||
```js
|
||||
import { escapeHtml } from './assistant/citations.js';
|
||||
|
||||
export function renderSourcesList(sources) {
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Modernization Path
|
||||
|
||||
1. New frontend code should be ESM where possible.
|
||||
2. Existing globals can remain until that feature is refactored.
|
||||
3. Keep browser script load order stable while refactoring.
|
||||
4. Export pure helper functions so Node tests can import them.
|
||||
5. Use `CustomEvent` or explicit imports instead of adding new global APIs when practical.
|
||||
|
||||
## Acceptable Globals
|
||||
|
||||
Globals are acceptable when they are part of the current shell contract.
|
||||
|
||||
Examples:
|
||||
|
||||
- `window.activateTab`,
|
||||
- `window.getAuthHeaders`,
|
||||
- shared UI helpers still consumed by legacy feature files.
|
||||
|
||||
Do not add new globals when an import or event would be clearer.
|
||||
|
||||
## Rendering And `innerHTML`
|
||||
|
||||
`innerHTML` is allowed only when one of these is true:
|
||||
|
||||
- the HTML is a static template controlled by the app,
|
||||
- all dynamic values are escaped before insertion,
|
||||
- the HTML has passed through the approved sanitizer,
|
||||
- the content is a trusted app component fetched from `public/components/`.
|
||||
|
||||
Prefer `textContent` for plain text.
|
||||
|
||||
Unsafe:
|
||||
|
||||
```js
|
||||
el.innerHTML = userText;
|
||||
el.innerHTML = modelOutput;
|
||||
```
|
||||
|
||||
Safer:
|
||||
|
||||
```js
|
||||
el.textContent = userText;
|
||||
el.innerHTML = escapeHtml(userText).replace(/\n/g, '<br>');
|
||||
el.innerHTML = sanitizeHtml(renderMarkdown(modelOutput));
|
||||
```
|
||||
|
||||
## Test Expectations
|
||||
|
||||
When converting a frontend file to ESM, add or update tests for:
|
||||
|
||||
- exported helper functions,
|
||||
- expected globals still present if legacy code needs them,
|
||||
- no browser-native `prompt`, `alert`, or `confirm`,
|
||||
- no unescaped dynamic text inserted through `innerHTML`.
|
||||
119
docs/SCALING.md
Normal file
119
docs/SCALING.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Scaling
|
||||
|
||||
This document describes how Ped-AI should scale without becoming harder to debug or maintain.
|
||||
|
||||
## Current Scaling Model
|
||||
|
||||
Ped-AI is currently a single app container backed by PostgreSQL and Redis. That is acceptable for self-hosted use, but the code should keep moving toward a shape where multiple app containers can run safely.
|
||||
|
||||
```txt
|
||||
reverse proxy
|
||||
-> pediatric-ai-scribe replica 1
|
||||
-> pediatric-ai-scribe replica 2
|
||||
-> shared PostgreSQL
|
||||
-> shared Redis
|
||||
-> LiteLLM
|
||||
-> MCP
|
||||
```
|
||||
|
||||
## Horizontal Scaling Requirements
|
||||
|
||||
| Requirement | Why It Matters |
|
||||
|---|---|
|
||||
| Session state in PostgreSQL/Redis | Any app replica can handle the next request |
|
||||
| No clinical state only in memory | Restarting or scaling containers should not lose required state |
|
||||
| Shared uploads/storage if files grow | Local container disk does not scale across replicas |
|
||||
| Idempotent migrations | Deploying more than one app container should not corrupt schema state |
|
||||
| Request timeouts | Slow providers should not exhaust Node workers |
|
||||
| Queue for slow jobs | Long work should not block interactive requests |
|
||||
| Readiness endpoint | Load balancer should only send traffic to ready replicas |
|
||||
|
||||
## What Can Stay In Memory
|
||||
|
||||
Small process-local caches are acceptable when they are optional and short-lived.
|
||||
|
||||
Examples:
|
||||
|
||||
- settings cache with short TTL,
|
||||
- provider model metadata cache,
|
||||
- static configuration derived at boot.
|
||||
|
||||
Do not store required user workflow state only in memory if the action must survive restart or run across replicas.
|
||||
|
||||
## Redis Use
|
||||
|
||||
Redis is appropriate for:
|
||||
|
||||
- prompt suggestion pools,
|
||||
- rate-limit coordination if needed,
|
||||
- queues and job status,
|
||||
- short-lived provider metadata,
|
||||
- operational locks.
|
||||
|
||||
Redis should not be used for final clinical answer response caching. Clinical answers should be generated live from current retrieval context.
|
||||
|
||||
## Queue Candidates
|
||||
|
||||
Consider moving these to a queue when latency or concurrency becomes a problem:
|
||||
|
||||
- long transcription jobs,
|
||||
- file import/export,
|
||||
- Learning Hub AI generation from large files,
|
||||
- image generation,
|
||||
- bulk document operations,
|
||||
- provider metadata refresh,
|
||||
- long-running admin maintenance actions.
|
||||
|
||||
BullMQ with Redis is a natural fit if a queue is added.
|
||||
|
||||
## Readiness And Health
|
||||
|
||||
Keep `/api/health` fast and simple for liveness.
|
||||
|
||||
Add a separate readiness endpoint when scaling:
|
||||
|
||||
```txt
|
||||
GET /api/ready
|
||||
```
|
||||
|
||||
It should check:
|
||||
|
||||
- PostgreSQL query works,
|
||||
- Redis ping works if Redis is required for this deployment,
|
||||
- core settings can be read,
|
||||
- MCP health is reachable if Clinical Assistant is enabled,
|
||||
- LiteLLM metadata or configured model endpoint is reachable if AI features are enabled.
|
||||
|
||||
## Database Scaling
|
||||
|
||||
Priorities:
|
||||
|
||||
- confirm indexes on hot user/session/settings/log tables,
|
||||
- keep migrations explicit and reversible where practical,
|
||||
- monitor slow queries,
|
||||
- cap admin log queries with safe limits,
|
||||
- keep audit/log writes batched where possible,
|
||||
- avoid long transactions around provider calls.
|
||||
|
||||
## Provider Scaling
|
||||
|
||||
LiteLLM and MCP can become the bottlenecks before Ped-AI does.
|
||||
|
||||
Track:
|
||||
|
||||
- LiteLLM request latency,
|
||||
- LiteLLM error rate by model,
|
||||
- MCP search latency,
|
||||
- MCP timeout/error rate,
|
||||
- queue depth if async jobs are added,
|
||||
- Postgres connections,
|
||||
- app container memory and event-loop delay.
|
||||
|
||||
## Scaling Order
|
||||
|
||||
1. Add request IDs across browser, Ped-AI, MCP, and LiteLLM calls.
|
||||
2. Add `/api/ready` for dependency readiness.
|
||||
3. Ensure sessions and settings are not process-local.
|
||||
4. Add a queue for slow jobs if interactive requests block.
|
||||
5. Run a second app replica behind the reverse proxy in a staging/test environment.
|
||||
6. Add metrics and alerts around latency, errors, and resource saturation.
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
# AI providers
|
||||
|
||||
All AI calls flow through `callAI(messages, options)` in `src/utils/ai.js`.
|
||||
Provider is selected once at startup and is transparent to callers.
|
||||
Provider is selected at startup and is transparent to route handlers.
|
||||
|
||||
## Provider selection
|
||||
|
||||
1. If `AI_PROVIDER` env var is set, use it.
|
||||
2. Otherwise, check credentials in priority order:
|
||||
`bedrock > azure > vertex > litellm > openrouter`.
|
||||
1. If `AI_PROVIDER` is set, it chooses `bedrock`, `azure`, `vertex`,
|
||||
`litellm`, or `openrouter` explicitly.
|
||||
2. If `AI_PROVIDER` is unset, `ai.js` initializes every configured client and
|
||||
the last configured non-OpenRouter provider wins in current load order:
|
||||
Bedrock → Azure → Vertex → LiteLLM. If none of those are configured,
|
||||
OpenRouter is the default.
|
||||
3. If the selected provider cannot initialize, the code falls back to
|
||||
OpenRouter and surfaces an error if `OPENROUTER_API_KEY` is missing.
|
||||
|
||||
## Providers
|
||||
|
||||
|
|
@ -15,7 +20,7 @@ Provider is selected once at startup and is transparent to callers.
|
|||
|
||||
- SDK: `@aws-sdk/client-bedrock-runtime`.
|
||||
- Uses Bedrock **inference profiles** for newer models (cross-region routing).
|
||||
- Model families: vendor model (Anthropic), Amazon Nova, Llama (Meta), Mistral, DeepSeek, Cohere.
|
||||
- Model families: Amazon Nova, Llama (Meta), Mistral, DeepSeek, Cohere, and other Bedrock-hosted families.
|
||||
|
||||
### Azure OpenAI (BAA-eligible)
|
||||
|
||||
|
|
@ -27,7 +32,7 @@ Provider is selected once at startup and is transparent to callers.
|
|||
|
||||
- SDK: `@google-cloud/vertexai`.
|
||||
- Also serves STT (Gemini inline audio) and TTS (Vertex TTS endpoint).
|
||||
- Families: Gemini 2.5 / 2.0, vendor model on Vertex (Anthropic via GCP), Llama.
|
||||
- Families: Gemini 2.5 / 2.0 and Llama.
|
||||
|
||||
### LiteLLM proxy (self-hosted)
|
||||
|
||||
|
|
@ -124,9 +129,11 @@ Applied to: `soap.js`, `hpi.js`, `refine.js`, `sickVisit.js`, `wellVisit.js`,
|
|||
|
||||
### Physician memories
|
||||
|
||||
Saved corrections are injected into prompts as `[STYLE HINTS (low priority)]`
|
||||
with 200-character snippets. The low-priority wording prevents smaller models
|
||||
from hallucinating content from the correction examples into the current note.
|
||||
Saved templates and prompt preferences are injected into prompts as
|
||||
`[STYLE HINTS (low priority)]` when they belong to AI-context categories. The
|
||||
low-priority wording prevents smaller models from hallucinating content from a
|
||||
stored template into the current note. `custom` memories and legacy
|
||||
`correction_*` rows are not prompt context.
|
||||
|
||||
## API call logging
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,6 @@
|
|||
# Architecture
|
||||
|
||||
Self-hosted, single-tenant clinical documentation platform. Dockerized Node.js
|
||||
server + PostgreSQL + vanilla-JS SPA. No build step on the frontend.
|
||||
Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend.
|
||||
|
||||
## Stack
|
||||
|
||||
|
|
@ -9,9 +8,11 @@ server + PostgreSQL + vanilla-JS SPA. No build step on the frontend.
|
|||
|---|---|
|
||||
| Runtime | Node.js 20 (Alpine) + Express 4 |
|
||||
| Database | PostgreSQL 16 with `pgvector` extension |
|
||||
| Cache / state | Redis for operational cache, prompt suggestions, and queue groundwork |
|
||||
| Frontend | Vanilla JavaScript SPA, service-worker cache |
|
||||
| Mobile | Capacitor 6 wrapper (Android + iOS) |
|
||||
| Container | Docker Compose (app + db) |
|
||||
| Container | Docker Compose (app + db + Redis) |
|
||||
| Observability | Prometheus metrics at `/metrics`; structured app logs in files, Postgres, and optional Loki |
|
||||
| Reverse proxy | External (Caddy, Nginx, Traefik — any) |
|
||||
|
||||
## Repository layout
|
||||
|
|
@ -47,9 +48,9 @@ src/
|
|||
logger.js # audit/api/access + Loki shipper
|
||||
errors.js # generic 500 responder
|
||||
models.js, prompts.js, ai.js # AI provider + model + prompt management
|
||||
embeddings.js # Vertex / LiteLLM / OpenAI embeddings
|
||||
transcribe*.js, tts*.js # STT / TTS provider clients
|
||||
routes/ # 27 Express routers (auth, hpi, soap, …)
|
||||
embeddings.js # LiteLLM embeddings
|
||||
transcribe.js, tts.js # LiteLLM STT / TTS routes
|
||||
routes/ # Express routers (auth, hpi, soap, patient education, …)
|
||||
|
||||
public/ # SPA
|
||||
index.html # shell, loads components on demand
|
||||
|
|
@ -57,16 +58,19 @@ public/ # SPA
|
|||
js/ # 24 vanilla JS modules
|
||||
components/ # per-tab HTML fragments
|
||||
css/styles.css
|
||||
models/ # bundled Whisper WASM + model files
|
||||
|
||||
mobile/ # Capacitor wrapper
|
||||
capacitor.config.json # appId com.pedshub.scribe
|
||||
src/ # launcher (server-URL picker)
|
||||
android/ # generated AS project + native Java
|
||||
|
||||
.forgejo/workflows/
|
||||
android-apk.yml # signed APK on tag push; optional Play upload
|
||||
docker-build.yml # Forgejo registry Docker image build
|
||||
|
||||
.github/workflows/
|
||||
auto-version.yml # conventional-commits → semver bump → tag
|
||||
android-release.yml # signed APK on tag push
|
||||
android-release.yml # legacy GitHub tag APK release path
|
||||
docker-publish.yml # multi-arch image on tag push
|
||||
version-bump.yml # manual dispatch override
|
||||
build-apk.yml # legacy TWA APK
|
||||
|
|
@ -82,7 +86,7 @@ request
|
|||
→ express.json (10 MB cap)
|
||||
→ rate limiters (general 200 req/min, per-endpoint tighter on auth)
|
||||
→ static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
|
||||
→ route (27 routers under /api/*)
|
||||
→ route (feature routers under /api/*)
|
||||
→ authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
|
||||
→ handler
|
||||
→ response
|
||||
|
|
@ -121,6 +125,12 @@ per-feature HTML fragments under `public/components/` fetched on demand. JS
|
|||
modules talk via `window` globals and `CustomEvent` on `document` — no
|
||||
bundler, no framework. Loader order is fixed in `index.html`.
|
||||
|
||||
Post-note helpers such as billing suggestions, don't-miss review, and patient
|
||||
education handouts are reusable browser-side actions backed by authenticated
|
||||
JSON APIs. The patient education helper generates a parent-facing plain-text
|
||||
draft from the edited note and keeps the clinician in the review loop before
|
||||
copying or sharing.
|
||||
|
||||
`authFetch.js` installs a global `fetch` interceptor that treats any 401 on an
|
||||
authenticated request as a signal to clear local session state and redirect to
|
||||
login. A `BroadcastChannel('pedscribe-auth')` pushes that signal to sibling
|
||||
|
|
@ -132,8 +142,9 @@ tabs so logging out in one tab drops UI in every open tab.
|
|||
|---|---|---|---|
|
||||
| `pediatric-ai-scribe` | `ped-ai-local:latest` (built from repo) | 3000 | 127.0.0.1:3552 |
|
||||
| `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed |
|
||||
| `ped-ai-redis` | Redis | 6379 | not exposed |
|
||||
|
||||
Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs).
|
||||
Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs), and Redis data if persistence is enabled by compose.
|
||||
Application health-check polls `GET /api/health`.
|
||||
|
||||
A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is
|
||||
|
|
@ -149,3 +160,11 @@ never bound to a public interface directly.
|
|||
|
||||
Precached on install: `index.html`, core JS, main stylesheet, login component.
|
||||
Cleared on logout (`caches.keys() → caches.delete()`).
|
||||
|
||||
## Clinical Assistant And MCP
|
||||
|
||||
The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse.
|
||||
|
||||
## Speech
|
||||
|
||||
Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through LiteLLM; upstream provider choice belongs in LiteLLM config. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.
|
||||
|
|
|
|||
|
|
@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
|
|||
|
||||
## Turnstile (Cloudflare bot protection)
|
||||
|
||||
Applied to `/api/auth/login`, `/register`, `/forgot-password` when
|
||||
Applied to `/api/auth/register` and `/api/auth/forgot-password` when
|
||||
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
|
||||
|
||||
`/api/auth/login` is deliberately **not** gated: the widget could not
|
||||
reliably complete a challenge inside the Capacitor WebView, which locked
|
||||
mobile users out of the app. Login is covered instead by its per-IP rate
|
||||
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
|
||||
|
||||
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
|
||||
the first time their form becomes visible — Turnstile does not reliably
|
||||
complete a challenge inside a `display:none` container, and both forms start
|
||||
hidden. Tokens are captured from the render callback, not read back out of
|
||||
the injected `[name="cf-turnstile-response"]` input.
|
||||
|
||||
Note that the site key is currently **hardcoded** in `public/index.html`.
|
||||
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
|
||||
|
||||
## Encryption at rest
|
||||
|
||||
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
|
||||
|
|
@ -146,7 +160,7 @@ Helmet defaults plus:
|
|||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
||||
- Content-Security-Policy:
|
||||
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
|
||||
(`unsafe-eval` is required by @xenova/transformers for in-browser Whisper)
|
||||
(do not add `unsafe-eval` unless a reviewed dependency requires it)
|
||||
- `script-src-attr 'none'` (blocks inline event handlers)
|
||||
- `frame-src 'self' challenges.cloudflare.com`
|
||||
- `object-src 'none'`
|
||||
|
|
|
|||
|
|
@ -29,39 +29,33 @@ keys):
|
|||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. Auto-detected by credential presence if unset. |
|
||||
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. If unset, the startup loader uses configured credentials and the last initialized provider in Bedrock → Azure → Vertex → LiteLLM order wins; otherwise OpenRouter is the default. |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter key (not HIPAA-eligible). |
|
||||
| `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock / Transcribe / Transcribe-Medical. |
|
||||
| `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock chat provider. |
|
||||
| `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. |
|
||||
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini (STT/TTS). |
|
||||
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI chat provider. |
|
||||
| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). |
|
||||
|
||||
### Speech-to-text
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `TRANSCRIBE_PROVIDER` | `google`, `aws`, `local`, `openai`, `litellm`. Auto-detects if unset. |
|
||||
| `OPENAI_API_KEY` | OpenAI Whisper. |
|
||||
| `GOOGLE_STT_MODEL` | Gemini model used as STT (default `gemini-2.0-flash`). |
|
||||
| `AWS_TRANSCRIBE_MEDICAL` | `true` enables Transcribe Medical. |
|
||||
| `AWS_TRANSCRIBE_SPECIALTY` | `PRIMARYCARE` / `CARDIOLOGY` / `NEUROLOGY` / `ONCOLOGY` / `RADIOLOGY` / `UROLOGY`. |
|
||||
| `WHISPER_BINARY`, `WHISPER_MODEL_SIZE`, `WHISPER_MODEL_PATH`, `WHISPER_LANGUAGE`, `WHISPER_THREADS` | Local whisper.cpp / faster-whisper. |
|
||||
| `TRANSCRIBE_PROVIDER` | Use `litellm`; auto mode uses LiteLLM when configured. |
|
||||
| `LITELLM_STT_MODEL` | Model name for LiteLLM-routed STT. |
|
||||
|
||||
### Text-to-speech
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `GOOGLE_TTS_VOICE` | Google Cloud TTS voice (e.g. `en-US-Journey-F`). |
|
||||
| `ELEVENLABS_API_KEY` | ElevenLabs (not HIPAA-compliant). |
|
||||
| `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` | LiteLLM-routed TTS. |
|
||||
| `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` | LiteLLM-routed TTS model and default voice. |
|
||||
| `LITELLM_TTS_VOICES` | Comma-separated LiteLLM-compatible voices exposed in voice search and user preferences. |
|
||||
|
||||
### Embeddings
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `EMBEDDING_MODEL` | Embedding model name (default `text-embedding-005`, Vertex). |
|
||||
| `EMBEDDING_DIMENSIONS` | Vector dimensions (default 768). |
|
||||
| `EMBEDDING_MODEL` | LiteLLM embedding model name (default `openai-text-embedding-3-large`). |
|
||||
| `EMBEDDING_DIMENSIONS` | Vector dimensions (default 3072). |
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
|
|
@ -178,8 +172,8 @@ OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies.
|
|||
|
||||
3. **Update model names** — Different gateways use different naming
|
||||
conventions. Bifrost requires `provider/model` format
|
||||
(e.g., `openrouter/vendor-model-sonnet-4.6`), while LiteLLM uses aliases
|
||||
(e.g., `openrouter-vendor-model-sonnet-4.6`). Update model names in:
|
||||
(e.g., `openrouter/gpt-4.1`), while LiteLLM can use deployment aliases
|
||||
(e.g., `openrouter-gpt-4.1`). Update model names in:
|
||||
- Admin Panel → Models (chat models)
|
||||
- Admin Panel → Settings → `stt.model` (speech-to-text)
|
||||
- Admin Panel → Settings → `tts.model` (text-to-speech)
|
||||
|
|
|
|||
|
|
@ -138,20 +138,23 @@ Draft/complete encounter workspace. Auto-expires (default 7 d,
|
|||
|
||||
### `user_memories`
|
||||
|
||||
Per-user clinical-style hints injected into AI prompts.
|
||||
Per-user template and preference rows. Only selected categories are injected
|
||||
into AI generation through `/api/memories/context`; `custom` rows are stored
|
||||
for the user but not included in prompt context.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | SERIAL PK | |
|
||||
| user_id | INTEGER FK users.id ON DELETE CASCADE | |
|
||||
| category | TEXT NOT NULL DEFAULT 'custom' | `physical_exam`, `ros`, `encounter_format`, `custom`, `template_*`, `correction_*` |
|
||||
| name | TEXT NOT NULL | |
|
||||
| content | TEXT NOT NULL | |
|
||||
| category | TEXT NOT NULL DEFAULT 'custom' | Valid categories: `physical_exam`, `ros`, `encounter_format`, `family_history`, `assessment_plan`, `custom`, `template_soap`, `template_hpi`, `template_wellvisit`, `template_sickvisit`, `template_ed`. Legacy `correction_*` rows may exist but are filtered out. |
|
||||
| name | TEXT NOT NULL | Encrypted with `enc1:` for new rows |
|
||||
| content | TEXT NOT NULL | Encrypted with `enc1:` for new rows |
|
||||
| created_at, updated_at | TIMESTAMPTZ DEFAULT NOW() | |
|
||||
|
||||
### `audio_backups`
|
||||
|
||||
Retry store for failed-transcription audio.
|
||||
Optional 24-hour encrypted recovery store for recordings when transcription
|
||||
fails, so users can retry without re-recording.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@
|
|||
|
||||
| Image | Role |
|
||||
|---|---|
|
||||
| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push (multi-arch: `linux/amd64` + `linux/arm64`). Pull directly or build from source. |
|
||||
| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push where configured. Pull directly or build from source. |
|
||||
| `pgvector/pgvector:pg16` | Database. |
|
||||
| `redis:7-alpine` | Operational Redis cache/state. |
|
||||
|
||||
## Build from source
|
||||
|
||||
|
|
@ -23,8 +24,7 @@ cp .env.example .env
|
|||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Two containers come up: `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db`
|
||||
internal only.
|
||||
The default compose starts `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` internally, and `ped-ai-redis` internally.
|
||||
|
||||
## Minimum `.env`
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ App sets `trust proxy: 1` so rate limiting uses the original client IP.
|
|||
| Volume | Contents | Backup priority |
|
||||
|---|---|---|
|
||||
| `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical |
|
||||
| `scribe-logs` | Filesystem audit log files (JSONL by day) | Low — Postgres also has these in `audit_log` table |
|
||||
| `scribe-logs` | Filesystem audit log files (JSONL by day) | High for compliance evidence; Postgres also has audit/API/access tables |
|
||||
|
||||
### Postgres backup / restore
|
||||
|
||||
|
|
@ -120,6 +120,7 @@ REINDEXes if the ICU library version changed between image builds.
|
|||
| `GET /api/health` | `{ok:true}` — public, used by Docker health check |
|
||||
| `GET /api/health/detailed` | Provider status — admin-auth required |
|
||||
| `GET /api/build` | Build ID (short git SHA) — useful for debugging cache invalidation |
|
||||
| `GET /metrics` | Prometheus metrics in text exposition format |
|
||||
|
||||
Docker health check in `Dockerfile`: every 30 s, wget-spiders `/api/health`.
|
||||
Container marked unhealthy after 5 failures.
|
||||
|
|
@ -127,7 +128,7 @@ Container marked unhealthy after 5 failures.
|
|||
## Resource footprint
|
||||
|
||||
- RAM: 256 MB minimum, 512 MB recommended for one instance with a handful of concurrent users.
|
||||
- Disk: ~220 MB image (self-hosted Whisper WASM included). Postgres size scales with audit log retention.
|
||||
- Disk: Postgres size scales with audit log retention, saved encounters, documents, and Learning Hub content.
|
||||
- CPU: idle load negligible; AI calls are network-bound on the LLM provider side.
|
||||
|
||||
## Production checklist
|
||||
|
|
@ -141,14 +142,15 @@ Container marked unhealthy after 5 failures.
|
|||
- Turnstile keys set for public-facing deployments
|
||||
- Reverse proxy serves valid TLS certs
|
||||
- Postgres dump scheduled off-host
|
||||
- Log retention and backup policy covers `audit_log`, `api_log`, `access_log`, and filesystem `scribe-logs`
|
||||
|
||||
## CI / CD
|
||||
|
||||
Four workflows fire on tag push:
|
||||
On push (and tag push), these workflows run (depending on runner/site):
|
||||
|
||||
| Workflow | Output | Runtime |
|
||||
|---|---|---|
|
||||
| `android-release.yml` | Signed APK attached to the GitHub release | ~8 min |
|
||||
| `.forgejo/workflows/android-apk.yml` | Signed APK attached to the Forgejo release, plus optional Google Play internal track upload | ~8 min |
|
||||
| `docker-publish.yml` | Multi-arch image (amd64 + arm64 via native runners) on Docker Hub | ~4 min |
|
||||
| `build-apk.yml` | Legacy TWA APK (optional second artifact) | ~2 min |
|
||||
|
||||
|
|
@ -162,6 +164,7 @@ Triggered by `auto-version.yml` (reads commit messages, bumps + tags via
|
|||
|---|---|---|
|
||||
| App | 3000 | 127.0.0.1:3552 |
|
||||
| Postgres | 5432 | not exposed |
|
||||
| Redis | 6379 | not exposed |
|
||||
|
||||
Change the app's external port by editing the `ports:` mapping in
|
||||
`docker-compose.yml`.
|
||||
|
|
@ -174,6 +177,8 @@ Change the app's external port by editing the `ports:` mapping in
|
|||
via `src/utils/auditQueue.js`, drained on SIGTERM.
|
||||
4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event.
|
||||
|
||||
A central Prometheus/Loki/Grafana stack can also scrape `GET /metrics` and collect Docker logs with Promtail. Keep direct Loki push enabled only for structured application events that are useful for compliance and operations.
|
||||
|
||||
## Auto-cleanup
|
||||
|
||||
| Target | Policy | Frequency |
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ src/
|
|||
fileType.js magic-byte upload verifier
|
||||
errors.js generic 500 responder
|
||||
logger.js audit + api + access + Loki shipper
|
||||
embeddings.js Vertex / LiteLLM / OpenAI embeddings
|
||||
embeddings.js LiteLLM embeddings
|
||||
notify.js ntfy push
|
||||
transcribe*.js, tts*.js STT / TTS provider clients
|
||||
routes/ 27 routers
|
||||
transcribe.js, tts.js LiteLLM STT / TTS routes
|
||||
routes/ Express routers for auth, AI workflows, education, logs, and user data
|
||||
|
||||
public/
|
||||
index.html SPA shell, version-stamped asset refs
|
||||
|
|
@ -49,7 +49,7 @@ public/
|
|||
js/ 24 vanilla JS modules (no bundler)
|
||||
components/ per-tab HTML fragments loaded on demand
|
||||
css/styles.css
|
||||
models/ bundled Whisper WASM
|
||||
template-guide.md downloadable user template guide
|
||||
|
||||
mobile/ Capacitor 6 wrapper (Android + iOS)
|
||||
.github/workflows/ CI (auto-version, APK, docker)
|
||||
|
|
@ -243,24 +243,19 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
|
|||
3. Admin-editable automatically through `PUT /api/admin/config` which accepts
|
||||
arbitrary keys.
|
||||
|
||||
## Physician memory / correction tracker
|
||||
## Physician Templates And Preferences
|
||||
|
||||
1. On note generation, `trackAIOutput(elementId, text)` captures the original
|
||||
output in memory.
|
||||
2. User edits the note in a contenteditable field.
|
||||
3. On Save, `saveCorrection(elementId, section)` diffs current vs. original.
|
||||
4. If changed by > 2 words or > 20 characters, `POST /api/memories/correction`
|
||||
stores the before/after in `user_memories` with category
|
||||
`correction_{section}`.
|
||||
5. Next generation: `GET /api/memories/context` fetches the 10 most recent per
|
||||
category and `src/utils/prompts.js` injects them as
|
||||
`[STYLE HINTS (low priority)]` 200-character snippets.
|
||||
|
||||
Tabs with correction capture: Live Encounter, SOAP, Dictation, Sick Visit,
|
||||
Well Visit (Hospital Course and Chart Review save corrections when available
|
||||
but don't always have a trackable single output element).
|
||||
|
||||
Maximum 20 corrections retained per category (oldest deleted).
|
||||
1. Settings saves user templates/preferences through `/api/memories` into
|
||||
`user_memories`.
|
||||
2. New rows encrypt `name` and `content` with the shared `enc1:` string format.
|
||||
3. `GET /api/memories/context` decrypts rows and returns only AI-context
|
||||
categories: `physical_exam`, `ros`, `encounter_format`, `family_history`,
|
||||
`assessment_plan`, `template_soap`, `template_hpi`, `template_wellvisit`,
|
||||
`template_sickvisit`, and `template_ed`.
|
||||
4. `custom` rows remain visible in settings but are not included in prompt
|
||||
context.
|
||||
5. Legacy `correction_*` rows from the removed correction-learning feature are
|
||||
filtered out rather than deleted.
|
||||
|
||||
## Route reference
|
||||
|
||||
|
|
@ -277,10 +272,10 @@ Maximum 20 corrections retained per category (oldest deleted).
|
|||
| `sickVisit.js` | `/api` | Auth | Sick visit |
|
||||
| `milestones.js` | `/api` | Auth | Developmental milestone narratives |
|
||||
| `refine.js` | `/api` | Auth | Refine / shorten / clarify |
|
||||
| `transcribe.js` | `/api` | Auth | STT (5 providers) |
|
||||
| `tts.js` | `/api` | Auth | TTS (3 providers) |
|
||||
| `transcribe.js` | `/api` | Auth | LiteLLM STT |
|
||||
| `tts.js` | `/api` | Auth | LiteLLM TTS |
|
||||
| `encounters.js` | `/api` | Auth | Save / load / optimistic-lock encounters |
|
||||
| `memories.js` | `/api` | Auth | Templates + corrections |
|
||||
| `memories.js` | `/api` | Auth | Templates + prompt preferences |
|
||||
| `audioBackups.js` | `/api` | Auth | Encrypted audio retry store |
|
||||
| `documents.js` | `/api` | Auth | S3 documents (magic-byte checked) |
|
||||
| `userPreferences.js` | `/api` | Auth | Per-user STT/TTS choice |
|
||||
|
|
@ -307,10 +302,8 @@ Maximum 20 corrections retained per category (oldest deleted).
|
|||
| `milestones.js` + `milestonesData.js` | Milestones tab |
|
||||
| `shadess.js` | SSHADESS adolescent assessment |
|
||||
| `encounters.js` | Save / load / resume with optimistic lock |
|
||||
| `memories.js` | Physician templates + corrections UI |
|
||||
| `correctionTracker.js` | Captures AI-output edits |
|
||||
| `browserWhisper.js` | In-browser WASM Whisper |
|
||||
| `speechRecognition.js` | Web Speech API preview |
|
||||
| `memories.js` | Physician templates and prompt preferences UI |
|
||||
| `speechRecognition.js` | Explicit opt-in browser Web Speech support |
|
||||
| `voicePreferences.js` | Per-user STT/TTS override |
|
||||
| `audioBackup.js` | Server + IndexedDB backup retries |
|
||||
| `nextcloud.js` | Connect / export |
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Embeddings & Semantic Search Setup
|
||||
# Embeddings And Semantic Search Setup
|
||||
|
||||
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
|
||||
|
||||
## 🎯 What's New
|
||||
## What This Enables
|
||||
|
||||
- **Semantic search** - Find content by meaning, not just keywords
|
||||
- **3 search modes**:
|
||||
|
|
@ -10,9 +10,9 @@ This guide explains how to set up and use the new vector-based semantic search f
|
|||
- **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity
|
||||
- **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results
|
||||
- **Auto-embedding** - Content is automatically vectorized when created/updated
|
||||
- **HIPAA-compliant** - Uses Vertex AI embeddings (BAA available)
|
||||
- **Gateway-routed** - Uses LiteLLM embeddings so provider policy stays in one place
|
||||
|
||||
## 📋 Prerequisites
|
||||
## Prerequisites
|
||||
|
||||
### 1. Install pgvector Extension
|
||||
|
||||
|
|
@ -37,39 +37,24 @@ postgres:
|
|||
# ... rest of your config
|
||||
```
|
||||
|
||||
### 2. Configure Embedding Provider
|
||||
### 2. Configure LiteLLM Embeddings
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Option 1: Vertex AI (HIPAA-eligible, recommended)
|
||||
EMBEDDING_MODEL=vertex_ai/text-embedding-005
|
||||
EMBEDDING_DIMENSIONS=768
|
||||
VERTEX_PROJECT=your-gcp-project-id
|
||||
VERTEX_LOCATION=us-central1
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||
|
||||
# Option 2: LiteLLM Proxy (routes to any provider)
|
||||
LITELLM_API_BASE=http://localhost:4000
|
||||
LITELLM_API_KEY=your-key
|
||||
EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
|
||||
|
||||
# Option 3: OpenAI (NOT HIPAA-eligible, fallback only)
|
||||
OPENAI_API_KEY=sk-your-key
|
||||
# Uses text-embedding-3-small automatically
|
||||
EMBEDDING_MODEL=openai-text-embedding-3-large
|
||||
EMBEDDING_DIMENSIONS=3072
|
||||
```
|
||||
|
||||
## 🚀 Available Vertex AI Embedding Models
|
||||
## Available Embedding Models
|
||||
|
||||
Tested and working via LiteLLM:
|
||||
The Admin embedding search reads LiteLLM `/model/info` and only shows models with `model_info.mode = "embedding"`. Do not add app-side built-in Vertex/OpenAI embedding lists; configure those choices in LiteLLM.
|
||||
|
||||
| Model | Dimensions | Use Case | HIPAA |
|
||||
|-------|-----------|----------|-------|
|
||||
| **vertex_ai/text-embedding-005** | 768 | English + code (recommended) | ✅ Yes |
|
||||
| **vertex_ai/gemini-embedding-001** | 768-3072 | Multilingual + code, best quality | ✅ Yes |
|
||||
| **vertex_ai/text-multilingual-embedding-002** | 768 | Multilingual focus | ✅ Yes |
|
||||
The local LiteLLM instance currently exposes examples such as `openai-text-embedding-3-large`, `openai-text-embedding-3-small`, and Mistral embedding models. Dimensions are read from LiteLLM metadata when available.
|
||||
|
||||
## 🔧 Setup Steps
|
||||
## Setup Steps
|
||||
|
||||
### 1. Database Migration
|
||||
|
||||
|
|
@ -113,12 +98,12 @@ Response:
|
|||
"total": 50,
|
||||
"withEmbeddings": 50,
|
||||
"missing": 0,
|
||||
"model": "vertex_ai/text-embedding-005",
|
||||
"dimensions": 768
|
||||
"model": "openai-text-embedding-3-large",
|
||||
"dimensions": 3072
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 Using Semantic Search
|
||||
## Using Semantic Search
|
||||
|
||||
### Keyword Search (existing)
|
||||
```bash
|
||||
|
|
@ -144,12 +129,12 @@ GET /api/learning/search/hybrid?q=fever management
|
|||
```
|
||||
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
|
||||
|
||||
## 🔬 How It Works
|
||||
## How It Works
|
||||
|
||||
1. **Content Creation/Update**:
|
||||
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
|
||||
- Sent to embedding model (Vertex AI)
|
||||
- Returns 768-dimensional vector
|
||||
- Sent to the configured LiteLLM embedding model
|
||||
- Returns an embedding vector
|
||||
- Stored in `learning_content.embedding` column
|
||||
|
||||
2. **Semantic Search**:
|
||||
|
|
@ -164,35 +149,23 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
|
|||
- Deduplicates by content ID
|
||||
- Sorts by relevance score
|
||||
|
||||
## 💰 Cost Estimate (Vertex AI)
|
||||
## Cost Estimate
|
||||
|
||||
**Titan Text Embeddings (AWS) pricing:**
|
||||
- ~$0.10 per 1M tokens
|
||||
- Average article: 2,000 words (~2,700 tokens) = $0.00027
|
||||
- 1,000 articles: ~**$0.27 one-time**
|
||||
- Search queries: ~500 tokens = $0.00005 per query
|
||||
Embedding cost depends on the upstream configured in LiteLLM.
|
||||
|
||||
**Google Vertex AI pricing:**
|
||||
- text-embedding-005: $0.025 per 1M characters
|
||||
- Average article: 10,000 chars = $0.00025
|
||||
- 1,000 articles: ~**$0.25 one-time**
|
||||
- Search queries: ~$0.0000125 per query
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
## Troubleshooting
|
||||
|
||||
### "pgvector extension not available"
|
||||
- Install: `apt-get install postgresql-16-pgvector`
|
||||
- For Docker: Use `pgvector/pgvector:pg16` image
|
||||
|
||||
### "Embeddings not configured"
|
||||
- Verify `.env` has `VERTEX_PROJECT` or `LITELLM_API_BASE` or `OPENAI_API_KEY`
|
||||
- Check service account credentials: `GOOGLE_APPLICATION_CREDENTIALS`
|
||||
- Verify `.env` has `LITELLM_API_BASE`
|
||||
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
|
||||
|
||||
### "Embedding generation failed"
|
||||
- Check logs for API errors
|
||||
- Verify Vertex AI API is enabled in GCP
|
||||
- Verify service account has `aiplatform.endpoints.predict` permission
|
||||
- Verify LiteLLM `/model/info` shows the selected model with `mode: embedding`
|
||||
- Check content isn't empty (skips empty bodies)
|
||||
|
||||
### "No results from semantic search"
|
||||
|
|
@ -200,23 +173,23 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
|
|||
- Lower threshold: `?threshold=0.3` (default 0.5)
|
||||
- Verify pgvector index exists: `\di` in psql
|
||||
|
||||
## 📊 Performance
|
||||
## Performance
|
||||
|
||||
- **Embedding generation**: ~500ms per article (Vertex AI)
|
||||
- **Embedding generation**: latency depends on the LiteLLM upstream
|
||||
- **Search latency**:
|
||||
- Keyword: 10-50ms
|
||||
- Semantic: 20-100ms (with IVFFLAT index)
|
||||
- Hybrid: 30-150ms
|
||||
- **Index build time**: ~1-5 seconds per 1,000 articles
|
||||
|
||||
## 🔐 Security & Compliance
|
||||
## Security And Compliance
|
||||
|
||||
- **HIPAA-eligible**: Vertex AI supports BAA (Business Associate Agreement)
|
||||
- **Compliance**: controlled by the upstream provider configured in LiteLLM
|
||||
- **Data retention**: Embeddings stored in your database only
|
||||
- **No PHI**: Only article content (not patient data) is embedded
|
||||
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
|
||||
|
||||
## 🎓 Example Queries
|
||||
## Example Queries
|
||||
|
||||
**Before (keyword):**
|
||||
```
|
||||
|
|
@ -244,7 +217,7 @@ Results:
|
|||
- Bronchiolitis vs asthma (keyword: 1.0)
|
||||
```
|
||||
|
||||
## 📚 API Reference
|
||||
## API Reference
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
81
docs/features-explained.md
Normal file
81
docs/features-explained.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Features Explained
|
||||
|
||||
This file is a practical operator-oriented overview of major Ped-AI features. It intentionally describes the current fork, not historical browser Whisper behavior.
|
||||
|
||||
## Clinical Documentation
|
||||
|
||||
Ped-AI generates pediatric clinical notes from typed input, dictation, or recorded audio. Major workflows include live encounters, dictation cleanup, sick visits, well visits, SOAP notes, hospital courses, chart review, ED documentation, and developmental milestones.
|
||||
|
||||
Model selection is available per task where the UI exposes a tab-level selector. Admin defaults provide the baseline model and user/task choices can override that baseline.
|
||||
|
||||
Generated notes can expose post-note helper panels. Billing suggestions and don't-miss review are clinician-facing. Patient education handouts are parent-facing drafts generated from the edited note, with optional diagnosis, medication, and preferred-language context. The clinician must verify the handout before sharing it.
|
||||
|
||||
## Phone Extensions And Pagers
|
||||
|
||||
The bedside tools include a per-user phone extension and pager directory. Entries support active/trash views, search, soft delete/restore, permanent purge, ZIP export, and JSON/ZIP import. Import preview flags exact active duplicates, exact trashed matches that can be restored, and possible duplicates before committing changes.
|
||||
|
||||
## Speech
|
||||
|
||||
Final transcription is server-side through LiteLLM. Configure upstream STT providers in LiteLLM rather than in Ped-AI.
|
||||
|
||||
Browser-native Web Speech is only an explicit opt-in preview path. It is not the final clinical transcript and may use browser-vendor cloud services.
|
||||
|
||||
Browser Whisper and browser-local model workers are removed. Do not expect a pre-download model button, public Whisper worker, or bundled Xenova model path.
|
||||
|
||||
## Text To Speech
|
||||
|
||||
The voice preview button calls LiteLLM TTS and plays the returned audio in the browser. If preview is silent, check that a LiteLLM voice is selected, the gateway is configured, the user is authenticated, and browser autoplay has not blocked playback.
|
||||
|
||||
## Learning Hub
|
||||
|
||||
Learning Hub is both a learner-facing content area and an admin/moderator CMS.
|
||||
|
||||
- Articles and pearls render sanitized content.
|
||||
- Quizzes support single-answer, multi-select, and true/false questions.
|
||||
- Presentations use Marp-style markdown with preview and PPTX export.
|
||||
- AI generation can use topic text, uploaded source files, or connected Nextcloud WebDAV files.
|
||||
- Categories can organize content without deleting the content when category assignments change.
|
||||
|
||||
## Nextcloud WebDAV
|
||||
|
||||
Users can connect a Nextcloud account with an app password. Learning Hub AI generation can browse files from the connected WebDAV account, and users can set a default browse path to avoid repeatedly navigating to the same clinical content folder.
|
||||
|
||||
## Documents And S3
|
||||
|
||||
Document upload is optional and depends on S3-compatible storage configuration. Treat uploaded documents as PHI unless you have a separate deployment reason not to.
|
||||
|
||||
## Audio Backups
|
||||
|
||||
Audio backups exist to recover failed transcription attempts.
|
||||
|
||||
- They are created when transcription fails.
|
||||
- They are encrypted before persistent storage.
|
||||
- They expire automatically.
|
||||
- Users can retry or delete them from Settings.
|
||||
|
||||
## Admin Panel
|
||||
|
||||
Admins can manage users, roles, registration, security settings, model defaults, prompts, logs, and Learning Hub content. Production deployments should enable SSO/2FA and restrict admin access.
|
||||
|
||||
## Feature Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Clinical note generation | Active | Provider depends on `AI_PROVIDER`. |
|
||||
| Server transcription | Active | Google/AWS/LiteLLM/OpenAI paths. |
|
||||
| Browser Web Speech preview | Optional | Explicit opt-in only. |
|
||||
| Browser Whisper | Removed | No public worker or model download path. |
|
||||
| Learning Hub CMS | Active | Articles, pearls, quizzes, presentations. |
|
||||
| Nextcloud WebDAV | Active | Used for file browsing/content import. |
|
||||
| Patient handouts | Active | Parent-facing, note-derived, preferred-language draft. |
|
||||
| Extension transfer | Active | ZIP export plus JSON/ZIP import preview. |
|
||||
| Audio backups | Active | Failure recovery only. |
|
||||
| TTS preview | Active | Depends on configured provider. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check browser console for frontend errors.
|
||||
- Check `docker logs pediatric-ai-scribe -f` for backend errors.
|
||||
- Check `/api/health` for service status.
|
||||
- Check provider credentials and model names before debugging UI state.
|
||||
- For Learning Hub file import failures, verify Nextcloud URL, username, app password, and folder path.
|
||||
|
|
@ -52,7 +52,7 @@ This is the highest-impact improvement for adoption but also the most complex to
|
|||
|
||||
### 5. Offline Mode
|
||||
|
||||
**Current state:** The app requires an internet connection for AI generation and cloud-based transcription. Browser Whisper works offline for transcription only.
|
||||
**Current state:** The app requires configured server-side providers for AI generation and final transcription. Browser Whisper has been removed from the runtime.
|
||||
|
||||
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
|
||||
- Rural clinics with unreliable internet
|
||||
|
|
@ -74,9 +74,9 @@ Each specialty has unique documentation requirements that could be addressed wit
|
|||
|
||||
### 7. Billing Code Suggestions
|
||||
|
||||
**Current state:** The well visit tab includes some billing code references.
|
||||
**Current state:** Post-note billing suggestions are active as clinician-facing helper panels on supported note outputs.
|
||||
|
||||
**Improvement:** Automatically suggest ICD-10 and CPT codes based on the generated note content. After the AI generates a note, it could analyze the diagnoses, procedures, and visit complexity to suggest appropriate billing codes. This saves time on coding and reduces missed charges.
|
||||
**Further improvement:** Improve payer-specific rules, add institution-specific favorites, and add export formats that match common EHR coding workflows.
|
||||
|
||||
### 8. Quality Metrics Dashboard
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ Each specialty has unique documentation requirements that could be addressed wit
|
|||
**Improvement:** Add a dashboard showing:
|
||||
- Average note generation time by type
|
||||
- Most-used AI models and their accuracy (based on how often users edit the output)
|
||||
- Transcription accuracy metrics (if corrections are tracked)
|
||||
- Transcription quality metrics from explicit user feedback or retry outcomes
|
||||
- Usage patterns by time of day and day of week
|
||||
- Cost tracking across AI providers
|
||||
|
||||
|
|
@ -93,9 +93,9 @@ This would help administrators optimize model selection and identify training op
|
|||
|
||||
### 9. Patient Education Materials
|
||||
|
||||
**Current state:** The Learning Hub serves educational content to physicians.
|
||||
**Current state:** Patient education handouts are active as post-note helpers. Generated notes can open a Handout panel that creates a parent-facing plain-text draft from the clinician note, with optional diagnosis, medication, patient age, and preferred language context. The Learning Hub remains the physician-facing education/CMS area.
|
||||
|
||||
**Improvement:** Add a patient-facing education module that generates age-appropriate handouts based on the diagnosis. For example, after generating a note for a child with asthma, the app could produce a parent-friendly handout explaining the diagnosis, medications, and when to seek emergency care — in the parent's preferred language.
|
||||
**Further improvement:** Add handout templates, saved handout history, institution-approved language libraries, and printable/PDF export.
|
||||
|
||||
### 10. Multi-Language Support
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ This mirrors the real workflow in training institutions and group practices.
|
|||
|
||||
### 14. Template Library
|
||||
|
||||
**Current state:** Physician memories and corrections provide some personalization.
|
||||
**Current state:** Physician templates and prompt preferences provide per-user personalization. Legacy correction-learning rows may exist but are no longer active behavior.
|
||||
|
||||
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
|
||||
- "My asthma follow-up template"
|
||||
|
|
@ -182,7 +182,7 @@ Compared to existing medical scribes and documentation tools:
|
|||
|
||||
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
|
||||
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
|
||||
- **Provider-agnostic** — works with any AI provider (swap between them without changing anything)
|
||||
- **Privacy-first** — optional fully offline transcription, auto-expiring data, no permanent PHI storage
|
||||
- **Learning system** — AI improves its output based on each physician's editing patterns
|
||||
- **Provider-flexible** — routes through OpenRouter, Bedrock, Azure, Vertex, or LiteLLM depending on deployment configuration
|
||||
- **Privacy-conscious** — self-hosted app, encrypted sensitive fields, auto-expiring encounter/audio recovery data, and configurable BAA-eligible providers
|
||||
- **Template-aware** — user templates and prompt preferences can shape output without relying on automatic correction learning
|
||||
- **All-in-one** — documentation, calculators, education, and administration in a single platform
|
||||
114
docs/logic/README.md
Normal file
114
docs/logic/README.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Application Logic — index
|
||||
|
||||
> Deep, dev-friendly documentation of how each part of the ped-ai app
|
||||
> actually works. Written so a human developer can understand the
|
||||
> codebase without spelunking, and so an AI assistant can confidently
|
||||
> modify code without breaking high-risk workflows.
|
||||
|
||||
These docs explain **application logic** — what the user does, what the
|
||||
system does in response, what the data flow is, and **why** the design
|
||||
looks the way it does. They are not API reference (see
|
||||
[`../api-reference.md`](../api-reference.md)) and not deployment
|
||||
recipes (see [`../deployment.md`](../deployment.md)).
|
||||
|
||||
## Read in this order
|
||||
|
||||
For someone brand new to the codebase:
|
||||
|
||||
1. **[architecture.md](architecture.md)** — Start here. The big picture:
|
||||
current frontend pattern, lazy tab loading, backend route convention,
|
||||
PostgreSQL schema, encryption at rest, Dockerfile + compose layout,
|
||||
and high-risk zones.
|
||||
|
||||
2. **[clinical-notes.md](clinical-notes.md)** — How every clinical note
|
||||
tab works. The shared "record → transcribe → generate → save"
|
||||
lifecycle, then per-tab deep dives for Encounter HPI, Dictation HPI,
|
||||
Sick Visit, Well Visit, SOAP, Hospital Course, Chart Review, and
|
||||
Personal Notes. Includes the helper trio (refine / billing-codes /
|
||||
don't-miss).
|
||||
|
||||
3. **[ed-encounters.md](ed-encounters.md)** — The ED encounter feature
|
||||
(multi-stage notes, per-stage don't-miss, consolidate→MDM finalize).
|
||||
Newest, most explicit explanation of how a clinical workflow gets
|
||||
composed in this codebase. Read this for a worked example.
|
||||
|
||||
4. **[bedside-and-calculators.md](bedside-and-calculators.md)** —
|
||||
Bedside emergencies module, the pediatric calculators (BP percentile, Fenton growth,
|
||||
bilirubin nomograms, etc.), the PE Guide, vax schedule, milestones.
|
||||
Includes the suture selector. **Important:** lists every clinical
|
||||
formula that must NOT be modified without test vectors.
|
||||
|
||||
5. **[ai-and-voice.md](ai-and-voice.md)** — AI provider routing
|
||||
(`callAI`), the centralized `PROMPTS` object with DB overrides, the
|
||||
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
|
||||
routing, TTS, and the AudioRecorder. Voice/STT plumbing is high-risk — the
|
||||
doc describes it without proposing changes.
|
||||
|
||||
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
|
||||
(local + OIDC SSO + 2FA), session management, OpenBao secret loading
|
||||
at container start, the Admin panel (model allowlist, prompt
|
||||
overrides, milestone editor), and the Learning Hub (AI-authored
|
||||
quizzes / outlines / Marp presentations).
|
||||
|
||||
## What's NOT here
|
||||
|
||||
- **Reference data details.** Every clinical formula's *math* lives in
|
||||
the source files; this doc series points to the formula and explains
|
||||
*what it does* but doesn't reproduce the lookup tables.
|
||||
- **API endpoint signatures.** See [`../api-reference.md`](../api-reference.md).
|
||||
- **Operational runbooks.** See [`../deployment.md`](../deployment.md),
|
||||
[`../configuration.md`](../configuration.md).
|
||||
- **Recent change history.** See git log + the rollback tags
|
||||
(`pre-ts-migration-2026-04-26`, `pre-ed-encounters-2026-04-26`, etc.).
|
||||
|
||||
## Voice + conventions
|
||||
|
||||
Each doc follows the same structure:
|
||||
|
||||
- **Overview** — what this part is and why it exists
|
||||
- **User flow** — what the physician does and sees
|
||||
- **Data flow** — what HTTP calls happen, what the server does
|
||||
- **File map** — which files do what
|
||||
- **Key design decisions** — *why* it works the way it does
|
||||
- **High-risk zones** — what requires small, tested changes
|
||||
- **How to extend** — concrete recipes for adding a new X
|
||||
|
||||
When a doc mentions a high-risk zone, changes should be small, well-tested, and
|
||||
directly tied to the requested behavior. Current high-risk areas:
|
||||
|
||||
| Zone | Why |
|
||||
|---|---|
|
||||
| `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. |
|
||||
| Voice/STT plumbing (`audioBackup.js`, `speechRecognition.js`, `voicePreferences.js`, `transcriptionSettings.js`, recorder paths in each clinical tab) | Recording UX has been hardened against many edge cases; refactor only with smallest-diff bug fixes. |
|
||||
| Validated clinical formulas (BP percentile LMS, Fenton 2013, bilirubin AAP 2022, Bhutani, APLS / Best-Guess weight, PE Guide SCALES) | Validated against peditools / AAP tables; modifying without test vectors risks miscoding patient care. |
|
||||
| Auth + crypto (`crypto.js`, `passwords.js`, `sessions.js`, `auth.js`, `oidc.js`) | Security; changes without security review are unsafe. |
|
||||
| MDM rubric in `PROMPTS.edFinalize` | Load-bearing for billing accuracy; trim only with explicit AMA/coding source citation. |
|
||||
|
||||
## Cross-cutting topics
|
||||
|
||||
A few topics span multiple docs. Use these as your jump-off points:
|
||||
|
||||
| Topic | Where to look |
|
||||
|---|---|
|
||||
| Frontend globals, ES modules, and lazy tab loading | architecture.md |
|
||||
| Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md |
|
||||
| `getUserMemoryContext` → templates feeding into AI prompts | clinical-notes.md §6, ed-encounters.md §9 |
|
||||
| The helper trio: `refineDocument`, `suggestBillingCodes`, `suggestDontMiss` | ai-and-voice.md §12, clinical-notes.md §5 |
|
||||
| `wrapUserText` + `INJECTION_GUARD` prompt-injection defense | ai-and-voice.md §5 |
|
||||
| `saveEncounter` API + optimistic locking + idempotency keys | architecture.md §13, clinical-notes.md §4, ed-encounters.md §5 |
|
||||
| `cryptoUtil.encryptString` / `encryptBuffer` "enc1:" format | architecture.md §12 |
|
||||
| AI provider routing (`callAI`) | ai-and-voice.md §2-3 |
|
||||
| 2023 AMA E/M MDM rubric | ed-encounters.md §6 |
|
||||
| User templates (`user_memories` table, `template_*` categories) | clinical-notes.md §6, ed-encounters.md §9 |
|
||||
|
||||
## How to keep these docs current
|
||||
|
||||
Each doc has a date implicit in the most recent feature it describes.
|
||||
When you add a feature, update the relevant doc in the same commit.
|
||||
When you remove a feature (e.g., the Dragon-style AI corrections
|
||||
removal in late April 2026), remove its section + leave a one-line
|
||||
historical note in the relevant doc.
|
||||
|
||||
When you write a new doc, follow the same structure as these (Overview /
|
||||
User flow / Data flow / File map / Design decisions / Sacred zones /
|
||||
How to extend) and add it to this index.
|
||||
101
docs/logic/ai-and-voice.md
Normal file
101
docs/logic/ai-and-voice.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# AI, Speech, And Post-Note Helpers
|
||||
|
||||
This doc summarizes the current AI/STT/TTS pipeline without line-number
|
||||
citations. For exact behavior, read `src/utils/ai.js`, `src/routes/transcribe.js`,
|
||||
`src/routes/tts.js`, and the relevant frontend scripts.
|
||||
|
||||
## Text Generation
|
||||
|
||||
All text-generation routes call `callAI(messages, options)` from
|
||||
`src/utils/ai.js`.
|
||||
|
||||
Supported providers:
|
||||
|
||||
- OpenRouter.
|
||||
- AWS Bedrock.
|
||||
- Azure OpenAI.
|
||||
- Google Vertex AI.
|
||||
- LiteLLM or another OpenAI-compatible gateway.
|
||||
|
||||
`AI_PROVIDER` can explicitly choose the provider. If unset, the startup loader
|
||||
initializes configured clients and the final active provider follows the current
|
||||
load order described in [`../ai-providers.md`](../ai-providers.md). Route
|
||||
handlers do not call provider SDKs directly.
|
||||
|
||||
## Model Allowlist
|
||||
|
||||
`callAI()` rejects model IDs outside the active server-side allowlist unless a
|
||||
specific admin test path opts out. The allowlist is assembled from built-in
|
||||
provider models, `models.disabled`, and `models.custom` in `app_settings`.
|
||||
|
||||
The default model comes from the configured provider/model settings. Admins can
|
||||
set defaults and custom models from the Admin Panel.
|
||||
|
||||
## Prompt Safety
|
||||
|
||||
Clinical routes should build prompts with:
|
||||
|
||||
- canonical templates from `src/utils/prompts.js`
|
||||
- optional DB prompt overrides through `app_settings` keys `prompt.*`
|
||||
- `INJECTION_GUARD`
|
||||
- `wrapUserText(label, text)` around user-derived text
|
||||
|
||||
User-derived text includes transcripts, dictated notes, pasted chart data,
|
||||
refine instructions, template preferences, and patient education source notes.
|
||||
|
||||
## User Templates
|
||||
|
||||
`getUserMemoryContext()` fetches `/api/memories/context` and passes the returned
|
||||
template/preference context as `physicianMemories`. Server routes wrap that block
|
||||
as low-priority style/template context. `custom` memories and legacy
|
||||
`correction_*` rows are not prompt context.
|
||||
|
||||
## Speech-To-Text
|
||||
|
||||
`POST /api/transcribe` accepts one audio file up to 25 MB. Provider selection:
|
||||
|
||||
- explicit `TRANSCRIBE_PROVIDER=litellm`, or
|
||||
- auto mode when `LITELLM_API_BASE` is configured.
|
||||
|
||||
Direct Google, AWS, local Whisper, and OpenAI Whisper branches are not part of the runtime. Browser Whisper/browser-local model downloads are also absent.
|
||||
|
||||
## Browser Web Speech
|
||||
|
||||
Browser-native Web Speech is an explicit opt-in preview. It may rely on browser
|
||||
vendor cloud services and must not be treated as the final clinical transcript.
|
||||
Final transcription should come from the configured server-side STT provider.
|
||||
|
||||
## Audio Backup
|
||||
|
||||
Failed transcription attempts can create encrypted 24-hour audio backups through
|
||||
`src/routes/audioBackups.js`. The user can retry or delete backups from
|
||||
Settings. Browser fallback storage is only for cases where the server cannot
|
||||
store the failed recording.
|
||||
|
||||
## Text-To-Speech
|
||||
|
||||
`POST /api/text-to-speech` returns audio from LiteLLM and marks the LiteLLM
|
||||
model in `X-TTS-Provider`. Voices are LiteLLM-compatible strings configured by
|
||||
`LITELLM_TTS_VOICES`.
|
||||
|
||||
## Post-Note Helpers
|
||||
|
||||
Generated note outputs can expose helper panels:
|
||||
|
||||
- `refineDocument` for editing/refining/shortening generated text.
|
||||
- `suggestBillingCodes` for clinician-facing ICD/CPT suggestions.
|
||||
- `suggestDontMiss` for clinician-facing safety review.
|
||||
- `attachPatientEducation` for parent-facing handout drafts.
|
||||
|
||||
These helpers are authenticated API-backed actions. They should treat the edited
|
||||
note as the source of truth and keep the clinician in the review loop.
|
||||
|
||||
## Change Checklist
|
||||
|
||||
When changing this area:
|
||||
|
||||
1. Keep provider-specific code inside utility/provider modules.
|
||||
2. Wrap all user-derived text with `wrapUserText` before AI calls.
|
||||
3. Do not add browser-local Whisper back without a new design review.
|
||||
4. Do not add clinical answer response caching.
|
||||
5. Run touched-file `node --check` commands and `npm test`.
|
||||
93
docs/logic/architecture.md
Normal file
93
docs/logic/architecture.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Application Architecture Logic
|
||||
|
||||
This is the long-form companion to [`../architecture.md`](../architecture.md).
|
||||
Older versions of this file tried to document every source line and frontend
|
||||
wrapper pattern; that became stale as Ped-AI moved selected areas to ES modules,
|
||||
added cookie-based web auth, migrations, Redis, metrics, patient education, and
|
||||
mobile support.
|
||||
|
||||
## Current Shape
|
||||
|
||||
- Runtime: Node.js 20 + Express 4 in Docker.
|
||||
- Data: PostgreSQL 16 with pgvector, plus Redis for operational cache/prompt
|
||||
suggestion groundwork.
|
||||
- Schema: idempotent baseline init in `src/db/database.js` plus versioned
|
||||
migrations in `migrations/` through `node-pg-migrate`.
|
||||
- Frontend: vanilla JS SPA. Many files are still classic deferred scripts;
|
||||
isolated newer areas use ES modules. There is no frontend bundler.
|
||||
- Auth: web uses the `ped_auth` httpOnly cookie; mobile uses secure token
|
||||
storage and `Authorization: Bearer` headers. `user_sessions` is authoritative.
|
||||
- AI: `src/utils/ai.js` routes to OpenRouter, Bedrock, Azure, Vertex, or
|
||||
LiteLLM based on startup configuration and server-side model allowlists.
|
||||
- Speech: server-side STT providers plus explicit opt-in browser Web Speech
|
||||
preview. Browser Whisper/browser-local model downloads are not part of the
|
||||
runtime.
|
||||
- Observability: `/metrics`, structured JSONL logs, Postgres audit/API/access
|
||||
logs, and optional direct Loki push.
|
||||
|
||||
## Composition Root
|
||||
|
||||
`server.js` owns the boot and routing order:
|
||||
|
||||
1. Load environment and core middleware.
|
||||
2. Apply Helmet/CSP, CORS, cookie parsing, metrics, JSON limits, rate limiters,
|
||||
static file serving, and logging.
|
||||
3. Mount auth, admin, Learning Hub, clinical workflow, storage, user data,
|
||||
metrics, and utility routers.
|
||||
4. Serve the SPA fallback for non-API paths.
|
||||
5. Drain audit queues and close Postgres on shutdown.
|
||||
|
||||
For exact current route mounts, read `server.js` and `src/routes/*.js`.
|
||||
|
||||
## Frontend Pattern
|
||||
|
||||
- `public/index.html` is the SPA shell.
|
||||
- `public/components/*.html` contains lazy-loaded tab fragments.
|
||||
- `public/js/app.js` handles tab activation and dispatches
|
||||
`CustomEvent('tabChanged', { detail: { tab } })`.
|
||||
- Feature scripts initialize their DOM only when the relevant tab is active.
|
||||
- Shared browser helpers are still exposed through `window.*` where needed.
|
||||
- New isolated frontend work should prefer small ES modules where the existing
|
||||
page load order supports it, but do not rewrite unrelated clinical flows just
|
||||
for style.
|
||||
|
||||
## Data And PHI
|
||||
|
||||
- Sensitive fields use `src/utils/crypto.js` AES-256-GCM helpers.
|
||||
- `user_memories.name` and `user_memories.content` are encrypted for new rows.
|
||||
- `audio_backups.audio_data` is gzipped and encrypted, then deleted after its
|
||||
short expiry window.
|
||||
- `saved_encounters` expire by `site.auto_delete_days`.
|
||||
- Audit details are PHI-redacted before database insert.
|
||||
|
||||
## Operational Boundaries
|
||||
|
||||
- Ped-AI owns the clinical UI, prompts, provider selection, note helpers,
|
||||
patient education, and local user data.
|
||||
- External MCP/Nextcloud services own retrieval/indexing when used by clinical
|
||||
assistant features.
|
||||
- Clinical answer response caching is intentionally avoided; Redis is for
|
||||
operational metadata and prompt suggestions, not answer reuse.
|
||||
|
||||
## High-Risk Areas
|
||||
|
||||
Treat these as small-diff zones unless you are deliberately testing a broader
|
||||
refactor:
|
||||
|
||||
- Auth/session/crypto: `src/middleware/auth.js`, `src/routes/auth.js`,
|
||||
`src/routes/oidc.js`, `src/utils/crypto.js`, `src/utils/sessions.js`.
|
||||
- Recording/STT plumbing: `AudioRecorder` in `public/js/app.js`,
|
||||
`public/js/audioBackup.js`, `public/js/speechRecognition.js`,
|
||||
`src/routes/transcribe.js`.
|
||||
- Encounter persistence: `src/routes/encounters.js` and
|
||||
`public/js/encounters.js`.
|
||||
- Validated calculators/reference data: `public/js/calc-math.js`,
|
||||
`public/js/calculators.js`, `public/data/**`, bedside calculator modules,
|
||||
and tests under `test/`.
|
||||
- ED MDM/finalization prompts and billing-related helpers.
|
||||
|
||||
## Keep Current
|
||||
|
||||
Do not add file-line citations here unless a test locks them down. Prefer
|
||||
describing responsibilities and pointing to file paths. If implementation moves,
|
||||
update this doc in the same commit.
|
||||
62
docs/logic/auth-admin-learning.md
Normal file
62
docs/logic/auth-admin-learning.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Auth, Admin, And Learning Hub Logic
|
||||
|
||||
This doc summarizes the current auth/admin/Learning Hub responsibilities. The
|
||||
source of truth is `server.js`, `src/routes/*.js`, and the focused top-level
|
||||
docs.
|
||||
|
||||
## Auth
|
||||
|
||||
- Local auth uses argon2id for new password hashes and bcrypt fallback/rehash
|
||||
for legacy rows.
|
||||
- Web sessions use the `ped_auth` httpOnly cookie.
|
||||
- Mobile sessions use secure token storage and `Authorization: Bearer`.
|
||||
- `user_sessions` is the authoritative session registry.
|
||||
- OIDC uses Authorization Code + PKCE through `src/routes/oidc.js`.
|
||||
- 2FA uses TOTP plus one-time backup codes.
|
||||
|
||||
See [`../authentication.md`](../authentication.md) for details.
|
||||
|
||||
## Admin Panel
|
||||
|
||||
Admin routes live under `/api/admin` and require admin middleware unless the
|
||||
specific route is explicitly public (for example public config reads used by the
|
||||
login screen). Admin responsibilities include:
|
||||
|
||||
- user management and role changes
|
||||
- settings and feature flags
|
||||
- model allowlist/defaults/custom models
|
||||
- prompt overrides
|
||||
- SMTP/OIDC/security settings
|
||||
- health/log views
|
||||
- milestone management
|
||||
- admin docs browser
|
||||
|
||||
## Learning Hub
|
||||
|
||||
Learning Hub has two surfaces:
|
||||
|
||||
- learner/user-facing routes under `/api/learning`
|
||||
- moderator/admin CMS routes under `/api/admin/learning`
|
||||
|
||||
Content types include articles, pearls, quizzes, and presentations. AI content
|
||||
generation can use topic text, uploaded files, or connected Nextcloud/WebDAV
|
||||
sources. Semantic search uses pgvector embeddings on `learning_content` when an
|
||||
embedding provider is configured.
|
||||
|
||||
See [`../learning-hub.md`](../learning-hub.md) and
|
||||
[`../embeddings-setup.md`](../embeddings-setup.md).
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Never expose raw secrets in admin health/config responses.
|
||||
- Keep OIDC issuer validation and SSRF protections intact.
|
||||
- Keep login, password reset, 2FA, and session endpoints rate-limited.
|
||||
- Treat Learning Hub uploads as untrusted input and keep file-type checks.
|
||||
- Sanitize rendered Learning Hub content.
|
||||
|
||||
## Change Checklist
|
||||
|
||||
1. Check the relevant route and frontend module together.
|
||||
2. Preserve role middleware order.
|
||||
3. Run `node --check` on touched JS files.
|
||||
4. Run `npm test`.
|
||||
71
docs/logic/bedside-and-calculators.md
Normal file
71
docs/logic/bedside-and-calculators.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Bedside Tools And Calculators
|
||||
|
||||
This doc describes the current responsibilities of the bedside/reference area
|
||||
without hardcoded line numbers. The source of truth is the code plus the
|
||||
calculator test suite.
|
||||
|
||||
## Areas
|
||||
|
||||
| Area | Files | Notes |
|
||||
|---|---|---|
|
||||
| Bedside emergencies | `public/js/bedside/*` | ES-module pocket for emergency reference sections. |
|
||||
| Core calculators | `public/js/calc-math.js`, `public/js/calculators.js`, `public/js/drugs-loader.js` | `calc-math.js` keeps a dual browser/CommonJS wrapper so tests can `require()` the same formulas used in browser. |
|
||||
| Drug data | `public/data/drugs.json` | Loaded by `drugs-loader.js`; fallback constants remain in some UI modules for resilience. |
|
||||
| PE Guide | `public/js/peGuide.js`, `src/routes/peGuide.js`, `public/components/pe-guide.html` | Structured PE reference plus AI narrative endpoint. |
|
||||
| Well-visit schedule | `public/js/wellVisit/scheduleData.js`, `public/data/well-visit/schedule.json`, `public/js/wellVisit.js` | Schedule JSON is loaded and applied to legacy globals used by the UI. |
|
||||
| Milestones | `public/js/milestonesData.js`, `public/js/milestones.js`, `src/routes/milestones.js`, `src/routes/adminMilestones.js` | DB-backed milestone data with static fallback. |
|
||||
|
||||
## Calculator Accuracy Rule
|
||||
|
||||
Do not change clinical formulas or reference data without tests. Add or update
|
||||
test vectors first, then change data/code, then run `npm test`.
|
||||
|
||||
Protected examples include:
|
||||
|
||||
- APLS and Best Guess weights.
|
||||
- Maintenance fluids.
|
||||
- Parkland burn fluids and Lund-Browder TBSA.
|
||||
- PRAM, Westley croup, GCS, Apgar, bilirubin, BMI, BP, growth, Fenton, and
|
||||
equipment sizing.
|
||||
- Emergency medication dosing in resuscitation, anaphylaxis, seizure,
|
||||
sedation, agitation, emesis, trauma, and NRP modules.
|
||||
|
||||
## Schedule Data
|
||||
|
||||
Well-visit schedule data now lives in JSON:
|
||||
|
||||
- `public/data/well-visit/schedule.json`
|
||||
- loader: `public/js/wellVisit/scheduleData.js`
|
||||
- consumer: `public/js/wellVisit.js`
|
||||
- server enrichment: `src/routes/wellVisit.js`
|
||||
|
||||
The loader exposes the legacy names expected by the existing UI, including
|
||||
`VISIT_AGES`, `PERIODICITY`, `CATCH_UP_SCHEDULE`, `GROWTH_REFERENCE`, and BMI
|
||||
classification data.
|
||||
|
||||
## Bedside ES Modules
|
||||
|
||||
The bedside tab is intentionally split by emergency/reference topic. Keep new
|
||||
sections small and self-contained. Shared formatting/helpers should stay in the
|
||||
bedside support modules rather than growing `calculators.js` again.
|
||||
|
||||
## PE Guide
|
||||
|
||||
The PE Guide is partly deterministic reference UI and partly AI-assisted
|
||||
narrative generation:
|
||||
|
||||
- Browser code collects assessed systems, selected normals/abnormals, and
|
||||
clinician notes.
|
||||
- `POST /api/generate-pe-narrative` wraps user-derived text with
|
||||
`wrapUserText` and applies `INJECTION_GUARD` before `callAI`.
|
||||
- The route returns a generated narrative plus summary metadata.
|
||||
|
||||
## Extension Checklist
|
||||
|
||||
When adding or changing a bedside/calculator feature:
|
||||
|
||||
1. Add test vectors for any clinical formula or reference boundary.
|
||||
2. Keep UI state local unless persistence is explicitly required.
|
||||
3. Avoid PHI storage in reference/calculator-only areas.
|
||||
4. Prefer small files for new bedside sections.
|
||||
5. Run `node --check` on touched scripts and `npm test` before deploy.
|
||||
65
docs/logic/clinical-notes.md
Normal file
65
docs/logic/clinical-notes.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Clinical Note Workflows
|
||||
|
||||
Clinical note workflows share the same broad lifecycle:
|
||||
|
||||
1. User enters text or records audio.
|
||||
2. Audio, when used, is transcribed by the configured server-side STT provider.
|
||||
3. The frontend gathers demographics, structured form data, and optional user
|
||||
template context.
|
||||
4. The route wraps user-derived text with `wrapUserText` and appends
|
||||
`INJECTION_GUARD` before calling `callAI`.
|
||||
5. The generated note is inserted as safe text/sanitized output.
|
||||
6. Post-note helpers can offer refine/shorten/clarify, billing suggestions,
|
||||
don't-miss review, and parent-facing patient handouts.
|
||||
7. Users can save/load encounter drafts through the shared encounter system.
|
||||
|
||||
## Main Workflows
|
||||
|
||||
| Workflow | Frontend | Route | Notes |
|
||||
|---|---|---|---|
|
||||
| Live Encounter HPI | `public/js/liveEncounter.js` | `POST /api/generate-hpi-encounter` | Recording/transcript to HPI. |
|
||||
| Dictation | `public/js/voiceDictation.js` | `POST /api/generate-hpi-dictation` or `POST /api/generate-soap` | Dictated summary to HPI or SOAP. |
|
||||
| SOAP | `public/js/soap.js` | `POST /api/generate-soap` | Transcript/dictation to SOAP. |
|
||||
| Sick Visit | `public/js/sickVisit.js` | `POST /api/sick-visit/note` | Chief complaint, transcript/dictation, ROS/PE, diagnosis context. |
|
||||
| Well Visit | `public/js/wellVisit.js`, `public/js/shadess.js` | `POST /api/well-visit/note`, `POST /api/well-visit/shadess` | Schedule data, ROS/PE, SSHADESS, milestones, vaccines/screenings. |
|
||||
| Hospital Course | `public/js/hospitalCourse.js` | `POST /api/generate-hospital-course` | Pasted notes/labs to course summary. |
|
||||
| Chart Review | `public/js/chartReview.js` | `POST /api/generate-chart-review` | Pasted chart content to outpatient review. |
|
||||
| ED Encounter | `public/js/ed-encounters.js` | `src/routes/edEncounters.js` | Multi-stage ED workflow; see `ed-encounters.md`. |
|
||||
| Milestones | `public/js/milestones.js` | `POST /api/generate-milestone-narrative`, `POST /api/generate-milestone-summary` | Developmental milestone narratives. |
|
||||
|
||||
## User Templates
|
||||
|
||||
Settings saves templates/preferences in `user_memories`. The frontend calls
|
||||
`getUserMemoryContext()` before generation and passes the result as
|
||||
`physicianMemories`. Server routes wrap that context as low-priority
|
||||
style/template guidance. `custom` memories and legacy `correction_*` rows are
|
||||
not injected into prompts.
|
||||
|
||||
## Encounter Persistence
|
||||
|
||||
Shared save/load behavior lives in `public/js/encounters.js` and
|
||||
`src/routes/encounters.js`.
|
||||
|
||||
- Encounters are scoped by `user_id`.
|
||||
- Rows expire by `site.auto_delete_days`.
|
||||
- `idempotency_key` prevents duplicate creates.
|
||||
- `version` supports optimistic locking when clients send `expected_version`.
|
||||
- Text fields are encrypted at rest for new writes.
|
||||
|
||||
## Patient Education
|
||||
|
||||
`attachPatientEducation` adds a Handout panel beside supported note outputs.
|
||||
`POST /api/patient-education` generates a parent-facing plain-text draft from
|
||||
the edited clinician note plus optional diagnosis, medication, age, language,
|
||||
and reading-level context. The clinician remains responsible for review before
|
||||
sharing.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Do not insert generated clinical output with raw `innerHTML` unless it is
|
||||
intentionally sanitized.
|
||||
- Do not add browser-native `prompt`, `alert`, or `confirm` workflows.
|
||||
- Do not add inline DOM event handlers.
|
||||
- Do not reintroduce browser Whisper/browser-local model downloads.
|
||||
- Do not cache clinical answer text in Redis.
|
||||
- Keep source transcript/context available for refine actions where relevant.
|
||||
45
docs/logic/ed-encounters.md
Normal file
45
docs/logic/ed-encounters.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# ED Encounters
|
||||
|
||||
The ED encounter workflow is a multi-stage clinical documentation flow for
|
||||
emergency visits.
|
||||
|
||||
## Shape
|
||||
|
||||
- Frontend: `public/js/ed-encounters.js`.
|
||||
- Backend: `src/routes/edEncounters.js`.
|
||||
- Prompts: ED-specific entries in `src/utils/prompts.js`.
|
||||
- Helpers: billing suggestions and don't-miss review can run after generated
|
||||
ED output.
|
||||
|
||||
## Typical Flow
|
||||
|
||||
1. Capture initial ED context and generate an initial note/stage output.
|
||||
2. Add interval updates as the encounter evolves.
|
||||
3. Consolidate relevant stages into the final ED note.
|
||||
4. Generate MDM/final documentation using the ED finalize prompt.
|
||||
5. Optionally run billing and don't-miss helpers.
|
||||
6. Save or reload the encounter through the shared encounter system.
|
||||
|
||||
## Design Constraints
|
||||
|
||||
- Later stages should not silently overwrite earlier clinical text.
|
||||
- Regeneration should make it clear which stage is being updated.
|
||||
- MDM/finalization prompt changes should be conservative and coding-aware.
|
||||
- Don't-miss output is clinician-facing safety support, not a replacement for
|
||||
clinical judgment.
|
||||
|
||||
## User Templates
|
||||
|
||||
Templates saved under ED-relevant categories can be included through
|
||||
`/api/memories/context` and passed as `physicianMemories`. Legacy
|
||||
`correction_*` rows are filtered out.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
When changing ED behavior:
|
||||
|
||||
1. Run syntax checks for `public/js/ed-encounters.js` and
|
||||
`src/routes/edEncounters.js`.
|
||||
2. Run `npm test`.
|
||||
3. Manually test stage generation, finalization, save/load, and helper panels
|
||||
in an authenticated session when possible.
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
# Mobile build & release
|
||||
# Mobile Build And Release
|
||||
|
||||
Capacitor 6 wrapper. Android only today; iOS project exists but requires macOS
|
||||
+ Xcode to produce an `.ipa`.
|
||||
Capacitor 6 wrapper around the hosted Ped-AI web app. The launcher defaults to `https://app.pedshub.com`, lets the user change the server URL, and stores that URL locally. Android is buildable on Linux. The iOS project exists but requires macOS and Xcode to produce an `.ipa`.
|
||||
|
||||
This is not a separate native clinical app. The native shell provides WebView hosting, microphone permission plumbing, secure storage, and mobile packaging for the same authenticated web app.
|
||||
|
||||
## One-time setup
|
||||
|
||||
|
|
@ -25,8 +26,12 @@ npx cap open android
|
|||
|
||||
## CI build (preferred)
|
||||
|
||||
Tag-triggered. Push any `vX.Y.Z` tag → `.github/workflows/android-release.yml`
|
||||
builds a signed APK on a GitHub runner and attaches it to the matching release.
|
||||
Push-triggered. Any push to `main`/feature branches and any `vX.Y.Z` tag push
|
||||
→ `.forgejo/workflows/android-apk.yml` builds a signed APK on the Forgejo
|
||||
runner.
|
||||
|
||||
Tagged builds additionally publish the artifact to the matching Forgejo release
|
||||
as `pedscribe-<tag>.apk` so Obtainium can track updates.
|
||||
|
||||
Required repo secrets (set once, via Settings → Secrets and variables → Actions
|
||||
or `gh secret set`):
|
||||
|
|
@ -35,6 +40,14 @@ or `gh secret set`):
|
|||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS` — `pedscribe`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` — base64 of your Google Play service
|
||||
account JSON (optional). If present, the same tag build also runs `bundleRelease`
|
||||
and uploads the AAB to Play's `internal` track.
|
||||
|
||||
Optional Play Store flow:
|
||||
- Service account must have permissions to edit releases on the app in Play.
|
||||
- Build task is `bundleRelease`, tracked as `com.pedshub.scribe`.
|
||||
- Upload lane is `fastlane/android publish_internal` (under `mobile/android/fastlane`).
|
||||
|
||||
Tag a release:
|
||||
|
||||
|
|
@ -44,12 +57,13 @@ git commit -m "feat: ..." && git push # auto-version workflow bumps minor
|
|||
git commit -m "fix: ..." && git push # auto-version workflow bumps patch
|
||||
|
||||
# or force an exact version
|
||||
scripts/release.sh 6.2.0 --push
|
||||
scripts/release.sh X.Y.Z --push
|
||||
```
|
||||
|
||||
APK lands at the GitHub release; `/releases/latest` link in the login page
|
||||
resolves to it automatically. Obtanium subscribers (`github.com/<owner>/<repo>`)
|
||||
pick up the update on next poll.
|
||||
APK lands on the Forgejo release. Obtainium can still track
|
||||
`git.danvics.com/danvics/pediatric-ai-scribe-v3` releases automatically.
|
||||
Play Store upload is handled automatically for tagged builds only when
|
||||
`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` is configured.
|
||||
|
||||
## Local build (fallback / debugging)
|
||||
|
||||
|
|
@ -69,6 +83,8 @@ Output: `android/app/build/outputs/apk/release/app-release.apk`
|
|||
For Play Store, swap `assembleRelease` → `bundleRelease`; output: `.aab` under
|
||||
`bundle/release/`.
|
||||
|
||||
If web assets or Capacitor config changed, run `npx cap sync android` from `mobile/` before building.
|
||||
|
||||
### Single-quote the password
|
||||
|
||||
Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must
|
||||
|
|
@ -109,8 +125,9 @@ user to uninstall + reinstall.
|
|||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `mobile/capacitor.config.json` | appId, name, WebView config, plugin opts |
|
||||
| `mobile/src/` | launcher HTML (server URL entry) |
|
||||
| `mobile/src/` | launcher HTML and server URL entry, defaulting to `https://app.pedshub.com` |
|
||||
| `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission |
|
||||
| `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording |
|
||||
| `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules |
|
||||
| `.github/workflows/android-release.yml` | CI build |
|
||||
| `.forgejo/workflows/android-apk.yml` | CI build |
|
||||
| `mobile/android/fastlane/Fastfile` | internal Play track upload lane |
|
||||
|
|
|
|||
|
|
@ -1,83 +1,38 @@
|
|||
# Speech: STT, TTS, audio backup
|
||||
# Speech: STT, TTS, Audio Backup
|
||||
|
||||
## Transcription (speech-to-text)
|
||||
## Transcription
|
||||
|
||||
### Overview
|
||||
`POST /api/transcribe` accepts `multipart/form-data` with one audio file up to 25 MB. Server STT is routed through LiteLLM.
|
||||
|
||||
`POST /api/transcribe` accepts `multipart/form-data` with a single audio
|
||||
file (≤ 25 MB). Provider is `TRANSCRIBE_PROVIDER` env var, or auto-detected
|
||||
(`google > aws > openai`) from available credentials. Each user may override
|
||||
via `users.stt_model`; admin-wide default via `stt.model` in `app_settings`.
|
||||
Set `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_API_BASE`, and `LITELLM_STT_MODEL`. Auto mode also uses LiteLLM when the gateway is configured.
|
||||
|
||||
### Providers
|
||||
|
||||
| Provider | Transport | HIPAA (with BAA) |
|
||||
| Provider | Notes | HIPAA posture |
|
||||
|---|---|---|
|
||||
| **Google Gemini** | Inline audio in `generateContent` call. Default model `gemini-2.0-flash`. | Yes |
|
||||
| **Amazon Transcribe** | Streaming. `AWS_TRANSCRIBE_MEDICAL=true` + `AWS_TRANSCRIBE_SPECIALTY` switches to Transcribe Medical. Specialties: `PRIMARYCARE`, `CARDIOLOGY`, `NEUROLOGY`, `ONCOLOGY`, `RADIOLOGY`, `UROLOGY`. | Yes |
|
||||
| **Local Whisper** | Spawns `whisper.cpp` or `faster-whisper` via `WHISPER_BINARY`. Fully offline. Model sizes `tiny`/`base`/`small`/`medium`/`large`. | N/A (nothing leaves host) |
|
||||
| **OpenAI Whisper** | `whisper-1` via `/v1/audio/transcriptions`. Medical-context prompt prepended: `"Medical patient encounter. Pediatric."` | No |
|
||||
| **LiteLLM** | Inline audio via LiteLLM's `chat.completions` endpoint (not the `/audio/transcriptions` path). Model from `LITELLM_STT_MODEL`. | Depends on LiteLLM backend |
|
||||
| LiteLLM | Sends audio through the configured LiteLLM `/audio/transcriptions` backend. | Depends on the selected upstream. |
|
||||
|
||||
## Browser Whisper (fully offline)
|
||||
Browser Whisper and browser-local Whisper workers are not part of the runtime. Do not add browser model downloads or Transformers.js STT back into the public app.
|
||||
|
||||
Runs entirely in the browser via WebAssembly. Zero network. Suitable when
|
||||
no external transcription is acceptable.
|
||||
## Web Speech Preview
|
||||
|
||||
- Runtime: `@xenova/transformers` (WASM).
|
||||
- Models (bundled in the Docker image, no CDN fetch):
|
||||
- `whisper-tiny.en` — 39 MB
|
||||
- `whisper-base.en` — 74 MB
|
||||
- `whisper-small.en` — 244 MB
|
||||
- Executes in a dedicated Web Worker; UI thread is never blocked.
|
||||
- Models cached in IndexedDB after first load.
|
||||
- Per-user toggle. On browser transcription failure, the client falls back to
|
||||
server-side transcription without user intervention.
|
||||
Browser-native Web Speech can show interim text when the user explicitly enables it. It is browser/vendor dependent, may send audio to browser-provider cloud services, and should not be treated as the final clinical transcript.
|
||||
|
||||
## Live speech preview
|
||||
## Text To Speech
|
||||
|
||||
Chrome / Edge `webkitSpeechRecognition` streams interim text to the UI during
|
||||
recording. Used for real-time preview only — **not** for final transcription.
|
||||
The actual transcript comes from the configured STT provider after recording
|
||||
ends.
|
||||
|
||||
## Text-to-speech
|
||||
|
||||
### Overview
|
||||
|
||||
`POST /api/text-to-speech`. Returns `audio/mpeg`. `X-TTS-Provider` response
|
||||
header identifies the provider used. 5000-character limit per request. Each
|
||||
user may override via `users.tts_voice`; admin-wide default via `tts.voice`.
|
||||
|
||||
### Providers
|
||||
`POST /api/text-to-speech` returns audio from LiteLLM `/audio/speech`. The `X-TTS-Provider` response header identifies the LiteLLM model used. Requests are limited to 5000 characters.
|
||||
|
||||
| Provider | Notes |
|
||||
|---|---|
|
||||
| **Google Cloud TTS** | `@google-cloud/text-to-speech`. Voice families: Journey, Studio, Neural2. |
|
||||
| **LiteLLM** | Configured via `LITELLM_TTS_MODEL` + `LITELLM_TTS_VOICE`. Backend-agnostic. |
|
||||
| **ElevenLabs** | `eleven_turbo_v2_5`. **Not HIPAA-compliant**. |
|
||||
| LiteLLM | Uses `LITELLM_TTS_MODEL` and `LITELLM_TTS_VOICE`. |
|
||||
|
||||
## Audio backup
|
||||
The admin/user voice pickers read available LiteLLM-compatible voices from `LITELLM_TTS_VOICES`.
|
||||
|
||||
Raw audio is saved to Postgres **only when transcription fails**, providing a
|
||||
retry window without persisting every recording.
|
||||
## Audio Backup
|
||||
|
||||
### Storage
|
||||
Failed transcription submissions can be stored for retry instead of being silently lost.
|
||||
|
||||
- Gzip-compressed, then AES-256-GCM encrypted (0x01 version byte prefix).
|
||||
- `BYTEA` column in `audio_backups`.
|
||||
- 24-hour `expires_at`, swept hourly.
|
||||
- Legacy rows (gzip magic `0x1F` as first byte, no encryption envelope)
|
||||
decompress as-is — detection is deterministic because `0x1F ≠ 0x01`.
|
||||
- Audio backups are compressed and encrypted before storage.
|
||||
- Backups expire automatically.
|
||||
- The Settings audio backup UI can retry or delete saved items.
|
||||
- Browser fallback storage is used only when the server cannot save the failed audio.
|
||||
|
||||
### Retry UI
|
||||
|
||||
Settings → Audio Backups:
|
||||
- List: module, size, created, expiry.
|
||||
- **Retry** — resubmits to `POST /api/transcribe`.
|
||||
- **Delete** — purge now.
|
||||
|
||||
### Browser fallback
|
||||
|
||||
If the server-side save fails (network, 500, etc.), the client stores the audio
|
||||
in IndexedDB so it can retry later. Cleared after successful submission.
|
||||
Treat audio backups as sensitive clinical data even when encrypted.
|
||||
|
|
|
|||
40
docs/transcription-options.md
Normal file
40
docs/transcription-options.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Transcription Options
|
||||
|
||||
Ped-AI currently supports server-side transcription through LiteLLM plus an explicit browser Web Speech preview option. Browser Whisper was removed and should not be offered in settings, documentation, public workers, or model download scripts.
|
||||
|
||||
## Recommended Clinical Setup
|
||||
|
||||
Route STT through LiteLLM and configure the compliant upstream in LiteLLM.
|
||||
|
||||
| Need | Recommended provider |
|
||||
|---|---|
|
||||
| Server STT | LiteLLM with a compliant upstream. |
|
||||
| Real-time draft preview | Browser Web Speech only with explicit user opt-in and privacy warning. |
|
||||
|
||||
Auto-detect uses LiteLLM when `LITELLM_API_BASE` is configured. Direct Google, AWS, local Whisper, and OpenAI Whisper branches are not part of the app runtime.
|
||||
|
||||
## Configuration
|
||||
|
||||
```env
|
||||
TRANSCRIBE_PROVIDER=litellm
|
||||
LITELLM_API_BASE=https://your-litellm.example/v1
|
||||
LITELLM_API_KEY=<key>
|
||||
LITELLM_STT_MODEL=local-parakeet-v3
|
||||
```
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Server transcription failures can create encrypted audio backups for retry.
|
||||
- Users can retry or delete failed backups from Settings.
|
||||
- Web Speech interim text is not a substitute for a server transcription response.
|
||||
|
||||
## Removed Paths
|
||||
|
||||
These should remain absent unless the project intentionally reintroduces browser-local STT with a new design review:
|
||||
|
||||
- `public/js/browserWhisper.js`
|
||||
- `public/js/whisperWorker.js`
|
||||
- `public/js/whisperWorkerV2.js`
|
||||
- `public/models/Xenova/*`
|
||||
- Browser Whisper setup/troubleshooting docs
|
||||
- Whisper model download scripts for public browser models
|
||||
|
|
@ -27,7 +27,6 @@ const USE_REAL_AI = process.env.E2E_USE_REAL_AI === '1' || process.env.E2E_USE_R
|
|||
// message matches one of these patterns it does NOT fail the test.
|
||||
const CONSOLE_ERROR_ALLOWLIST = [
|
||||
/favicon/i,
|
||||
/Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand
|
||||
/\/api\/models/i, // When no AI provider configured yet
|
||||
/Cross-Origin-Opener-Policy/i, // Chrome warning on non-HTTPS e2e server
|
||||
/Failed to load resource.*(400|401|403|404|500|502|503)/i, // Any HTTP error on subsidiary fetches — smoke tests only verify UI renders, deeper integration tests validate endpoint contracts separately
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
|||
// ============================================================
|
||||
// ADMIN (React port) — access-gate smoke test.
|
||||
//
|
||||
// The e2e test user is a non-admin by default, so the expected
|
||||
// outcome here is the access-denied panel. A separate admin-enabled
|
||||
// fixture can exercise the elevated view when the per-section ports
|
||||
// land.
|
||||
// ============================================================
|
||||
|
||||
const { test, expect, E2E_BASE } = require('../fixtures');
|
||||
|
||||
test.describe('React Admin — access gate', () => {
|
||||
|
||||
test('non-admin users see the access-denied card', async ({ authedPage: _, page }) => {
|
||||
await page.goto(E2E_BASE + '/app/admin');
|
||||
// Either the admin shell (role=admin) OR the access-denied card
|
||||
// (everyone else). The seeded e2e user is non-admin, so we expect
|
||||
// the denial — but if the seed ever flips, the test still passes.
|
||||
await page.waitForSelector(
|
||||
'[data-testid="admin-access-denied"], [data-testid="admin-shell"]',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const denied = await page.locator('[data-testid="admin-access-denied"]').count();
|
||||
const shell = await page.locator('[data-testid="admin-shell"]').count();
|
||||
expect(denied + shell).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,10 +21,7 @@ test.describe('Unauthenticated auth screen', () => {
|
|||
});
|
||||
|
||||
test('register link is present but currently disabled (display:none)', async ({ page }) => {
|
||||
// Daniel's instance has invite-only registration — the "Create account"
|
||||
// link is explicitly hidden via inline style, so the HTML is there but
|
||||
// users can't reach the register form through the UI. Verify the hidden
|
||||
// state so flipping the style to re-enable it fails loudly.
|
||||
// Invite-only registration hides the link while keeping the form in the DOM.
|
||||
await page.goto(E2E_BASE + '/');
|
||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
||||
const display = await page.locator('#show-register').evaluate(el => el.style.display);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue