// pages.jsx — Transcribe + Settings, wired to the real backend. // // Transcribe flow: // Setup → Recording → Processing → Review & Save // Setup picks a real patient (or adds a new one) and a template; Start // creates a backend session and begins MediaRecorder. Stop ends the session // (per CLAUDE.md gotcha #8: stop recorder → end session → upload audio), // uploads the blob, polls for the generated note, and shows transcript + // clinical note for review/save. const { useState: useStateX, useEffect: useEffectX, useRef: useRefX, useMemo: useMemoX } = React; const TX_FALLBACK_TEMPLATES = [ { id: "soap", nameKey: "tx.templates.soap.name", descriptionKey: "tx.templates.soap.desc", fallbackName: "SOAP Note", fallbackDescription: "Subjective · Objective · Assessment · Plan" }, { id: "progress", nameKey: "tx.templates.progress.name", descriptionKey: "tx.templates.progress.desc", fallbackName: "Progress Note", fallbackDescription: "Chief complaint · HPI · ROS · Plan" }, { id: "referral", nameKey: "tx.templates.referral.name", descriptionKey: "tx.templates.referral.desc", fallbackName: "Referral Letter", fallbackDescription: "Patient summary · reason · request" }, { id: "procedure", nameKey: "tx.templates.procedure.name", descriptionKey: "tx.templates.procedure.desc", fallbackName: "Procedure Note", fallbackDescription: "Indication · procedure · findings" }, ]; const TX_LANGUAGES = [ { id: "auto", labelKey: "tx.lang.auto", fallback: "Auto detect" }, { id: "en", labelKey: "tx.lang.en", fallback: "English" }, { id: "es", labelKey: "tx.lang.es", fallback: "Spanish" }, { id: "es-419", labelKey: "tx.lang.es419", fallback: "Spanish (Latin America)" }, { id: "es-ES", labelKey: "tx.lang.esES", fallback: "Spanish (Spain)" }, { id: "ca", labelKey: "tx.lang.ca", fallback: "Catalan" }, { id: "hi-IN", labelKey: "tx.lang.hi", fallback: "Hindi" }, { id: "bn-IN", labelKey: "tx.lang.bn", fallback: "Bengali" }, { id: "ta-IN", labelKey: "tx.lang.ta", fallback: "Tamil" }, { id: "te-IN", labelKey: "tx.lang.te", fallback: "Telugu" }, { id: "kn-IN", labelKey: "tx.lang.kn", fallback: "Kannada" }, { id: "ml-IN", labelKey: "tx.lang.ml", fallback: "Malayalam" }, { id: "mr-IN", labelKey: "tx.lang.mr", fallback: "Marathi" }, { id: "gu-IN", labelKey: "tx.lang.gu", fallback: "Gujarati" }, { id: "pa-IN", labelKey: "tx.lang.pa", fallback: "Punjabi" }, { id: "od-IN", labelKey: "tx.lang.od", fallback: "Odia" }, ]; function txTemplateName(t, tmpl) { if (!tmpl) return ""; return (tmpl.nameKey && t(tmpl.nameKey)) || tmpl.name || tmpl.label || tmpl.fallbackName || tmpl.id; } function txTemplateDescription(t, tmpl) { if (!tmpl) return ""; return (tmpl.descriptionKey && t(tmpl.descriptionKey)) || tmpl.description || tmpl.desc || tmpl.fallbackDescription || t("tx.templateHelp"); } function pickMimeType() { if (typeof MediaRecorder === "undefined") return null; const candidates = [ "audio/webm;codecs=opus", "audio/webm", "audio/mp4;codecs=mp4a.40.2", "audio/mp4", ]; for (const m of candidates) { try { if (MediaRecorder.isTypeSupported(m)) return m; } catch (_) {} } return ""; } function txLanguageLabel(t, lang) { return (lang.labelKey && t(lang.labelKey)) || lang.fallback || lang.id; } function defaultTxLanguage() { if (window.MSStore && window.MSStore.getStoredDefaultEncounterLanguage) { return window.MSStore.getStoredDefaultEncounterLanguage(); } return "auto"; } // ── Step 1: Setup (patient + language + template) ──────────────────────────── function TxSetup({ onStart }) { const store = window.MSStore.useStore(); const t = window.MSStore.useT(); const [lang] = window.MSStore.useLang(); const [patient, setPatient] = useStateX(null); const [templateId, setTemplateId] = useStateX("soap"); const [language, setLanguage] = useStateX(defaultTxLanguage); const [patSearch, setPatSearch] = useStateX(""); const [addingNew, setAddingNew] = useStateX(false); const [newPat, setNewPat] = useStateX({ first: "", last: "", dob: "", sex: "F", phone: "", email: "" }); const setNP = (k, v) => setNewPat(p => ({ ...p, [k]: v })); const [adding, setAdding] = useStateX(false); const [addErr, setAddErr] = useStateX(""); useEffectX(() => { if (window.MSStore && window.MSStore.getStoredDefaultEncounterLanguage) { setLanguage(window.MSStore.getStoredDefaultEncounterLanguage()); return; } setLanguage(current => current === "en" || current === "es" ? (lang === "es" ? "es" : "en") : current); }, [lang]); const todayPats = useMemoX(() => { const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today.getTime() + 86400000); const seen = new Set(); const out = []; for (const a of (store.appointments || [])) { const t = new Date(a.scheduledAt); if (isNaN(t) || t < today || t >= tomorrow || !a.patientName) continue; const key = a.patientName.toLowerCase(); if (seen.has(key)) continue; seen.add(key); const p = (store.patients || []).find(p => `${p.first} ${p.last}`.toLowerCase() === key) || { id: a.patientName, first: a.patientName.split(" ")[0], last: a.patientName.split(" ").slice(1).join(" "), avatar: (a.patientName || "?").split(" ").map(s => s[0]).slice(0, 2).join("").toUpperCase(), phone: a.raw && a.raw.patient_phone || "", email: a.raw && a.raw.patient_email || "", }; out.push({ ...p, _appt: a }); } return out; }, [store.appointments, store.patients]); const searchResults = useMemoX(() => { if (!patSearch) return []; const ql = patSearch.toLowerCase(); return (store.patients || []) .filter(p => `${p.first} ${p.last}`.toLowerCase().includes(ql) || (p.phone || "").includes(patSearch) || (p.email || "").toLowerCase().includes(ql)) .slice(0, 6); }, [patSearch, store.patients]); const newPatValid = newPat.first.trim() && newPat.last.trim(); const tmplList = (store.templates && store.templates.length ? store.templates : TX_FALLBACK_TEMPLATES); async function quickAddPatient() { if (!newPatValid || adding) return; setAdding(true); setAddErr(""); try { const fullName = `${newPat.first.trim()} ${newPat.last.trim()}`; let age = ""; if (newPat.dob) { const d = new Date(newPat.dob); if (!isNaN(d)) age = String(Math.floor((Date.now() - d.getTime()) / (365.25 * 86400000))); } await store.createPatient({ name: fullName, age, phone: newPat.phone, email: newPat.email }); setPatient({ id: fullName, first: newPat.first.trim(), last: newPat.last.trim(), avatar: ((newPat.first[0] || "") + (newPat.last[0] || "")).toUpperCase(), age, phone: newPat.phone, email: newPat.email, }); setAddingNew(false); setNewPat({ first: "", last: "", dob: "", sex: "F", phone: "", email: "" }); } catch (e) { setAddErr(e && e.message || t("tx.errors.addPatient")); } finally { setAdding(false); } } return (
{/* Patient column */}
{t("tx.step1")}
{t("tx.selectPatient")}
{!addingNew ? ( <> {!patSearch && todayPats.length > 0 && (
{t("common.today")}
{todayPats.map(p => { const sel = patient && patient.id === p.id; return ( ); })}
)}
setPatSearch(e.target.value)} placeholder={t("tx.searchPatients")}/> {patSearch && }
{patSearch && (
{searchResults.map(p => { const sel = patient && patient.id === p.id; return ( ); })} {searchResults.length === 0 &&
{t("tx.noPatients")}
}
)} ) : (
setNP("first", e.target.value)} placeholder={t("auth.firstName")}/>
setNP("last", e.target.value)} placeholder={t("auth.lastName")}/>
setNP("dob", e.target.value)}/>
setNP("phone", e.target.value)}/>
{addErr &&
{addErr}
}
)}
{patient && (
{patient.avatar}
{patient.first} {patient.last}
)}
{/* Template column */}
{t("tx.step2")}
{t("tx.configureEncounter")}
{(() => { const selected = tmplList.find(tmpl => tmpl.id === templateId) || tmplList[0] || {}; return (
{txTemplateDescription(t, selected)}
); })()}
{!patient &&
{t("tx.selectPatientFirst")}
}
); } // ── Step 2: Recording (real MediaRecorder) ─────────────────────────────────── function TxRecording({ patient, onStop, onCancel }) { const t = window.MSStore.useT(); const [elapsed, setElapsed] = useStateX(0); const [stopped, setStopped] = useStateX(false); const [paused, setPaused] = useStateX(false); const [error, setError] = useStateX(""); const [level, setLevel] = useStateX(0); const recRef = useRefX(null); const chunksRef = useRefX([]); const streamRef = useRefX(null); const audioCtxRef = useRefX(null); const rafRef = useRefX(null); const pausedRef = useRefX(false); useEffectX(() => { let cancelled = false; (async () => { try { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error(t("tx.cantRecord")); } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; } streamRef.current = stream; try { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (AudioCtx) { const audioCtx = new AudioCtx(); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.78; const source = audioCtx.createMediaStreamSource(stream); source.connect(analyser); audioCtxRef.current = audioCtx; const data = new Uint8Array(analyser.fftSize); const tick = () => { if (pausedRef.current) { setLevel(0); rafRef.current = requestAnimationFrame(tick); return; } analyser.getByteTimeDomainData(data); let sum = 0; for (let i = 0; i < data.length; i++) { const centered = (data[i] - 128) / 128; sum += centered * centered; } const rms = Math.sqrt(sum / data.length); const normalized = Math.min(1, Math.max(0, (rms - 0.012) * 7.5)); setLevel(prev => prev * 0.72 + normalized * 0.28); rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); } } catch (_) { // Recording still works without the analyser; CSS keeps a fallback pulse. } const mime = pickMimeType(); const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream); recRef.current = rec; rec.addEventListener("dataavailable", (e) => { if (e.data && e.data.size > 0) chunksRef.current.push(e.data); }); rec.start(); } catch (e) { const raw = (e && (e.name || e.message)) || ""; const micMissing = /notfound|not found|devicesnotfound/i.test(raw); const micDenied = /notallowed|permission|denied/i.test(raw); setError(micMissing ? t("tx.micNotFound") : micDenied ? t("tx.micDenied") : t("tx.couldNotStart")); } })(); const id = setInterval(() => setElapsed(e => pausedRef.current ? e : e + 1), 1000); return () => { cancelled = true; clearInterval(id); if (rafRef.current) cancelAnimationFrame(rafRef.current); try { audioCtxRef.current && audioCtxRef.current.close(); } catch (_) {} try { recRef.current && recRef.current.state !== "inactive" && recRef.current.stop(); } catch (_) {} const s = streamRef.current; if (s) s.getTracks().forEach(t => { try { t.stop(); } catch (_) {} }); }; }, []); const fmt = s => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; function finalize() { if (stopped) return; setStopped(true); pausedRef.current = false; setPaused(false); const rec = recRef.current; if (!rec) { onCancel(t("tx.errors.noRecorder")); return; } const finishWithBlob = () => { const type = rec.mimeType || "audio/webm"; const blob = new Blob(chunksRef.current, { type }); // Stop the mic. const s = streamRef.current; if (s) s.getTracks().forEach(t => { try { t.stop(); } catch (_) {} }); if (rafRef.current) cancelAnimationFrame(rafRef.current); try { audioCtxRef.current && audioCtxRef.current.close(); } catch (_) {} setLevel(0); onStop(blob, elapsed); }; if (rec.state === "inactive") { finishWithBlob(); } else { rec.addEventListener("stop", finishWithBlob, { once: true }); try { rec.stop(); } catch (_) { finishWithBlob(); } } } function togglePause() { if (stopped) return; const rec = recRef.current; if (!rec || rec.state === "inactive") return; try { if (pausedRef.current || rec.state === "paused") { if (rec.state === "paused") rec.resume(); pausedRef.current = false; setPaused(false); } else if (rec.state === "recording") { rec.pause(); pausedRef.current = true; setPaused(true); setLevel(0); } } catch (_) { setError(t("tx.errors.pause")); } } if (error) { return (
{error}
); } const isVoiceActive = !paused && level > 0.16; const orbStyle = { width: 200, height: 200, "--voice-level": level.toFixed(3), }; const bars = Array.from({length: 32}).map((_, i) => { const phase = Math.abs(Math.sin(i * 0.62)); const boosted = Math.min(1, 0.18 + level * (0.7 + phase * 0.55)); return isVoiceActive ? { height: `${22 + boosted * 74}%`, opacity: 0.48 + Math.min(0.52, level * 1.4), transform: `scaleY(${0.45 + boosted * 0.75})`, } : { height: "2px", opacity: 0.5, transform: "scaleY(1)", }; }); return (
{patient.avatar}
{patient.first} {patient.last} {patient.age && {patient.age}}
{paused && {t("tx.paused")}}
{bars.map((style, i) => ( ))}
{fmt(elapsed)}
{stopped ? t("tx.stopping") : paused ? t("tx.paused") : t("tx.tapToStop")}
); } // ── Step 3: Processing — uploads, ends session, polls for note ─────────────── function TxProcessing({ patient, blob, templateId, language, onReady, onError }) { const t = window.MSStore.useT(); const [detail, setDetail] = useStateX(t("tx.creatingSession")); const [progress, setProgress] = useStateX(8); const ranRef = useRefX(false); const store = window.MSStore.useStore(); useEffectX(() => { if (ranRef.current) return; ranRef.current = true; let cancelled = false; (async () => { let sessionId = null; try { // 1. Start session. setDetail(t("tx.creatingSession")); setProgress(14); const fullName = `${patient.first} ${patient.last}`.trim(); const session = await store.startSession({ patient_name: fullName, patient_phone: patient.phone || "", patient_email: patient.email || "", language: language || "auto", }); if (cancelled) return; sessionId = session && (session.id || session.session_id); if (!sessionId) throw new Error(t("tx.errors.createSession")); // 2. End session BEFORE uploading audio (CLAUDE.md gotcha #8: the // backend rejects upload if the session is ended after upload). setDetail(t("tx.endingSession")); setProgress(34); try { await store.endSession(sessionId); } catch (e) { throw new Error(t("tx.errors.endSession") + ": " + (e && e.message || e)); } if (cancelled) return; // 3. Upload audio — the backend transcribes (sync) and then queues // clinical-note generation in the background. We do NOT call // /summarize separately because /upload-audio already enqueues it // when transcripts exist; calling /summarize again races and errors // with "No transcript available" if Deepgram returned 0 segments. if (!blob || blob.size === 0) { throw new Error(t("tx.errors.noAudio")); } setDetail(t("tx.uploadingSized", { size: (blob.size / 1024 / 1024).toFixed(1) })); setProgress(58); try { await store.uploadAudio(sessionId, blob, templateId); } catch (e) { throw new Error(t("tx.errors.upload") + ": " + (e && e.message || e)); } if (cancelled) return; // 4. Show the transcript immediately. Note generation continues in the // background and the review screen refreshes itself when ready. setDetail(t("tx.loadingTranscript")); setProgress(86); let sessionFull = null; try { sessionFull = await store.getSession(sessionId); } catch (_) {} if (cancelled) return; if (!sessionFull) throw new Error(t("tx.errors.loadSession")); setProgress(100); setTimeout(() => onReady({ sessionId, session: sessionFull, note: sessionFull.clinical_note || null }), 250); } catch (e) { if (cancelled) return; onError(e && e.message ? e.message : t("tx.errors.process"), sessionId); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{detail}
{progress}%
{t("tx.processingHint")}
); } // ── Step 4: Review & Save ──────────────────────────────────────────────────── function TxReview({ patient, sessionId, session, note, onDone, onSaved }) { const store = window.MSStore.useStore(); const t = window.MSStore.useT(); const [sessionData, setSessionData] = useStateX(session || null); const [noteText, setNoteText] = useStateX((note && note.note_text) || ""); const [noteReady, setNoteReady] = useStateX(!!(note && note.note_text)); const [ignoreNoteCreatedAt, setIgnoreNoteCreatedAt] = useStateX(""); const [addingMore, setAddingMore] = useStateX(false); const [appendPending, setAppendPending] = useStateX(false); const [saved, setSaved] = useStateX(false); const [pending, setPending] = useStateX(false); const [error, setError] = useStateX(""); const [previewOpen, setPreviewOpen] = useStateX(false); const [copiedNote, setCopiedNote] = useStateX(false); const [followupAppointment, setFollowupAppointment] = useStateX(null); useEffectX(() => { if (!sessionId || noteReady) return; let cancelled = false; const started = Date.now(); async function poll() { try { const fresh = await store.getSession(sessionId); if (cancelled) return; if (fresh) setSessionData(fresh); const freshNote = fresh && fresh.clinical_note; if (freshNote && freshNote.note_text && freshNote.created_at !== ignoreNoteCreatedAt) { setNoteText(freshNote.note_text); setNoteReady(true); setAppendPending(false); return; } } catch (_) {} if (!cancelled && Date.now() - started < 120_000) setTimeout(poll, 3000); if (!cancelled && Date.now() - started >= 120_000) setAppendPending(false); } const id = setTimeout(poll, 3000); return () => { cancelled = true; clearTimeout(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, noteReady, ignoreNoteCreatedAt]); const transcripts = (sessionData && sessionData.transcripts) || []; const followupText = window.MSFollowupTextFromSession ? window.MSFollowupTextFromSession(sessionData, noteText) : ""; async function handleSave() { if (!noteText.trim() || pending) return; setError(""); setPending(true); try { await store.saveClinicalNote(sessionId, noteText); const followupRes = await store.ensureFollowupDraft(sessionId); const followupRaw = followupRes && followupRes.appointment; if (followupRaw) { setFollowupAppointment({ scheduledAt: followupRaw.scheduled_at, reason: followupRaw.reason || "", }); } if (onSaved) onSaved(); setSaved(true); } catch (e) { setError(e && e.message || t("tx.errors.saveNote")); } finally { setPending(false); } } function handleCopyNote() { if (!noteText.trim()) return; window.copyClinicalNoteText(noteText, () => { setCopiedNote(true); setTimeout(() => setCopiedNote(false), 1600); }); } async function handleAddMore(blob) { if (!blob || blob.size === 0 || appendPending) { setAddingMore(false); setError(t("tx.errors.noAudio")); return; } const previousNote = sessionData && sessionData.clinical_note; setAddingMore(false); setAppendPending(true); setError(""); setIgnoreNoteCreatedAt((previousNote && previousNote.created_at) || ""); setNoteReady(false); setNoteText(""); try { const template = (previousNote && previousNote.template_used) || "soap"; await store.uploadAudio(sessionId, blob, template); const fresh = await store.getSession(sessionId); if (fresh) setSessionData(fresh); } catch (e) { setAppendPending(false); setError(e && e.message || t("tx.errors.upload")); } } const NotePreviewModal = window.ClinicalNotePreviewModal; if (saved) { return (
{t("tx.saved")}
{patient.first} {patient.last}
{followupText && (
Follow-up captured
{followupText}
)} {followupAppointment ? (
Follow-up added to calendar
{new Date(followupAppointment.scheduledAt).toLocaleString()} · {followupAppointment.reason || "Follow-up visit"}
) : (
No follow-up calendar item was detected for this note.
)}
); } return (
{t("tx.transcript")}
{patient.first} {patient.last} · {t("tx.transcriptCount", { n: transcripts.length })}
{addingMore && (
{t("tx.addMore")}
{t("tx.addMoreHint")}
setAddingMore(false)}/>
)} {appendPending && (
{t("tx.addMoreProcessing")}
)}
{transcripts.length === 0 && (
{t("tx.transcriptEmpty")}
)} {transcripts.map((seg, i) => { const speaker = seg.speaker_label || (i % 2 === 0 ? "Provider" : "Patient"); const isProvider = String(speaker).toLowerCase().includes("provider") || String(speaker).toLowerCase().startsWith("doctor") || String(speaker) === "0"; const text = seg.corrected_text || seg.auto_corrected_text || seg.text || ""; return (
{isProvider ? "Dr" : (patient.avatar || "Pt")}
{isProvider ? t("tx.provider") : t("tx.patient")}
{text}
); })}
{t("tx.note.title")}
{noteReady ? t("tx.note.aiSub") : t("tx.note.pending")}
{followupText && (
Follow-up captured
{followupText}
)}