feat(client): port Settings — Voice + Content (8 sub-sections)

Third and final commit of the Settings port. Adds the remaining eight
sub-sections so the React page matches vanilla settings.html 1:1.
After this commit Settings is fully ported; Layout already flipped to
available in commit 1, and the page fills out cleanly for local-auth
and SSO users alike.

Voice Preferences (VoicePreferencesCard)
  GET /api/user/preferences + /api/user/preferences/options populate the
  STT model / TTS voice selectors. Save POSTs /api/user/preferences.
  Preview persists the current TTS selection, then fetches /api/text-to-
  speech (binary blob, bypasses the JSON api wrapper), wraps the blob in
  an Audio element and plays it. A one-shot hydrated flag drives the
  first selection sync; after that the fields are local state.

Browser Whisper (BrowserWhisperCard) — UI-only port
  Persists the enabled flag + model choice under the same localStorage
  keys the vanilla BrowserWhisper module reads, so behavior will light
  up automatically when the recording components port. The preload +
  WASM transcription flow stays in vanilla for this commit — noted in
  the page copy so users aren't surprised.

Web Speech Recognition (WebSpeechCard) — UI-only port
  Same localStorage approach. Enabling surfaces a styled ConfirmModal
  with the HIPAA privacy warning before persisting. Enabling Web Speech
  flips Browser Whisper off automatically (mirrors vanilla priority:
  Web Speech > Browser Whisper > server).

My Templates (TemplatesCard)
  Full Memories CRUD for non-correction entries: category select,
  name, content textarea, Add/Update toggle (in-place edit), per-row
  Delete confirm. Hits /api/memories {GET, POST, PUT, DELETE}.

AI Corrections (CorrectionsCard)
  Read-only list filtered to category starting with 'correction_'.
  Per-row expand reveals the parsed ORIGINAL / CORRECTED TO: split
  (same text delimiter the vanilla parseCorrection() uses). Delete is
  wired through /api/memories/:id.

Audio Backups (AudioBackupsCard)
  Lists /api/audio-backups (server-stored, 24h TTL). Play opens the
  decompressed audio stream in a new tab; Delete hits DELETE
  /api/audio-backups/:id. Retry flow stays in vanilla for this commit —
  it re-submits to /api/transcribe and that integration belongs with
  the recording components.

Saved Encounters (SavedEncountersCard)
  Lists /api/encounters/saved with label / type / expires / preview.
  Delete only — Resume requires the encounter pages to receive
  pre-filled state, which ports alongside those pages.

Compliance (ComplianceCard)
  Static info card — plain JSX, no API.

shared/types.ts + client/src/shared/types.ts — additive only:
  UserPreferencesOk, PreferencesOptionsOk, VoiceOption,
  SavedEncounterRow, SavedEncountersListOk, AudioBackupRow,
  AudioBackupsListOk, MemoryRow, MemoriesOk.

e2e/tests/settings-react-voice-content.spec.js — seven smoke tests
covering control presence, templates empty-save validation, and the
Web Speech privacy-confirm modal (with the no-native-dialog guard).

Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Final bundle 425.42 kB / 121.55 kB gzipped (+21 kB over commit 2).
The e2e container still predates /app/*; running these specs against
it needs a rebuild.
This commit is contained in:
Daniel 2026-04-23 23:40:23 +02:00
parent 25fa628b83
commit 719d0cb7f7
9 changed files with 1088 additions and 55 deletions

View file

@ -38,6 +38,15 @@ import type {
DocumentUploadOk,
DocumentDownloadOk,
UserDocument,
UserPreferencesOk,
PreferencesOptionsOk,
VoiceOption,
SavedEncountersListOk,
SavedEncounterRow,
AudioBackupsListOk,
AudioBackupRow,
MemoriesOk,
MemoryRow,
} from '@/shared/types';
// ── Small presentational bits ────────────────────────────────
@ -919,6 +928,803 @@ function DocumentsCard() {
);
}
// ── Voice Preferences (STT model + TTS voice) ───────────────
function VoicePreferencesCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [sttModel, setSttModel] = useState('');
const [ttsVoice, setTtsVoice] = useState('');
const [previewing, setPreviewing] = useState(false);
const [hydrated, setHydrated] = useState(false);
const { data: options } = useQuery<PreferencesOptionsOk>({
queryKey: ['voice-options'],
queryFn: () => api.get<PreferencesOptionsOk>('/api/user/preferences/options'),
});
const { data: prefs } = useQuery<UserPreferencesOk>({
queryKey: ['voice-prefs'],
queryFn: () => api.get<UserPreferencesOk>('/api/user/preferences'),
});
// Hydrate selection from the server once — subsequent typing is local.
if (!hydrated && prefs) {
setSttModel(prefs.stt_model || '');
setTtsVoice(prefs.tts_voice || '');
setHydrated(true);
}
const save = useMutation({
mutationFn: (body: { stt_model: string | null; tts_voice: string | null }) =>
api.post<{ success: true }>('/api/user/preferences', body),
onSuccess: () => {
setMsg({ text: 'Voice preferences saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['voice-prefs'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }),
});
async function previewVoice() {
setMsg(null);
setPreviewing(true);
try {
// First persist the selection so the server renders with the chosen voice.
await api.post('/api/user/preferences', { tts_voice: ttsVoice || null });
qc.invalidateQueries({ queryKey: ['voice-prefs'] });
const text =
'Hello, this is a preview of the ' +
(ttsVoice || 'server default') +
' voice. This is how your read-aloud feature will sound.';
const resp = await fetch('/api/text-to-speech', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!resp.ok) throw new Error('Preview failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
await audio.play();
} catch (e) {
setMsg({ text: 'Preview failed: ' + (e as Error).message, kind: 'err' });
} finally {
setPreviewing(false);
}
}
return (
<section className={card} data-testid="voice-preferences-section">
<h3 className="text-base font-semibold">Voice Preferences</h3>
<p className="text-sm text-muted-foreground">
Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features.
</p>
<div className="space-y-2 max-w-md">
<label className="block text-sm">
<span className="block text-xs font-semibold text-muted-foreground mb-1">
Speech-to-Text Model (Transcription)
</span>
<select
className={input}
value={sttModel}
onChange={(e) => setSttModel(e.target.value)}
data-testid="stt-model-select"
>
<option value="">Server default{options ? ' (' + options.sttProvider + ')' : ''}</option>
{options?.sttModels.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="block text-xs font-semibold text-muted-foreground mb-1">
Text-to-Speech Voice (Read Aloud)
</span>
<div className="flex gap-2">
<select
className={input}
value={ttsVoice}
onChange={(e) => setTtsVoice(e.target.value)}
data-testid="tts-voice-select"
>
<option value="">Server default{options ? ' (' + options.ttsProvider + ')' : ''}</option>
{options?.ttsVoices.map((v) => (
<option key={v.value} value={v.value}>{v.label}</option>
))}
</select>
<button
type="button"
className={btnGhost}
disabled={previewing}
onClick={previewVoice}
data-testid="btn-preview-voice"
>
{previewing ? 'Loading…' : 'Preview'}
</button>
</div>
</label>
<button
type="button"
className={btnPrimary}
disabled={save.isPending}
onClick={() =>
save.mutate({
stt_model: sttModel || null,
tts_voice: ttsVoice || null,
})
}
data-testid="btn-save-voice-prefs"
>
{save.isPending ? 'Saving…' : 'Save Voice Preferences'}
</button>
</div>
<StatusLine msg={msg} />
</section>
);
}
// ── Browser Whisper (WASM, local transcription) ─────────────
// UI-only port: persists enabled + model choice in localStorage under the
// same keys the vanilla BrowserWhisper module reads. The actual WASM
// model preload / transcription still lives in the vanilla
// public/js/browserWhisper.js module and will wire through when the
// recording components port to React.
const BW_ENABLED_KEY = 'ped_browser_whisper_enabled';
const BW_MODEL_KEY = 'ped_browser_whisper_model';
const BW_MODELS: VoiceOption[] = [
{ value: 'Xenova/whisper-tiny.en', label: 'Tiny (~39MB) — fastest, ~2-3s' },
{ value: 'Xenova/whisper-base.en', label: 'Base (~74MB) — balanced, ~3-5s' },
{ value: 'Xenova/whisper-small.en', label: 'Small (~244MB) — best quality, ~6-10s' },
];
function readLS(k: string, fallback = ''): string {
try { return window.localStorage.getItem(k) ?? fallback; } catch { return fallback; }
}
function writeLS(k: string, v: string) {
try { window.localStorage.setItem(k, v); } catch { /* ignore */ }
}
function BrowserWhisperCard() {
const [enabled, setEnabled] = useState(readLS(BW_ENABLED_KEY) === 'true');
const [model, setModel] = useState(readLS(BW_MODEL_KEY) || BW_MODELS[0].value);
function toggle(next: boolean) {
setEnabled(next);
writeLS(BW_ENABLED_KEY, String(next));
}
function changeModel(next: string) {
setModel(next);
writeLS(BW_MODEL_KEY, next);
}
return (
<section className={card} data-testid="browser-whisper-section">
<h3 className="text-base font-semibold">Browser Transcription (Local Whisper)</h3>
<p className="text-sm text-muted-foreground">
Transcribes audio entirely in your browser no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.
</p>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-sm font-medium">Enable browser transcription:</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-primary size-4"
checked={enabled}
onChange={(e) => toggle(e.target.checked)}
data-testid="browser-whisper-enabled"
/>
<span className="text-sm" data-testid="browser-whisper-status">
{enabled ? 'On — audio stays on device' : 'Off'}
</span>
</label>
</div>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-sm font-medium">Model:</label>
<select
className={input + ' max-w-md'}
value={model}
onChange={(e) => changeModel(e.target.value)}
data-testid="browser-whisper-model"
>
{BW_MODELS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<p className="text-xs text-muted-foreground">
When enabled, overrides server transcription. Falls back to server if browser transcription fails. The actual WASM download runs from the recording components pre-download will light up when those port to React.
</p>
</section>
);
}
// ── Web Speech Recognition (real-time streaming) ────────────
const WS_ENABLED_KEY = 'ped_web_speech_enabled';
function WebSpeechCard() {
const [enabled, setEnabled] = useState(readLS(WS_ENABLED_KEY) === 'true');
const [confirmOpen, setConfirmOpen] = useState(false);
const supported =
typeof window !== 'undefined' &&
('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
function enable() {
// Enabling Web Speech flips Browser Whisper off (the vanilla app enforces
// the same priority: Web Speech > Browser Whisper > server).
writeLS(BW_ENABLED_KEY, 'false');
setEnabled(true);
writeLS(WS_ENABLED_KEY, 'true');
}
function disable() {
setEnabled(false);
writeLS(WS_ENABLED_KEY, 'false');
}
return (
<section className={card + ' border-l-4 border-l-orange-500'} data-testid="web-speech-section">
<h3 className="text-base font-semibold">Real-Time Streaming Transcription</h3>
<div className="bg-orange-50 dark:bg-orange-950/30 p-3 rounded-md text-sm text-orange-900 dark:text-orange-100">
<strong>Privacy Warning:</strong> Uses your browser's built-in speech recognition, which <strong>may send audio to cloud servers</strong> (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.
</div>
<p className="text-sm text-muted-foreground">
See words appear as you speak (streaming). Overrides browser and server transcription when enabled. <strong>Not HIPAA-compliant</strong> in most browsers.
</p>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-sm font-medium">Enable real-time streaming:</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-orange-500 size-4"
checked={enabled}
disabled={!supported}
onChange={(e) => {
if (e.target.checked) setConfirmOpen(true);
else disable();
}}
data-testid="web-speech-enabled"
/>
<span className="text-sm" data-testid="web-speech-status">
{!supported ? 'Not supported in this browser' : enabled ? 'On — real-time streaming' : 'Off'}
</span>
</label>
</div>
{supported && (
<p className="text-xs text-muted-foreground">
Streaming integration lights up when the recording components port to React.
</p>
)}
<ConfirmModal
open={confirmOpen}
title="Enable real-time streaming transcription?"
body="Your browser's speech recognition may send audio to cloud servers (e.g. Google). This is NOT HIPAA-compliant. Only enable if you understand and accept this privacy trade-off."
confirmText="Enable"
danger
onConfirm={() => {
enable();
setConfirmOpen(false);
}}
onCancel={() => setConfirmOpen(false)}
/>
</section>
);
}
// ── My Templates (Memories, non-correction entries) ─────────
const TEMPLATE_CATEGORIES: Array<{ value: string; label: string }> = [
{ value: 'physical_exam', label: 'Physical Exam Template' },
{ value: 'ros', label: 'Review of Systems Template' },
{ value: 'encounter_format', label: 'Encounter Note Format' },
{ value: 'family_history', label: 'Family History Format' },
{ value: 'assessment_plan', label: 'Assessment & Plan Format' },
{ value: 'template_soap', label: 'SOAP Note Template' },
{ value: 'template_hpi', label: 'HPI Template' },
{ value: 'template_wellvisit', label: 'Well Visit Template' },
{ value: 'template_sickvisit', label: 'Sick Visit Template' },
{ value: 'custom', label: 'Custom' },
];
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TEMPLATE_CATEGORIES.map((c) => [c.value, c.label.replace(/ Template$| Format$/, '')])
);
function TemplatesCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [category, setCategory] = useState(TEMPLATE_CATEGORIES[0].value);
const [name, setName] = useState('');
const [content, setContent] = useState('');
const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);
const { data } = useQuery<MemoriesOk>({
queryKey: ['memories'],
queryFn: () => api.get<MemoriesOk>('/api/memories'),
});
const templates = (data?.memories || []).filter((m) => !m.category.startsWith('correction_'));
const save = useMutation({
mutationFn: (body: { id?: number; category: string; name: string; content: string }) => {
const { id, ...rest } = body;
return id
? api.put<{ success: true }>('/api/memories/' + id, rest)
: api.post<{ success: true }>('/api/memories', rest);
},
onSuccess: () => {
setMsg({ text: editingId ? 'Template updated' : 'Template saved', kind: 'ok' });
setEditingId(null);
setName('');
setContent('');
qc.invalidateQueries({ queryKey: ['memories'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id),
onSuccess: () => {
setMsg({ text: 'Template deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['memories'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
});
function beginEdit(m: MemoryRow) {
setEditingId(m.id);
setCategory(m.category);
setName(m.name);
setContent(m.content);
}
function cancelEdit() {
setEditingId(null);
setName('');
setContent('');
}
function submit(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (!name.trim()) return setMsg({ text: 'Enter a template name', kind: 'err' });
if (!content.trim()) return setMsg({ text: 'Enter template content', kind: 'err' });
save.mutate({ id: editingId ?? undefined, category, name: name.trim(), content: content.trim() });
}
return (
<section className={card} data-testid="templates-section">
<h3 className="text-base font-semibold">My Templates</h3>
<p className="text-sm text-muted-foreground">
Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes.
</p>
<form onSubmit={submit} className="space-y-2">
<div className="flex gap-2 flex-wrap items-center">
<select
className={input + ' max-w-xs'}
value={category}
onChange={(e) => setCategory(e.target.value)}
data-testid="mem-category"
>
{TEMPLATE_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<input
type="text"
className={input + ' flex-1 min-w-[150px]'}
placeholder="Template name (e.g. Normal PE)"
value={name}
onChange={(e) => setName(e.target.value)}
data-testid="mem-name"
/>
</div>
<textarea
rows={5}
className={input + ' resize-y'}
placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL…"
value={content}
onChange={(e) => setContent(e.target.value)}
data-testid="mem-content"
/>
<div className="flex gap-2">
<button
type="submit"
className={btnPrimary}
disabled={save.isPending}
data-testid="btn-mem-save"
>
{save.isPending ? 'Saving…' : editingId ? 'Update Template' : 'Add Template'}
</button>
{editingId !== null && (
<button type="button" className={btnGhost} onClick={cancelEdit}>Cancel</button>
)}
</div>
</form>
<div className="space-y-2">
{templates.length === 0 ? (
<div className="text-sm text-muted-foreground">No templates saved yet. Add one above.</div>
) : (
templates.map((m) => (
<div
key={m.id}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
data-testid={'mem-row-' + m.id}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{m.name}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{CATEGORY_LABEL[m.category] || m.category}
</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{(m.content || '').slice(0, 100).replace(/\n/g, ' ')}
{m.content && m.content.length > 100 ? '…' : ''}
</div>
</div>
<button
type="button"
className={btnGhost + ' text-xs'}
onClick={() => beginEdit(m)}
data-testid={'btn-mem-edit-' + m.id}
>
Edit
</button>
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={() => setDeleteTarget(m)}
data-testid={'btn-mem-delete-' + m.id}
>
Delete
</button>
</div>
))
)}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title={'Delete template "' + (deleteTarget?.name || '') + '"?'}
body="This cannot be undone."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── AI Corrections (read-only, expandable) ──────────────────
const CORRECTION_LABEL: Record<string, string> = {
correction_soap: 'SOAP Correction',
correction_hpi: 'HPI Correction',
correction_encounter: 'Encounter Correction',
correction_wellvisit: 'Well Visit Correction',
correction_sickvisit: 'Sick Visit Correction',
};
function parseCorrection(content: string): { original: string; corrected: string } {
const idx = content.indexOf('\nCORRECTED TO: ');
if (idx === -1) return { original: content.trim(), corrected: '' };
return {
original: content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '').trim(),
corrected: content.substring(idx + '\nCORRECTED TO: '.length).trim(),
};
}
function CorrectionsCard() {
const qc = useQueryClient();
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [msg, setMsg] = useState<Msg>(null);
const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);
const { data } = useQuery<MemoriesOk>({
queryKey: ['memories'],
queryFn: () => api.get<MemoriesOk>('/api/memories'),
});
const corrections = (data?.memories || []).filter((m) => m.category.startsWith('correction_'));
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id),
onSuccess: () => {
setMsg({ text: 'Correction deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['memories'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
});
return (
<section className={card} data-testid="corrections-section">
<h3 className="text-base font-semibold">AI Learning (Corrections)</h3>
<p className="text-sm text-muted-foreground">
The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.
</p>
<div className="space-y-2">
{corrections.length === 0 ? (
<div className="text-sm text-muted-foreground">
No corrections yet. Edit AI-generated notes and save to start learning.
</div>
) : (
corrections.map((m) => {
const isOpen = expanded[m.id];
const parts = parseCorrection(m.content || '');
const date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
return (
<div
key={m.id}
className="rounded-md bg-muted/40 border border-border overflow-hidden"
data-testid={'corr-row-' + m.id}
>
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2 text-left"
onClick={() => setExpanded({ ...expanded, [m.id]: !isOpen })}
>
<span className="text-xs text-muted-foreground">{isOpen ? '▾' : '▸'}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{CORRECTION_LABEL[m.category] || m.category}
</span>
<span className="flex-1 text-sm truncate">{m.name}</span>
<span className="text-xs text-muted-foreground">{date}</span>
<span
role="button"
className="text-destructive text-xs px-2"
onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); }}
data-testid={'btn-corr-delete-' + m.id}
>
Delete
</span>
</button>
{isOpen && (
<div className="px-3 py-2 text-sm space-y-2 border-t border-border">
<div>
<div className="text-xs font-semibold uppercase text-destructive mb-1">Original (AI generated):</div>
<div className="whitespace-pre-wrap text-muted-foreground">{parts.original}</div>
</div>
{parts.corrected && (
<div>
<div className="text-xs font-semibold uppercase text-green-600 mb-1">Corrected to:</div>
<div className="whitespace-pre-wrap">{parts.corrected}</div>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title="Delete this correction?"
body="The AI will no longer apply this edit in future notes."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── Audio Backups (server-stored, 24h TTL) ──────────────────
function AudioBackupsCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [deleteTarget, setDeleteTarget] = useState<AudioBackupRow | null>(null);
const { data, isLoading } = useQuery<AudioBackupsListOk>({
queryKey: ['audio-backups'],
queryFn: () => api.get<AudioBackupsListOk>('/api/audio-backups'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/audio-backups/' + id),
onSuccess: () => {
setMsg({ text: 'Backup deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['audio-backups'] });
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
function playBackup(id: number) {
// Server streams the decompressed audio. Open in a new tab; browser
// picks the default audio player.
window.open('/api/audio-backups/' + id + '/audio', '_blank', 'noopener,noreferrer');
}
return (
<section className={card} data-testid="audio-backups-section">
<h3 className="text-base font-semibold">Audio Backups</h3>
<p className="text-sm text-muted-foreground">
Recordings are automatically backed up on the server and kept for 24 hours. Retry flow ports with the recording components.
</p>
{isLoading && <div className="text-sm text-muted-foreground">Loading</div>}
<div className="space-y-2">
{data && data.backups.length === 0 && (
<div className="text-sm text-muted-foreground">No audio backups.</div>
)}
{data?.backups.map((b) => {
const sizeKb = Math.round(b.size_bytes / 1024);
const comp = b.compressed_bytes ? ' (' + Math.round(b.compressed_bytes / 1024) + ' KB compressed)' : '';
return (
<div
key={b.id}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
data-testid={'audio-backup-row-' + b.id}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{b.module} recording</div>
<div className="text-xs text-muted-foreground">
{new Date(b.created_at).toLocaleString()} · {sizeKb} KB{comp} · {timeAgo(b.created_at)}
</div>
</div>
<button
type="button"
className={btnPrimary + ' text-xs'}
onClick={() => playBackup(b.id)}
>
Play
</button>
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={() => setDeleteTarget(b)}
data-testid={'btn-audio-delete-' + b.id}
>
Delete
</button>
</div>
);
})}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title="Delete this audio backup?"
body="This cannot be undone."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── Saved Encounters (auto-deleted after 7 days) ────────────
function SavedEncountersCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [deleteTarget, setDeleteTarget] = useState<SavedEncounterRow | null>(null);
const { data, isLoading } = useQuery<SavedEncountersListOk>({
queryKey: ['saved-encounters'],
queryFn: () => api.get<SavedEncountersListOk>('/api/encounters/saved'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/encounters/saved/' + id),
onSuccess: () => {
setMsg({ text: 'Deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['saved-encounters'] });
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
return (
<section className={card} data-testid="saved-encounters-section">
<h3 className="text-base font-semibold">Saved Encounters</h3>
<p className="text-sm text-muted-foreground">
Encounters are automatically deleted after 7 days per site policy. Resume action ports with the encounter components.
</p>
{isLoading && <div className="text-sm text-muted-foreground">Loading</div>}
<div className="space-y-2">
{data && data.encounters.length === 0 && (
<div className="text-sm text-muted-foreground">No saved encounters.</div>
)}
{data?.encounters.map((enc) => {
const date = new Date(enc.updated_at).toLocaleDateString();
const expires = new Date(enc.expires_at).toLocaleDateString();
const preview = (enc.transcript_preview || '').slice(0, 80);
return (
<div
key={enc.id}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
data-testid={'enc-row-' + enc.id}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium truncate">{enc.label || 'Untitled'}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{enc.enc_type}
</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{date} · expires {expires}{preview ? ' · ' + preview + '…' : ''}
</div>
</div>
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={() => setDeleteTarget(enc)}
data-testid={'btn-enc-delete-' + enc.id}
>
Delete
</button>
</div>
);
})}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title="Delete this saved encounter?"
body={deleteTarget?.label || undefined}
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── Compliance (static info card) ───────────────────────────
function ComplianceCard() {
return (
<section className={card} data-testid="compliance-section">
<h3 className="text-base font-semibold">Compliance & Usage</h3>
<div className="text-sm space-y-2">
<p>
<strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.
</p>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>All connections use HTTPS/TLS encryption</li>
<li>Authentication with optional 2FA</li>
<li>No patient data stored on server beyond session</li>
<li>AWS Bedrock supports BAA for HIPAA compliance</li>
<li>Azure OpenAI supports BAA for HIPAA compliance</li>
</ul>
<p>
<strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.
</p>
</div>
</section>
);
}
// ── Page shell ───────────────────────────────────────────────
export default function Settings() {
const { data: me, isLoading, error } = useQuery<MeOk>({
@ -965,6 +1771,19 @@ export default function Settings() {
{/* Integrations — shown to all users, not gated by canLocalAuth. */}
{me && <NextcloudCard user={me.user} />}
{me && <DocumentsCard />}
{/* Voice + transcription */}
{me && <VoicePreferencesCard />}
{me && <BrowserWhisperCard />}
{me && <WebSpeechCard />}
{/* Personal content */}
{me && <TemplatesCard />}
{me && <CorrectionsCard />}
{me && <AudioBackupsCard />}
{me && <SavedEncountersCard />}
<ComplianceCard />
</div>
);
}

View file

@ -250,6 +250,70 @@ 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[];
}
// ── Extensions (pagers/directory) ────────────────────────────
export interface Extension {
id: number;

View file

@ -0,0 +1,85 @@
// ============================================================
// SETTINGS (React port) — Voice + Content sub-sections.
//
// Commit 3 of the Settings port. Covers the eight remaining
// sub-sections so the React Settings page matches settings.html
// feature-for-feature:
// • Voice Preferences (STT / TTS selectors + Save + Preview)
// • Browser Whisper (WASM stub — localStorage-only)
// • Web Speech Recognition (localStorage-only + privacy modal)
// • My Templates (Memories CRUD)
// • AI Corrections (read-only list)
// • Audio Backups (list + delete)
// • Saved Encounters (list + delete)
// • Compliance (static info card)
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
async function openReactSettings(page) {
await page.goto(E2E_BASE + '/app/settings');
await page.waitForSelector(
'[data-testid="voice-preferences-section"], [data-testid="templates-section"], [data-testid="compliance-section"]',
{ timeout: 15000 }
);
}
test.describe('React Settings — Voice + Content sections render', () => {
test('Voice Preferences: STT / TTS selectors + Save + Preview', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="stt-model-select"]')).toBeVisible();
await expect(page.locator('[data-testid="tts-voice-select"]')).toBeVisible();
await expect(page.locator('[data-testid="btn-save-voice-prefs"]')).toBeVisible();
await expect(page.locator('[data-testid="btn-preview-voice"]')).toBeVisible();
});
test('Browser Whisper card: enable toggle + model select', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="browser-whisper-enabled"]')).toBeVisible();
await expect(page.locator('[data-testid="browser-whisper-model"]')).toBeVisible();
await expect(page.locator('[data-testid="browser-whisper-status"]')).toBeVisible();
});
test('Web Speech: checkbox shows privacy confirm modal before enabling', async ({ authedPage: _, page }) => {
let nativeDialogFired = false;
page.on('dialog', async (d) => { nativeDialogFired = true; await d.dismiss(); });
await openReactSettings(page);
const checkbox = page.locator('[data-testid="web-speech-enabled"]');
await expect(checkbox).toBeVisible();
const isDisabled = await checkbox.isDisabled();
if (!isDisabled) {
await checkbox.check();
await expect(page.locator('[data-testid="confirm-modal-cancel"]')).toBeVisible();
await page.click('[data-testid="confirm-modal-cancel"]');
}
expect(nativeDialogFired).toBe(false);
});
test('Templates: inputs + save button present', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="mem-category"]')).toBeVisible();
await expect(page.locator('[data-testid="mem-name"]')).toBeVisible();
await expect(page.locator('[data-testid="mem-content"]')).toBeVisible();
await expect(page.locator('[data-testid="btn-mem-save"]')).toBeVisible();
});
test('Templates: saving without name shows inline error', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await page.click('[data-testid="btn-mem-save"]');
await expect(page.getByText('Enter a template name')).toBeVisible();
});
test('Corrections card renders (list may be empty)', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="corrections-section"]')).toBeVisible();
});
test('Audio Backups + Saved Encounters + Compliance render', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="audio-backups-section"]')).toBeVisible();
await expect(page.locator('[data-testid="saved-encounters-section"]')).toBeVisible();
await expect(page.locator('[data-testid="compliance-section"]')).toBeVisible();
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-DdnCNlZ0.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BT9JgnfU.css">
<script type="module" crossorigin src="/app/assets/index-DG4q33Hv.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DiAauNJX.css">
</head>
<body>
<div id="root"></div>

View file

@ -250,6 +250,70 @@ 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[];
}
// ── Extensions (pagers/directory) ────────────────────────────
export interface Extension {
id: number;