Safety net for upcoming refactors:
- 26 Playwright smoke tests via @playwright/test 1.50.0 in an official
Playwright container (no host Node needed). Covers every Bedside sub-pill,
the age→weight estimator, dose calculators (seizure/sepsis/anaphylaxis/
burns/airway), and interactive widgets (lightbox, vent SVG).
- `npm run e2e` wrapper runs tests inside mcr.microsoft.com/playwright:v1.50.0-noble
on the ped-ai_default Docker network so no host port mapping is needed.
- public/e2e-harness.html + public/js/e2e-bootstrap.js load the calculators
component without the SPA auth wall (scripts external to satisfy CSP).
Server:
- server.js now re-reads public/index.html on mtime change instead of
caching at boot. Fixes the "edit HTML, restart container" friction.
- CSP upgradeInsecureRequests disabled in helmet config; Caddy still
enforces HTTPS at the reverse-proxy layer in production.
Calculators:
- Bedside tab consolidating emergency protocols (Neonatal+Apgar+NRP, Airway/RSI,
Cardiac Arrest/PALS, Respiratory, O2 & Ventilation, Status Epilepticus,
Sepsis & Fever with PECARN/Aronson/Rochester/Step-by-Step, Anaphylaxis,
Procedural Sedation, Agitation, Antiemetics, Antimicrobials, Burns with
Lund-Browder body-parts TBSA + Parkland, Toxicology, Trauma).
- Global age→weight estimator at top of Calculators tab (APLS + Best Guess).
- Pressure-time waveform SVG teaching graphic for Ventilation.
- Algorithm image lightbox (fullscreen, Esc/tap-to-close).
- Every weight-based dose shows mg/kg inline for clinician verification.
- Drug tables wrapped in overflow-x:auto for mobile.
Infrastructure:
- Pure dose math extracted to public/js/calc-math.js (dual-export Node+browser).
- 36 unit tests in test/calc-math.test.js via node:test (zero new deps).
- "npm test" added to package.json.
- Add logger.audit/access calls to auth route (login, login_failed,
login_blocked, register, password_changed, 2fa_backup_code_used,
2fa_backup_codes_regenerated) — these previously only wrote to DB
via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
(Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
- Add neonatal assessment calculator: GA classification (extremely preterm through
post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
- Drop first/second-person voice; reference-style prose throughout
- Remove stale information; align with current code (argon2id primary, hybrid cookie/Bearer auth, sliding 24h idle, AES-256-GCM PHI at rest, backup codes, node-pg-migrate, collation-drift guard, multi-arch Docker, auto-version pipeline)
- Preserve all technical accuracy and code examples
- Remove any remaining references to separate PedsHub Quiz app
- Keep consistent tone across files (tables + code blocks, imperatives where needed)
- api-reference.md and developer-guide.md route tables expanded to reflect current routes (billing, sessions)
docker-publish.yml:
- Rewrote as matrix build + manifest merge.
- amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm (free for public
repos). No QEMU — argon2 and every other native dep compile on
their target CPU, no more SIGILL / exit 132.
- Per-arch GHA cache scopes so builds don't thrash each other.
- Final step merges both digests under one tag (vX.Y.Z + latest),
publishing a real multi-arch manifest. `docker pull` from either
arch gets the right variant automatically.
auto-version.yml, version-bump.yml:
- Checkout now uses `secrets.RELEASE_PAT || secrets.GITHUB_TOKEN`.
With RELEASE_PAT set, the tag push this workflow does DOES
trigger downstream (android-release, docker-publish). Without
it, falls back to GITHUB_TOKEN (no downstream trigger, what we
have today).
All workflows (auto-version, version-bump, android-release,
docker-publish):
- Added FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' so actions
still on Node 20 runtime (checkout/cache/setup-*) opt in to
Node 24 early. GitHub makes Node 24 default 2026-06-02 and
removes Node 20 2026-09-16.
To finish the chain (one-time user step): create a fine-grained
PAT with "Contents: Read and write" on this repo and add as
RELEASE_PAT secret. After that `feat:` / `fix:` commits auto-tag
AND auto-build with zero manual intervention.
mobile-build.md:
- Removed "PedsHub Quiz" sections. That app lives in a separate
repo (quiz/mobile/) and has its own build pipeline. Docs here
are PedScribe-only now.
- Reorganized around CI as the primary flow, local build as
fallback. Added explicit secret names, JDK requirement, single-
quote-password caveat, QEMU/argon2 note.
- File-map section at the end so the native sources are
discoverable without grepping.
CONTRIBUTING.md:
- Cut the narrative prose. Dev-facing tables + single-line
commands only. Decision-tree removed (the table suffices).
- Release pipeline and mobile build link out rather than
duplicating content.
Drops a cheat sheet (CONTRIBUTING.md) in repo root so anyone — you,
future maintainers — has the full commit-prefix table one glance
away. Covers which prefixes trigger a release and which don't.
Also adds .gitmessage that you can optionally wire into git as the
default commit template:
git config --local commit.template .gitmessage
Opens the cheat sheet in your editor every time you `git commit`
without -m. Remove it with `git config --local --unset commit.template`.
This commit uses `docs:` prefix so it does NOT trigger a release —
proving the auto-version workflow's filter works.
Adds .github/workflows/auto-version.yml that fires on every push to
main, parses commit messages since the last semver tag, and decides
whether to cut a new release:
feat: → minor bump (new feature, backward-compatible)
fix: → patch bump (bug fix)
feat!: → major bump (breaking change)
BREAKING CHANGE in body → major bump
docs/chore/refactor/style/test/ci → no release
If any commit since the last tag matches feat/fix/BREAKING, the
workflow bumps versions across package.json, mobile/package.json,
mobile/android/app/build.gradle, commits the change as
"Release vX.Y.Z", tags it, and pushes. The tag push then fires the
existing android-release and docker-publish workflows.
You no longer need to remember "what version am I on?" — just commit
with a conventional-commits prefix and push. Docs-only or refactor
commits don't create releases. Add [skip ci] to any commit message
to skip this workflow for that commit.
docker-publish.yml:
- Dropped linux/arm64 from the platforms matrix. The amd64 GitHub-
hosted runner builds arm64 under QEMU emulation, which fails at
native argon2 compile with SIGILL (exit 132). Your production
box is x86, so arm64 isn't needed. Add it back with a native
ARM runner the day you deploy to ARM hardware.
version-bump.yml (new):
- Manual Actions trigger. Click "Run workflow" → pick patch / minor /
major (or type a custom X.Y.Z). The workflow computes the next
semver from the current package.json version, updates all three
version sites (package.json, mobile/package.json, Android
versionName + versionCode), commits "Release vX.Y.Z", tags it,
and pushes. The tag push then fires android-release.yml and
docker-publish.yml automatically — APK + Docker image published
with no local commands required.
Typical flow now:
Actions → "Version bump & release" → Run workflow → patch
↓
Bump + tag in ~5 s
↓
Parallel: android APK build (~2 m), docker image push (~4 m)
↓
Both assets show up on the new release; Obtanium + docker-hub
subscribers see the update automatically.
The mobile/ wrapper had 1700+ node_modules files tracked, plus the
Capacitor-regenerated artifacts that get rewritten on every
`npx cap sync android` (capacitor.build.gradle, capacitor.config.json,
capacitor.plugins.json, capacitor.settings.gradle, the cordova-android-
plugins subtree). Every local dev or CI sync caused noisy drift that
blocked scripts/release.sh from running.
Added mobile/.gitignore covering node_modules, cap-sync outputs,
Android build outputs, .jks/.apk/.aab files, and .DS_Store.
Kept package-lock.json tracked for reproducible npm install.
No logic changes — only stopped tracking files that are always
regenerated.
On every v*.*.* tag push the workflow:
1. Checks out the repo
2. Sets up JDK 17 + Node 20 + Android SDK (cached between runs)
3. Runs npm install + npx cap sync android in mobile/
4. Restores the signing keystore from ANDROID_KEYSTORE_BASE64 secret
5. Builds a signed release APK via gradle
6. Renames to pedscribe-X.Y.Z.apk
7. Creates/updates the matching GitHub release with the APK attached
and make_latest=true so the /releases/latest URL always points to
the newest build (Obtanium and the login-page link pick it up
automatically)
Required repo secrets (set via gh secret set ... or the GitHub UI):
ANDROID_KEYSTORE_BASE64 base64 -w0 of the .jks file
ANDROID_KEYSTORE_PASSWORD keystore password
ANDROID_KEY_ALIAS key alias (pedscribe)
ANDROID_KEY_PASSWORD key password (same as keystore in our setup)
Typical release flow after this lands:
scripts/release.sh 6.1.1 --push (laptop, 5 sec)
── Actions builds APK in ~8-10 min ──
── Release updates automatically with signed APK ──
── Obtanium clients notice on next poll ──
The "Download Android app (APK)" link on the login page is pointless
when the user is already inside the Capacitor app. Wrapped the link
in id="apk-download-link" and added a native-app-only hide pass in
auth.js that runs against a short array of web-only element IDs.
Add more entries to that array as other web-only UI appears, so the
mobile wrapper can diverge cleanly from the web UI without branching
the HTML.
Replaces my best-effort image readings with the pre-digitized
JavaScript data arrays extracted from codingace.net's open
Bhutani calculator (their arrays were embedded in the page
source, apparently digitized from the original Figure 2 at
6-hour granularity through 72 h).
Cross-checked against the AAP 2004 CPG reproduction of the same
Bhutani chart (Southern Health Manitoba clinical policy PDF).
Classifications at several spot-check points (24/36/41/72 h at
varying TSB) match expected zones.
User's reference case (41 h of life, TSB 9.7 mg/dL):
p95 ≈ 13.6, p75 ≈ 11.4, p40 ≈ 9.1
→ Low-Intermediate Zone ✓ (matches clinician expectation)
Data source now properly cited in both the in-code block comment
and the on-card footer text. Tool still documents that AAP 2022
is the correct tab for phototherapy decisions.
The previous bhutaniZones table was introduced in the initial
calculator commit (61cf096, 2026-04-09) without any source citation
and was systematically ~0.5-1 mg/dL below the published Bhutani 1999
curves — borderline patients got pushed into the next-higher zone.
New values read directly from the Stanford Medicine Newborn Nursery
reproduction of Bhutani 1999 Figure 2:
https://med.stanford.edu/newborns/professional-education/jaundice-and-phototherapy/bhutani-nomogram.html
Uncertainty: ±0.3 mg/dL (values eyeballed from a 556 px rendered
graph, not a published table). This is called out explicitly in
both the in-code comment and the Bhutani tab footer, which also
points clinicians to the AAP 2022 tab for actual phototherapy
decisions.
Spot-check: 41h of life, TSB 9.7 mg/dL
Before: p75=9.33 → High-Intermediate Zone (wrong)
After: p75=10.1 → 9.7 below p75, above p40 → Low-Intermediate ✓
No interpolation or decision logic changed — only the lookup data
and its citation.
Vitals — updated source attribution to "Harriet Lane — Johns Hopkins
Children's Center Kids Kard" (was just Kids Kard). Both the card
subtitle and the intro line.
Bhutani — reverted my incorrect "Harriet Lane" citation (the values
in our table were never transcribed from Harriet Lane). Restored the
original attribution to the 1999 paper. Data itself is unchanged from
the pre-session state; treat it as unverified pending a clinician-
supplied source.
Previous table was ~0.5-1.0 mg/dL below the published Bhutani 1999
nomogram at every reference point, which pushed borderline patients
into the next-higher zone. Coarse 12-hour granularity made the
interpolation error worse between reference points.
Corrected to the PediTools-vetted values (same source we use for
AAP 2022) with 6-hour granularity. Source: Bhutani VK et al.,
Pediatrics 1999;103(1):6-14.
Example: 41h of life, TSB 9.7 mg/dL
Before: p75=9.33 → TSB above p75 → "High-Intermediate Zone" (wrong)
After: p75=10.25 → TSB below p75, above p40 → "Low-Intermediate Zone"
(matches the published nomogram)
No code-path changes — only the lookup data.
Replaced "Phototherapy" with the clinical abbreviation "Photo Tx"
(fits in the right margin without cut-off). Exchange label stays.
Bumped the layout right-padding from 40 px to 88 px so even the
longest label ("95th (High-Risk)" on the Bhutani chart) prints
fully inside the canvas on narrow viewports.
Pure visual improvements to renderBiliChart. Interpolation, lookup
tables, and threshold math are all untouched.
AAP chart now has three-zone shading matching BiliTool's convention:
- Faint green tint below the phototherapy curve (safe zone)
- Amber band between phototherapy and exchange (treatment zone)
- Unshaded above the dashed crimson exchange line (danger)
Phototherapy line: solid orange 2.4 px; Exchange: dashed crimson.
renderBiliChart common improvements:
- Right-edge label on each threshold line ("Phototherapy", "Exchange",
or "95th (High-Risk)" for Bhutani) with white halo — identifiable
without a legend, like PediTools
- Legend removed (replaced by the inline labels)
- X-axis auto-ranges to fit the actual data with tick every 12 h
- Y-axis tick every 5 mg/dL for clean BiliTool-style gridlines
- 40 px right padding so labels don't clip
- Patient dot shrunk from r=8 to r=5 with a 1.8 px white ring,
redrawn on top of every label + its TSB value printed next to it
- Bhutani chart inherits all the same improvements without changing
its own zone/fill setup
Reduces label crowding at the chart extremes. Clinical meaning
preserved: 3rd and 97th remain as the abnormal-threshold dashed
outermost lines, 10/25/50/75/90 give the mid trend. The bidirectional
label spread, leader lines, and dot-on-top rendering from the prior
pass all still apply to the 7-line layout.
Fills updated for new indexes (3↔97, 10↔90, 25↔75).
Labels at the top (97/95/90) crowd just as much as the bottom
trio — prior forward-only pass only spread the bottom. Now:
- Forward pass pushes items DOWN when crowded from above
- Backward pass pulls items UP when still crowded from below
- Min gap bumped 13 → 15 px for breathing room
- Labels clamped to stay inside chart top/bottom
- Thin leader line drawn from native curve position to the label
when the two diverge by more than 2 px, so you can still see
which line a nudged label belongs to
- Patient dot redrawn on top of all labels at the very end of
the plugin so a top-region label can never cover the dot
(Chart.js's afterDatasetsDraw fires after dataset rendering,
so the native dot was being painted over).
Label plugin rewritten:
- Collect all percentile-line labels with their native screen Y
- Sort top-to-bottom (97th ... 3rd) so order is always correct
- Walk the sorted list and enforce 13px min vertical gap by
pushing down (never reordering)
- Second-pass pull-back if the stack would clip off the chart
bottom
Prior logic could nudge "10th" past "3rd" because it moved labels
independently without respecting their natural screen order.
Patient dot: radius 8 → 5 (hover 11 → 7). The 8px ring was
dominating the chart; 5px is still clearly visible with the 1.8px
white border but no longer obscures adjacent curves.
Reverted from the 7-line reduced set back to the full clinical set:
3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th — matching what
Epic and the printed AAP/CDC/WHO charts display.
Kept the mobile-readability improvements from the prior pass:
- Each percentile is a distinct hue (red / deep-orange / orange /
amber / green / blue / violet / purple / pink)
- Outer 3rd + 97th are dashed (abnormal-threshold convention); 5th
and 95th use a tighter dash; rest solid
- 50th remains bold green to anchor the center
- Label plugin's white halo + vertical nudge keeps 3/5/10 and
90/95/97 label stacks legible
- Patient dot still on top, white-ringed
Fill bands updated for 9 indexes (3↔97, 5↔95, 10↔90, 25↔75).
Reduced reference curves from 9 to 7 (dropped 5th and 95th — they
crowd the 3rd/10th and 90th/97th labels on small screens without
adding clinical value; 3rd and 97th are the US/WHO standard
abnormal thresholds).
Each percentile now gets a distinct hue:
3rd red 50th green (bold) 90th violet
10th orange 75th blue 97th pink
25th amber (3rd/97th dashed)
Label plugin improvements:
- Bolder 11px font (was 10px)
- White halo stroke behind text so labels stay legible when the
line they sit on is also colored
- Vertical nudge when two labels would overlap — keeps adjacent
percentiles readable on mobile aspect ratios
- Solid fill color (strips alpha from borderColor)
Patient dot:
- Moved to order: -1 (drawn on top of everything, including labels
and fill bands)
- White 2.5px border ring so it's visible even when it lands
exactly on a colored curve
- Slightly larger hover radius (11 → was 10)
Adds a Chart.js plugin that draws the percentile label at the right
end of each reference line, matching the convention on printed
WHO/CDC growth charts. Lets clinicians identify lines at a glance
without using the legend. Canvas gets 30px right-padding so the
labels don't get clipped.
1. Build-ID cache busting (server.js):
- Compute a BUILD_ID at boot: git HEAD short hash if available,
else /app/BUILD_ID file, else random-on-boot.
- On first request for /, rewrite every local /js/*.js and
/css/*.css reference in index.html to include ?v=BUILD_ID.
Cached once at startup so subsequent renders are free.
- X-Build-Id response header + GET /api/build expose it for
debugging.
- Eliminates the "works after hard-refresh" class of bugs: every
deploy gets a new build ID, so browsers fetch fresh JS/CSS on
the very next page load.
2. Optimistic encounter locking wired into the client
(public/js/encounters.js):
- On resumeEncounter(): stash enc.version into
window._encounterVersions[id]
- On saveEncounter(): send expected_version in the POST body
when we have one.
- Server returns 409 if another tab/device wrote first → user
sees "Someone else edited this encounter. Reload to see the
latest version." instead of silently clobbering the prior save.
- On success, remember the new server-assigned version for the
next save.
Previous check was strict (canLocalAuth !== true → hide). On a
transient /me hiccup or when the boot cache lagged, a legit local
user saw empty Settings with none of the sections they should see.
Inverted the predicate: hide only when canLocalAuth === false
(explicit SSO-only signal from the server). Undefined/missing now
defaults to show — local-auth users never lose their own UI.
Still hides correctly for the documented SSO-only case because the
/me endpoint sets the flag to false explicitly for those users.
Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
Two findings from review:
1. callAI() previously accepted any model string from the client.
POST /api/hpi with { model: "openai/o1" } would call the reasoning
model regardless of whether the operator enabled it. Added
getAllowedModelIds() in src/utils/models.js (60s TTL DB-backed
cache) and a guard at the top of callAI() that rejects with
"model_not_permitted" when the requested ID isn't in the active
roster. No model supplied → silent fallback to DEFAULT_MODEL.
2. Middleware was updating user_sessions.last_activity on every
request, including GETs. Client-side polling (/api/auth/me
heartbeats, dashboard refreshes, log tail calls) kept sessions
alive indefinitely, defeating the 24h sliding idle policy. Now
only POST/PUT/DELETE/PATCH count as "user activity". GETs are
read-only and often automated — they no longer extend the
session. Idle enforcement still runs on every method, so a
24h-idle user still gets kicked on their next GET.
The server-side revoke was always working — it deletes user_sessions
rows, and middleware correctly returned 401 on the revoked device's
next /api/* request. The bug was entirely client-side: individual
fetch handlers swallowed the 401 (rendering "no sessions found" or
empty data) and nothing redirected to the login screen. So the
revoked device looked like it stayed signed in.
Added public/js/authFetch.js: a global fetch interceptor that
watches every /api/* response. On 401 from a non-auth endpoint
(i.e. not /login, /register, /logout, /me, etc.), it clears any
cached token/user state and reloads the page. The reload's boot
flow lands on /api/auth/me → 401 → login screen as usual.
Guarded against false positives: only triggers when the app believes
the user is currently logged in (AUTH_TOKEN set or main-app visible)
so a pre-login 401 doesn't accidentally flash the screen.
Loaded before auth.js in index.html.
Follows the same pattern as Change Password and 2FA sections —
hidden by default in the HTML, revealed only when canLocalAuth=true.
Why: revoke technically deletes the PedScribe session row and clears
the cookie on that device, but the SSO user can re-auth instantly
because their IdP session is still live. Surfacing a "revoke" button
that the IdP will immediately undo is misleading. SSO users now see
only the SSO-relevant sections of Settings.
Infrastructure only — no existing data or tables modified.
src/db/migrate.js — programmatic runner, fires at boot after
the existing idempotent initDatabase()
migrations/1744600000000... — intentionally empty example, documents
the file shape. Registered in the new
pgmigrations tracking table so it won't
rerun.
.node-pg-migraterc.json — CLI config (migrations-dir, utc naming)
docs/migrations.md — workflow + conventions
package.json — migrate:up/down/new/status npm scripts
(status is a direct pgmigrations query
since node-pg-migrate v7 lacks a status
subcommand)
src/utils/sessions.js:
- parseUserAgent now recognizes the Capacitor wrapper (UA suffix
"PedScribe-Android" / "PedScribe-iOS") and labels sessions
"PedScribe (Android)" instead of "Chrome on Android".
Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.