feat(notes): restore audio recording + save/load across all 7 note pages

Recovered from git history (commit be14578) — the vanilla recording UI
was deleted during the "minimum-viable" note ports without being
re-implemented. Every note page now gets the full vanilla behavior
back:

Recording (Encounter, Dictation, SOAP, Sick Visit, Hospital Course):
  • AudioRecorder — MediaRecorder mono/16kHz/EC+NS/opus 32kbps
  • Pause / Resume (native where supported, restart-on-same-stream
    fallback for Safari)
  • Live preview via Web Speech API (opt-in in Settings)
  • On stop → upload to /api/transcribe; fall back to live preview
    if server unavailable or blob > 24 MB
  • Failed uploads → /api/audio-backups (IndexedDB fallback)

Save / Load / New-patient toolbar (all 7 note pages):
  • sessionStorage keys _savedEncId_<type> + _idempKey_<type>
    survive page refresh + sign-out within the same tab
  • Optimistic locking via expected_version (409 → "Someone else
    edited this encounter")
  • Load popover lists saved encounters of matching type only
  • Draft #N chip shows current session-bound row

Well Visit and Chart Review: toolbar only — vanilla had no recorder
on those tabs (paste-based workflows).

New components:
  client/src/components/Recorder.tsx
  client/src/components/EncounterToolbar.tsx

New libraries:
  client/src/lib/recorder.ts            — AudioRecorder class
  client/src/lib/transcribe.ts          — /api/transcribe + audio backup
  client/src/lib/web-speech.ts          — webkit speech preview + dedupe
  client/src/lib/encounter-persistence.ts — save/load/version tracking
This commit is contained in:
Daniel 2026-04-24 03:45:28 +02:00
parent 88dd878492
commit cd1f762f34
13 changed files with 1077 additions and 23 deletions

View file

@ -0,0 +1,159 @@
// ============================================================
// Encounter Toolbar — Save / New Patient / Load + label input.
// Mirrors the vanilla saveFromTab / clearTab flow from
// public/js/encounters.js. Each note page gets one of these so
// transcripts + generated notes persist across sign-outs.
// ============================================================
import { useEffect, useState } from 'react';
import {
saveEncounter, loadEncounter, listSavedEncounters,
getSavedEncId, clearTabState,
type EncType, type SavedEncounterListEntry, type LoadedEncounter,
} from '@/lib/encounter-persistence';
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
interface Props {
type: EncType;
label: string;
setLabel: (s: string) => void;
transcript: string;
generatedNote: string;
partialData?: unknown;
onLoad: (enc: LoadedEncounter) => void;
onClear: () => void;
}
export default function EncounterToolbar({
type, label, setLabel, transcript, generatedNote, partialData, onLoad, onClear,
}: Props) {
const [msg, setMsg] = useState<{ kind: 'ok' | 'err' | 'info'; text: string } | null>(null);
const [saving, setSaving] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [saved, setSaved] = useState<SavedEncounterListEntry[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [search, setSearch] = useState('');
const [savedId, setSavedId] = useState<number | null>(getSavedEncId(type));
// Sync the savedId chip whenever sessionStorage changes (e.g. after Load).
useEffect(() => {
const v = getSavedEncId(type);
setSavedId(v);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transcript, generatedNote]);
async function save() {
if (!label.trim()) { setMsg({ kind: 'err', text: 'Enter a patient label first' }); return; }
setMsg(null); setSaving(true);
try {
const r = await saveEncounter({ type, label, transcript, generatedNote, partialData });
setSavedId(r.id);
setMsg({ kind: 'ok', text: 'Saved (' + label + ')' });
} catch (e) {
setMsg({ kind: 'err', text: (e as Error).message });
} finally { setSaving(false); }
}
function newPatient() {
clearTabState(type);
setSavedId(null);
setLabel('');
onClear();
setMsg({ kind: 'info', text: 'New patient — fields cleared.' });
}
async function openLoad() {
if (popoverOpen) { setPopoverOpen(false); return; }
setPopoverOpen(true);
setLoadingList(true);
try {
const list = await listSavedEncounters();
setSaved(list.filter((e) => e.enc_type === type));
} finally { setLoadingList(false); }
}
async function pick(id: number) {
try {
const enc = await loadEncounter(id);
setSavedId(id);
onLoad(enc);
setPopoverOpen(false);
setMsg({ kind: 'ok', text: 'Loaded ' + (enc.label || '') });
} catch (e) {
setMsg({ kind: 'err', text: (e as Error).message });
}
}
const filtered = search
? saved.filter((e) => (e.label || '').toLowerCase().includes(search.toLowerCase()))
: saved;
return (
<div className="space-y-2" data-testid={'enc-toolbar-' + type}>
<div className="flex flex-wrap items-center gap-2">
<input
className={input + ' flex-1 min-w-[180px] max-w-md'}
placeholder="Patient label (e.g. JD-2026-04-24)"
value={label}
onChange={(e) => setLabel(e.target.value)}
data-testid="enc-label"
/>
<button type="button" onClick={save} disabled={saving} className={btnPrimary} data-testid="enc-save">
💾 {saving ? 'Saving…' : (savedId != null ? 'Save (update)' : 'Save')}
</button>
<div className="relative">
<button type="button" onClick={openLoad} className={btn} data-testid="enc-load-toggle">
📂 Load
</button>
{popoverOpen && (
<div className="absolute z-30 mt-1 right-0 w-80 max-h-96 overflow-auto rounded-md border border-border bg-card shadow-lg">
<div className="sticky top-0 bg-card p-2 border-b border-border">
<input
type="search"
placeholder="Filter saved encounters…"
className={input + ' w-full text-xs'}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
data-testid="enc-load-search"
/>
</div>
{loadingList && <div className="p-3 text-xs text-muted-foreground">Loading</div>}
{!loadingList && filtered.length === 0 && (
<div className="p-3 text-xs text-muted-foreground italic">No saved {type} encounters.</div>
)}
{filtered.map((e) => (
<button
key={e.id}
type="button"
onClick={() => pick(e.id)}
className="w-full text-left px-3 py-2 hover:bg-muted border-b border-border last:border-0"
data-testid={'enc-load-pick-' + e.id}
>
<div className="text-sm font-medium truncate">{e.label}</div>
<div className="text-xs text-muted-foreground">
Updated {new Date(e.updated_at).toLocaleString()} · expires {new Date(e.expires_at).toLocaleDateString()}
</div>
</button>
))}
</div>
)}
</div>
<button type="button" onClick={newPatient} className={btn} data-testid="enc-new">
🆕 New patient
</button>
{savedId != null && (
<span className="text-xs text-muted-foreground">Draft #{savedId}</span>
)}
</div>
{msg && (
<div className={
'text-sm ' +
(msg.kind === 'ok' ? 'text-green-600' : msg.kind === 'err' ? 'text-destructive' : 'text-muted-foreground')
}>{msg.text}</div>
)}
</div>
);
}

View file

@ -0,0 +1,215 @@
// ============================================================
// Recorder — shared mic-capture component for every note page.
// Faithful React port of public/js/liveEncounter.js (and the
// equivalent dictation/SOAP recording wrappers). Mirror behavior:
// • Mic permission via getUserMedia (mono / 16 kHz / EC+NS)
// • Live preview via Web Speech API (when enabled by user)
// • Pause / Resume native MediaRecorder where supported
// • On Stop → upload blob to /api/transcribe
// • If transcription unavailable, fall back to the live preview text
// • Failed uploads → /api/audio-backups (or IndexedDB) for retry
//
// The recorder is dumb about persistence — the parent note page
// owns the transcript text and uses encounter-persistence.ts to
// save/resume across sign-outs.
// ============================================================
import { useEffect, useRef, useState } from 'react';
import { AudioRecorder } from '@/lib/recorder';
import { transcribeAudio, isTranscribeAvailable, checkTranscribeStatus } from '@/lib/transcribe';
import { createSpeechSession, isSpeechRecognitionEnabled, deduplicateFinal, type SpeechHandle } from '@/lib/web-speech';
interface Props {
module: string; // 'encounter' | 'dictation' | 'soap' | …
// Called when transcription completes. The text replaces (or merges with)
// whatever the parent currently has in the transcript field.
onTranscript: (text: string, meta: { provider?: string; durationSec: number; appended: boolean }) => void;
// Called continuously with the live (interim) preview while recording.
// Parents can show a faded "interim" string concatenated to the
// confirmed transcript for instant visual feedback.
onInterim?: (text: string) => void;
// Called when transcription fails (so the parent can decide what to
// do — typically appending the live preview text instead).
onError?: (msg: string) => void;
disabled?: boolean;
}
const btnRecord = 'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-semibold border transition-colors';
export default function Recorder({ module, onTranscript, onInterim, onError, disabled }: Props) {
const recorderRef = useRef<AudioRecorder | null>(null);
const speechRef = useRef<SpeechHandle | null>(null);
const finalTextRef = useRef('');
const intervalRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const pauseAccumRef = useRef<number>(0);
const pauseStartRef = useRef<number>(0);
const [state, setState] = useState<'idle' | 'recording' | 'paused' | 'transcribing'>('idle');
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (isTranscribeAvailable() === null) checkTranscribeStatus();
}, []);
// Stop everything cleanly on unmount (page navigation while recording).
useEffect(() => {
return () => {
if (intervalRef.current) window.clearInterval(intervalRef.current);
if (speechRef.current) speechRef.current.stop();
if (recorderRef.current) recorderRef.current.stop().catch(() => { /* ignore */ });
};
}, []);
function tickStart() {
if (intervalRef.current) window.clearInterval(intervalRef.current);
intervalRef.current = window.setInterval(() => {
const now = Date.now();
const elapsed = Math.floor((now - startTimeRef.current - pauseAccumRef.current) / 1000);
setSeconds(elapsed);
}, 1000);
}
function tickStop() {
if (intervalRef.current) { window.clearInterval(intervalRef.current); intervalRef.current = null; }
}
async function start() {
if (state !== 'idle') return;
finalTextRef.current = '';
pauseAccumRef.current = 0;
setSeconds(0);
try {
const rec = new AudioRecorder();
await rec.start();
recorderRef.current = rec;
startTimeRef.current = Date.now();
tickStart();
setState('recording');
// Live preview via Web Speech (only if user enabled it in Settings).
if (isSpeechRecognitionEnabled()) {
const handle = createSpeechSession({
onFinal: (chunk) => {
const deduped = deduplicateFinal(chunk, finalTextRef.current);
finalTextRef.current += deduped;
onInterim?.(finalTextRef.current);
},
onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
onError: () => { /* swallow */ },
});
speechRef.current = handle;
handle?.start();
}
} catch {
onError?.('Microphone permission denied');
setState('idle');
}
}
function pause() {
if (state !== 'recording') return;
recorderRef.current?.pause();
speechRef.current?.stop();
pauseStartRef.current = Date.now();
tickStop();
setState('paused');
}
function resume() {
if (state !== 'paused') return;
recorderRef.current?.resume();
pauseAccumRef.current += Date.now() - pauseStartRef.current;
tickStart();
if (isSpeechRecognitionEnabled() && !speechRef.current) {
const handle = createSpeechSession({
onFinal: (chunk) => {
const deduped = deduplicateFinal(chunk, finalTextRef.current);
finalTextRef.current += deduped;
onInterim?.(finalTextRef.current);
},
onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
});
speechRef.current = handle;
}
speechRef.current?.start();
setState('recording');
}
async function stop() {
if (state !== 'recording' && state !== 'paused') return;
const liveText = finalTextRef.current.trim();
speechRef.current?.stop();
speechRef.current = null;
tickStop();
const dur = seconds;
setState('transcribing');
try {
const blob = await recorderRef.current!.stop();
recorderRef.current = null;
if (!blob || blob.size === 0) {
// Recording produced nothing — fall back to live preview if any.
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
setState('idle');
return;
}
// Server-side too-large guard mirrors the vanilla 24 MB cap.
if (blob.size > 24 * 1024 * 1024) {
onError?.('Recording too large for AI transcription — using live transcript');
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
setState('idle');
return;
}
const result = await transcribeAudio(blob, module);
if (result.success && result.text) {
onTranscript(result.text, { provider: result.provider, durationSec: dur, appended: false });
} else if (result.noProvider) {
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
else onError?.('No transcription API configured');
} else {
if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
else onError?.(result.error || 'Transcription failed');
}
} catch (e) {
onError?.((e as Error).message);
} finally {
setState('idle');
}
}
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
return (
<div className="flex flex-wrap items-center gap-2" data-testid={'recorder-' + module}>
{state === 'idle' && (
<button type="button" onClick={start} disabled={disabled}
className={btnRecord + ' bg-destructive text-white border-destructive hover:bg-red-700'}
data-testid="recorder-start">
🎙 Start recording
</button>
)}
{(state === 'recording' || state === 'paused') && (
<>
<button type="button" onClick={state === 'recording' ? pause : resume}
className={btnRecord + ' bg-amber-500 text-white border-amber-500 hover:bg-amber-600'}
data-testid="recorder-pause">
{state === 'recording' ? '⏸ Pause' : '▶ Resume'}
</button>
<button type="button" onClick={stop}
className={btnRecord + ' bg-slate-800 text-white border-slate-800 hover:bg-slate-900'}
data-testid="recorder-stop">
Stop
</button>
<span className="inline-flex items-center gap-1.5 text-sm text-destructive font-mono"
data-testid="recorder-timer">
<span className={state === 'recording' ? 'animate-pulse' : 'opacity-50'}></span>
{state === 'paused' ? 'paused ' : ''}{mm}:{ss}
</span>
</>
)}
{state === 'transcribing' && (
<span className="text-sm text-muted-foreground" data-testid="recorder-transcribing">
Transcribing
</span>
)}
</div>
);
}

View file

@ -0,0 +1,124 @@
// ============================================================
// Encounter persistence — port of public/js/encounters.js save/load
// flow. sessionStorage keys survive page refresh + sign-out so a
// resumed session lands on the same DB row instead of creating a
// duplicate. Optimistic-locking via expected_version preserved.
// ============================================================
export type EncType = 'encounter' | 'dictation' | 'hospital' | 'chart' | 'wellvisit' | 'sickvisit' | 'soap';
const SAVED_KEY = (t: EncType) => '_savedEncId_' + t;
const IDEMP_KEY = (t: EncType) => '_idempKey_' + t;
export function getSavedEncId(t: EncType): number | null {
try {
const v = sessionStorage.getItem(SAVED_KEY(t));
return v ? Number(v) : null;
} catch { return null; }
}
export function setSavedEncId(t: EncType, id: number | null) {
try {
if (id == null) sessionStorage.removeItem(SAVED_KEY(t));
else sessionStorage.setItem(SAVED_KEY(t), String(id));
} catch { /* ignore */ }
}
export function getIdempotencyKey(t: EncType): string {
try {
const existing = sessionStorage.getItem(IDEMP_KEY(t));
if (existing) return existing;
} catch { /* ignore */ }
const fresh = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
try { sessionStorage.setItem(IDEMP_KEY(t), fresh); } catch { /* ignore */ }
return fresh;
}
export function resetIdempotencyKey(t: EncType) {
try { sessionStorage.removeItem(IDEMP_KEY(t)); } catch { /* ignore */ }
}
const _versions = new Map<number, number>();
export interface SaveEncounterInput {
type: EncType;
label: string;
transcript: string;
generatedNote: string;
partialData?: unknown; // serialized to JSON
}
export interface SaveEncounterResult {
id: number;
version: number;
}
export async function saveEncounter(input: SaveEncounterInput): Promise<SaveEncounterResult> {
if (!input.label.trim()) throw new Error('Enter a patient label first');
const id = getSavedEncId(input.type);
const body: Record<string, unknown> = {
label: input.label.trim(),
enc_type: input.type,
transcript: input.transcript,
generated_note: input.generatedNote,
partial_data: input.partialData != null ? JSON.stringify(input.partialData) : undefined,
idempotency_key: getIdempotencyKey(input.type),
};
if (id != null) {
body.id = id;
const expected = _versions.get(id);
if (expected != null) body.expected_version = expected;
}
const r = await fetch('/api/encounters/saved', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await r.json();
if (r.status === 409) throw new Error('Someone else edited this encounter. Reload to see the latest version.');
if (!data.success) throw new Error(data.error || 'Save failed');
if (data.id != null) {
setSavedEncId(input.type, data.id);
if (data.version != null) _versions.set(data.id, data.version);
}
return { id: data.id, version: data.version };
}
export interface LoadedEncounter {
id: number;
label: string;
enc_type: string;
transcript: string;
generated_note: string;
partial_data: string | null;
version?: number;
}
export async function loadEncounter(id: number): Promise<LoadedEncounter> {
const r = await fetch('/api/encounters/saved/' + id, { credentials: 'include' });
const data = await r.json();
if (!data.success) throw new Error(data.error || 'Load failed');
if (data.encounter && data.encounter.version != null) _versions.set(id, data.encounter.version);
return data.encounter as LoadedEncounter;
}
export interface SavedEncounterListEntry {
id: number;
label: string;
enc_type: EncType;
status?: string;
updated_at: string;
expires_at: string;
}
export async function listSavedEncounters(): Promise<SavedEncounterListEntry[]> {
const r = await fetch('/api/encounters/saved', { credentials: 'include' });
const data = await r.json();
return (data.encounters || []) as SavedEncounterListEntry[];
}
export function clearTabState(t: EncType) {
setSavedEncId(t, null);
resetIdempotencyKey(t);
}

View file

@ -0,0 +1,59 @@
// ============================================================
// AudioRecorder — verbatim port of public/js/app.js:660-687.
// Same constraints (mono, 16kHz, EC+NS), same opus 32kbps target.
// Exposes the underlying mediaRecorder + stream so the React
// Recorder component can pause/resume + restart on the same stream
// (matches vanilla liveEncounter.js fallback behavior).
// ============================================================
export class AudioRecorder {
mediaRecorder: MediaRecorder | null = null;
chunks: Blob[] = [];
stream: MediaStream | null = null;
start(): Promise<void> {
this.chunks = [];
return navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true },
}).then((stream) => {
this.stream = stream;
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
this.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
this.mediaRecorder.start(1000);
});
}
stop(): Promise<Blob | null> {
return new Promise((resolve) => {
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') { resolve(null); return; }
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.mediaRecorder!.mimeType });
if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
resolve(blob);
};
this.mediaRecorder.stop();
});
}
pause() {
if (this.mediaRecorder && typeof this.mediaRecorder.pause === 'function' && this.mediaRecorder.state === 'recording') {
try { this.mediaRecorder.pause(); } catch { /* ignore */ }
}
}
resume() {
if (!this.mediaRecorder) return;
try {
if (typeof this.mediaRecorder.resume === 'function' && this.mediaRecorder.state === 'paused') {
this.mediaRecorder.resume();
} else if (this.mediaRecorder.state === 'inactive' && this.stream && this.stream.active) {
// Browser killed it — restart on the same stream (matches vanilla fallback).
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
this.mediaRecorder.start(1000);
}
} catch { /* ignore */ }
}
}

View file

@ -0,0 +1,99 @@
// ============================================================
// Transcribe + audio-backup helpers — port of public/js/app.js
// transcribeAudio() / _serverTranscribe() / saveAudioBackup().
//
// Server-first via /api/transcribe (cookie auth — same-origin).
// On failure, blob is saved to /api/audio-backups so the user can
// retry from Settings → Audio Backups (or vanilla Audio Backups page).
// IndexedDB fallback preserved from vanilla audioBackup.js.
// ============================================================
export interface TranscribeResult {
success: boolean;
text?: string;
provider?: string;
duration?: number;
noProvider?: boolean;
error?: string;
}
let transcribeAvailable: boolean | null = null;
let transcribeProvider = 'none';
export async function checkTranscribeStatus(): Promise<void> {
try {
const r = await fetch('/api/transcribe/status', { credentials: 'include' });
const data = await r.json();
transcribeAvailable = !!data.available;
transcribeProvider = data.provider || 'none';
} catch {
transcribeAvailable = false;
}
}
export function isTranscribeAvailable(): boolean | null { return transcribeAvailable; }
export function getTranscribeProvider(): string { return transcribeProvider; }
export async function transcribeAudio(blob: Blob, module = 'encounter'): Promise<TranscribeResult> {
if (transcribeAvailable === null) await checkTranscribeStatus();
if (transcribeAvailable === false) {
return { success: false, noProvider: true, error: 'No transcription API configured — using live transcript' };
}
const form = new FormData();
form.append('audio', blob, 'audio.webm');
try {
const r = await fetch('/api/transcribe', { method: 'POST', credentials: 'include', body: form });
const data: TranscribeResult = await r.json();
if (!data.success && blob.size > 0) {
// Best-effort: save the blob so the user can retry later.
saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
}
return data;
} catch (e) {
saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
return { success: false, error: (e as Error).message };
}
}
// ── Audio backup (server-first, IndexedDB fallback) ──
export async function saveAudioBackup(blob: Blob, module: string): Promise<number | null> {
// Server first.
try {
const form = new FormData();
form.append('audio', blob, 'audio.webm');
form.append('module', module);
const r = await fetch('/api/audio-backups', { method: 'POST', credentials: 'include', body: form });
const data = await r.json();
if (data.success && data.id) return data.id;
} catch { /* fall through */ }
// IndexedDB fallback.
return saveToIndexedDB(blob, module);
}
const DB_NAME = 'PedScribeAudioBackup';
const STORE = 'recordings';
let _db: IDBDatabase | null = null;
function openDB(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
req.onsuccess = () => { _db = req.result; resolve(_db); };
req.onerror = () => reject(new Error('IndexedDB open failed'));
});
}
function saveToIndexedDB(blob: Blob, module: string): Promise<number | null> {
return openDB().then((db) => new Promise<number>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
const req = store.add({ blob, module, timestamp: Date.now(), size: blob.size, mimeType: blob.type });
req.onsuccess = () => resolve(req.result as number);
req.onerror = () => reject(new Error('Failed to save audio backup'));
})).catch(() => null);
}

View file

@ -0,0 +1,117 @@
// ============================================================
// Web Speech API wrapper — verbatim port of
// public/js/speechRecognition.js. Browser-side live transcription
// for showing words as the user speaks. Falls back to no-op when
// the API is unsupported. Privacy warning preserved.
// ============================================================
const STORAGE_ENABLED = 'ped_web_speech_enabled';
interface SpeechRecognitionResult {
isFinal: boolean;
[index: number]: { transcript: string };
}
interface SpeechRecognitionResults {
length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionEvent {
resultIndex: number;
results: SpeechRecognitionResults;
}
interface SpeechRecognitionErrorEvent { error: string }
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
maxAlternatives: number;
onresult: ((e: SpeechRecognitionEvent) => void) | null;
onerror: ((e: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
start(): void;
stop(): void;
}
type SRCtor = new () => SpeechRecognitionLike;
function getCtor(): SRCtor | null {
if (typeof window === 'undefined') return null;
const w = window as unknown as { SpeechRecognition?: SRCtor; webkitSpeechRecognition?: SRCtor };
return (w.SpeechRecognition || w.webkitSpeechRecognition) || null;
}
export function isSpeechRecognitionSupported(): boolean {
return !!getCtor();
}
export function isSpeechRecognitionEnabled(): boolean {
try { return localStorage.getItem(STORAGE_ENABLED) === '1' || localStorage.getItem(STORAGE_ENABLED) === 'true'; }
catch { return false; }
}
export function setSpeechRecognitionEnabled(v: boolean) {
try { localStorage.setItem(STORAGE_ENABLED, v ? 'true' : 'false'); } catch { /* ignore */ }
}
export interface SpeechHandle {
start: () => void;
stop: () => void;
}
// Creates a continuous-listening session that calls handlers as words
// come in. Mirrors the liveEncounter.js wiring (final + interim results).
export function createSpeechSession(handlers: {
onFinal: (text: string) => void;
onInterim: (text: string) => void;
onError?: (err: string) => void;
}): SpeechHandle | null {
const Ctor = getCtor();
if (!Ctor) return null;
let rec: SpeechRecognitionLike | null = null;
let active = false;
function build(): SpeechRecognitionLike {
const r = new (Ctor as SRCtor)();
r.continuous = true;
r.interimResults = true;
r.lang = 'en-US';
r.maxAlternatives = 1;
r.onresult = (e: SpeechRecognitionEvent) => {
let interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) handlers.onFinal(t + ' ');
else interim = t;
}
handlers.onInterim(interim);
};
r.onerror = (e: SpeechRecognitionErrorEvent) => {
if (e.error === 'no-speech' || e.error === 'aborted') return;
handlers.onError?.(e.error);
};
r.onend = () => { if (active) try { r.start(); } catch { /* ignore */ } };
return r;
}
return {
start: () => { active = true; rec = build(); try { rec.start(); } catch { /* ignore */ } },
stop: () => { active = false; if (rec) try { rec.stop(); } catch { /* ignore */ } },
};
}
// Verbatim port of deduplicateFinal from public/js/app.js:964-984.
export function deduplicateFinal(newText: string, existingText: string): string {
if (!newText || !existingText) return newText;
const trimmed = newText.trim();
if (!trimmed) return '';
if (existingText.trimEnd().endsWith(trimmed)) return '';
const words = trimmed.split(/\s+/);
if (words.length >= 3) {
const tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
if (tail === trimmed) return '';
const half = Math.ceil(words.length / 2);
const firstHalf = words.slice(0, half).join(' ');
if (existingText.trimEnd().endsWith(firstHalf)) {
return words.slice(half).join(' ') + ' ';
}
}
return newText;
}

View file

@ -6,14 +6,17 @@ import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
const TYPE = 'chart' as const;
interface VisitInput { date: string; content: string; labs: string }
function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; }
export default function ChartReview() {
const [label, setLabel] = useState('');
const [type, setType] = useState<ReviewType>('outpatient');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
@ -56,6 +59,34 @@ export default function ChartReview() {
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={JSON.stringify(visits)} generatedNote={result || ''}
partialData={{ type, age: patientAge, gender: patientGender, pmh, additionalInstructions }}
onLoad={(enc) => {
try {
const parsedVisits = enc.transcript ? JSON.parse(enc.transcript) as VisitInput[] : [emptyVisit()];
setVisits(parsedVisits.length ? parsedVisits : [emptyVisit()]);
} catch { setVisits([emptyVisit()]); }
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.type) setType(pd.type);
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.pmh) setPmh(pd.pmh);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setVisits([emptyVisit()]); setResult(null);
setType('outpatient'); setPatientAge(''); setPatientGender('');
setPmh(''); setAdditionalInstructions('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<label className="flex flex-col gap-1">

View file

@ -13,16 +13,22 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'dictation' as const;
export default function Dictation() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [setting, setSetting] = useState<Setting>('outpatient');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-dictation', body),
@ -33,7 +39,7 @@ export default function Dictation() {
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: HpiEncounterRequest = { transcript, patientAge, patientGender, setting };
const body: HpiEncounterRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, setting };
const parsed = HpiEncounterRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
@ -45,11 +51,13 @@ export default function Dictation() {
function clear() {
setTranscript('');
setInterim('');
setResult(null);
setValidationError(null);
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
@ -57,10 +65,29 @@ export default function Dictation() {
<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>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, setting }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.setting) setSetting(pd.setting);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => { clear(); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
@ -84,6 +111,18 @@ export default function Dictation() {
</label>
</div>
<Recorder
module="dictation"
onTranscript={(text, meta) => {
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
@ -95,9 +134,9 @@ export default function Dictation() {
</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)}
placeholder="Click Start recording, or type / paste your dictation here."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>
@ -107,7 +146,7 @@ export default function Dictation() {
<div className="flex gap-2">
<button
type="submit"
disabled={generate.isPending || !transcript.trim()}
disabled={generate.isPending || !displayedTranscript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate HPI'}

View file

@ -1,7 +1,8 @@
// ============================================================
// 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.
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter.
// Full port — mic recorder + Web Speech live preview + transcribe
// + save/resume across sign-outs (mirrors public/js/liveEncounter.js
// + encounters.js).
// ============================================================
import { useState } from 'react';
@ -9,16 +10,22 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'encounter' as const;
export default function Encounter() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [setting, setSetting] = useState<Setting>('outpatient');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-encounter', body),
@ -28,7 +35,7 @@ export default function Encounter() {
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: HpiEncounterRequest = { transcript, patientAge, patientGender, setting };
const body: HpiEncounterRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, setting };
const parsed = HpiEncounterRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
@ -39,6 +46,7 @@ export default function Encounter() {
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
@ -49,6 +57,26 @@ export default function Encounter() {
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, setting }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.setting) setSetting(pd.setting);
} catch { /* ignore malformed partial */ }
setLabel(enc.label || '');
}}
onClear={() => { setTranscript(''); setInterim(''); setResult(null); setPatientAge(''); setPatientGender(''); setSetting('outpatient'); }}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
@ -72,15 +100,30 @@ export default function Encounter() {
</label>
</div>
<Recorder
module="encounter"
onTranscript={(text, meta) => {
// appended=true means the live preview text is being kept;
// appended=false means we got a fresh transcription that
// should replace what's there.
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Transcript
</span>
<textarea
className={input + ' min-h-[220px] font-mono text-sm'}
placeholder="Type or paste an encounter transcript, then click Generate."
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder="Click Start recording, or type / paste an encounter transcript here."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>
@ -89,7 +132,7 @@ export default function Encounter() {
<button
type="submit"
disabled={generate.isPending || !transcript.trim()}
disabled={generate.isPending || !displayedTranscript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate HPI'}

View file

@ -6,13 +6,18 @@ import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { HospitalCourseOk } from '@/shared/types';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
const TYPE = 'hospital' as const;
interface NoteEntry { date: string; type: string; content: string }
export default function HospitalCourse() {
const [label, setLabel] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [pmh, setPmh] = useState('');
@ -61,6 +66,35 @@ export default function HospitalCourse() {
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={notesText} generatedNote={result?.hospitalCourse || ''}
partialData={{ age: patientAge, gender: patientGender, pmh, setting, los, format, hAndPContent, additionalInstructions }}
onLoad={(enc) => {
setNotesText(enc.transcript || '');
setResult(enc.generated_note ? { hospitalCourse: enc.generated_note, format: 'auto' } : null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.pmh) setPmh(pd.pmh);
if (pd?.setting) setSetting(pd.setting);
if (pd?.los) setLos(pd.los);
if (pd?.format) setFormat(pd.format);
if (pd?.hAndPContent) setHAndPContent(pd.hAndPContent);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setNotesText(''); setResult(null);
setPatientAge(''); setPatientGender(''); setPmh('');
setSetting('floor'); setLos(''); setFormat('auto');
setHAndPContent(''); setAdditionalInstructions('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
@ -110,6 +144,20 @@ export default function HospitalCourse() {
<textarea className={input + ' min-h-[120px] font-mono text-sm'} value={hAndPContent} onChange={(e) => setHAndPContent(e.target.value)} />
</label>
<Recorder
module="hospital"
onTranscript={(text, meta) => {
if (meta.appended) {
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
} else {
setNotesText((prev) => prev ? prev + '\n\n' + text : text);
}
setRecError(null);
}}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Progress notes <span className="normal-case font-normal text-muted-foreground">(separate each note with a blank line)</span>

View file

@ -7,14 +7,21 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
const TYPE = 'sickvisit' as const;
export default function SickVisit() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [chiefComplaint, setChiefComplaint] = useState('');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<VisitNoteOk, Error, SickVisitRequest>({
mutationFn: (body) => api.post<VisitNoteOk>('/api/sick-visit/note', body),
@ -24,7 +31,7 @@ export default function SickVisit() {
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript };
const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript: (interim || transcript).trim() };
const parsed = SickVisitRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
@ -35,6 +42,7 @@ export default function SickVisit() {
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
@ -45,6 +53,29 @@ export default function SickVisit() {
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, chiefComplaint }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.chiefComplaint) setChiefComplaint(pd.chiefComplaint);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
setPatientAge(''); setPatientGender(''); setChiefComplaint('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
@ -65,13 +96,25 @@ export default function SickVisit() {
</label>
</div>
<Recorder
module="sickvisit"
onTranscript={(text, meta) => {
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Encounter narrative — transcribed or dictated."
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder="Click Start recording, or type / paste encounter narrative."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>

View file

@ -7,17 +7,23 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { SoapOk } from '@/shared/types';
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
type SoapType = 'full' | 'subjective';
const TYPE = 'soap' as const;
export default function Soap() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [type, setType] = useState<SoapType>('full');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [additionalInstructions, setAdditionalInstructions] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
const generate = useMutation<SoapOk, Error, SoapRequest>({
mutationFn: (body) => api.post<SoapOk>('/api/generate-soap', body),
@ -27,7 +33,7 @@ export default function Soap() {
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: SoapRequest = { transcript, patientAge, patientGender, type, additionalInstructions };
const body: SoapRequest = { transcript: (interim || transcript).trim(), patientAge, patientGender, type, additionalInstructions };
const parsed = SoapRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
@ -38,6 +44,7 @@ export default function Soap() {
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const displayedTranscript = interim || transcript;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
@ -48,6 +55,30 @@ export default function Soap() {
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, type, additionalInstructions }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.type) setType(pd.type);
if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
setPatientAge(''); setPatientGender(''); setType('full'); setAdditionalInstructions('');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
@ -71,13 +102,25 @@ export default function Soap() {
</label>
</div>
<Recorder
module="soap"
onTranscript={(text, meta) => {
setTranscript((prev) => (meta.appended ? (prev ? prev + ' ' + text : text) : text));
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(msg) => setRecError(msg)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript</span>
<textarea
className={input + ' min-h-[200px] font-mono text-sm'}
placeholder="Type or paste the encounter transcript."
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder="Click Start recording, or type / paste the encounter transcript."
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
/>
</label>
@ -98,7 +141,7 @@ export default function Soap() {
<button
type="submit"
disabled={generate.isPending || !transcript.trim()}
disabled={generate.isPending || !displayedTranscript.trim()}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate SOAP'}

View file

@ -9,8 +9,12 @@ import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
const TYPE = 'wellvisit' as const;
export default function WellVisit() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [visitAge, setVisitAge] = useState('');
@ -50,6 +54,36 @@ export default function WellVisit() {
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, screenings, vaccines, noteStyle }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.visitAge) setVisitAge(pd.visitAge);
if (pd?.vitals) setVitals(pd.vitals);
if (pd?.measurements) setMeasurements(pd.measurements);
if (pd?.parentConcerns) setParentConcerns(pd.parentConcerns);
if (pd?.screenings) setScreenings(pd.screenings);
if (pd?.vaccines) setVaccines(pd.vaccines);
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setResult(null);
setPatientAge(''); setPatientGender(''); setVisitAge('');
setVitals(''); setMeasurements(''); setParentConcerns('');
setScreenings(''); setVaccines(''); setNoteStyle('full');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">