feat(web): labs upload, draft review, draw dashboard with history sparklines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
marcuspaico
2026-08-17 16:01:59 -07:00
parent 7e70bd1940
commit 1aa600adfc
7 changed files with 285 additions and 1 deletions

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import type { ExtractedMarker } from "@helios/shared";
import { api } from "../api";
export function LabDraft() {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const [meta, setMeta] = useState<{ filename: string; status: string; error: string | null } | null>(null);
const [collectedDate, setCollectedDate] = useState("");
const [labName, setLabName] = useState("");
const [markers, setMarkers] = useState<ExtractedMarker[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!id) return;
api.labDraft(id).then((r) => {
setMeta({ filename: r.filename, status: r.status, error: r.error });
if (r.draft) {
setCollectedDate(r.draft.collectedDate ?? "");
setLabName(r.draft.labName ?? "");
setMarkers(r.draft.markers);
}
}).catch((e) => setError(e.message));
}, [id]);
const edit = (i: number, patch: Partial<ExtractedMarker>) =>
setMarkers((m) => m.map((row, j) => (j === i ? { ...row, ...patch } : row)));
const remove = (i: number) => setMarkers((m) => m.filter((_, j) => j !== i));
const addRow = () => setMarkers((m) => [...m, { panel: null, name: "", value: "", unit: null, referenceRange: null, flagged: false }]);
const confirm = async () => {
if (!id) return;
setBusy(true);
setError(null);
try {
const { drawId } = await api.confirmDraft(id, { collectedDate, labName: labName || null, markers });
nav(`/labs/draw/${drawId}`);
} catch (e) {
setError(e instanceof Error ? e.message : "confirm failed");
} finally {
setBusy(false);
}
};
if (!meta) return <p>Loading…</p>;
return (
<section>
<h2>Review: {meta.filename}</h2>
{meta.error && <p className="error">Extraction failed: {meta.error}. You can still enter values manually below.</p>}
{meta.status !== "pending" && <p className="muted">This draft is already {meta.status}.</p>}
<p className="hint">Check every value against the PDF (<a href={`/api/labs/drafts/${id}/file`} target="_blank" rel="noreferrer">open original</a>). Nothing is saved until you confirm.</p>
<div className="row">
<label>Collected <input type="date" value={collectedDate} onChange={(e) => setCollectedDate(e.target.value)} /></label>
<label>Lab <input value={labName} onChange={(e) => setLabName(e.target.value)} placeholder="lab name" /></label>
</div>
<table className="review-table">
<thead><tr><th>Name</th><th>Value</th><th>Unit</th><th>Range</th><th>Flag</th><th /></tr></thead>
<tbody>
{markers.map((m, i) => (
<tr key={i}>
<td><input value={m.name} onChange={(e) => edit(i, { name: e.target.value })} /></td>
<td><input value={m.value} onChange={(e) => edit(i, { value: e.target.value })} /></td>
<td><input value={m.unit ?? ""} onChange={(e) => edit(i, { unit: e.target.value || null })} /></td>
<td><input value={m.referenceRange ?? ""} onChange={(e) => edit(i, { referenceRange: e.target.value || null })} /></td>
<td><input type="checkbox" checked={m.flagged} onChange={(e) => edit(i, { flagged: e.target.checked })} /></td>
<td><button className="link" onClick={() => remove(i)}>✕</button></td>
</tr>
))}
</tbody>
</table>
<button className="link" onClick={addRow}>+ add marker</button>
{error && <p className="error">{error}</p>}
{meta.status === "pending" && (
<div className="row">
<button disabled={busy || !collectedDate || markers.length === 0} onClick={confirm}>
{busy ? "Saving…" : `Confirm ${markers.length} markers`}
</button>
<button className="danger" onClick={() => id && api.discardDraft(id).then(() => nav("/labs"))}>Discard</button>
</div>
)}
</section>
);
}

61
web/src/pages/LabDraw.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { Fragment, useEffect, useState } from "react";
import { useParams } from "react-router";
import { api, type LabDrawDetail } from "../api";
import { Sparkline } from "../components/Sparkline";
export function LabDraw() {
const { id } = useParams<{ id: string }>();
const [detail, setDetail] = useState<LabDrawDetail | null>(null);
const [history, setHistory] = useState<Record<string, { display: string; canonicalUnit: string; points: { collectedAt: string; value: number }[] }>>({});
useEffect(() => {
if (id) api.labDraw(id).then(setDetail).catch(() => setDetail(null));
}, [id]);
const toggleHistory = async (key: string) => {
if (history[key]) {
setHistory((h) => { const { [key]: _, ...rest } = h; return rest; });
return;
}
const h = await api.markerHistory(key);
setHistory((prev) => ({ ...prev, [key]: h }));
};
if (!detail) return <p>Loading…</p>;
return (
<section>
<h2>{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}</h2>
{detail.panels.map((p) => (
<div key={p.panel}>
<h3 className="panel-title">{p.panel}</h3>
<table className="lab-table">
<tbody>
{p.markers.map((m) => (
<Fragment key={m.id}>
<tr className={m.flagged ? "flagged" : undefined}>
<td>
{m.analyteKey
? <button className="link" onClick={() => toggleHistory(m.analyteKey!)}>{m.name}</button>
: m.name}
</td>
<td className="num">{m.value} {m.unit ?? ""}</td>
<td className="muted">{m.referenceRange ?? ""}</td>
<td>{m.flagged ? <span className="flag">●</span> : null}</td>
</tr>
{m.analyteKey && history[m.analyteKey] && (
<tr>
<td colSpan={4}>
<Sparkline points={history[m.analyteKey].points.map((pt) => pt.value)} />
<span className="muted"> {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit}</span>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
))}
</section>
);
}

72
web/src/pages/Labs.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { api } from "../api";
export function Labs() {
const nav = useNavigate();
const fileInput = useRef<HTMLInputElement>(null);
const [draws, setDraws] = useState<Awaited<ReturnType<typeof api.labDraws>>["draws"]>([]);
const [drafts, setDrafts] = useState<Awaited<ReturnType<typeof api.labDrafts>>["drafts"]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
api.labDraws().then((r) => setDraws(r.draws)).catch((e) => setError(e.message));
api.labDrafts().then((r) => setDrafts(r.drafts.filter((d) => d.status === "pending"))).catch(() => {});
}, []);
useEffect(refresh, [refresh]);
const onFile = async (file: File) => {
setBusy(true);
setError(null);
try {
const { id } = await api.uploadLab(file);
nav(`/labs/draft/${id}`);
} catch (e) {
setError(e instanceof Error ? e.message : "upload failed");
} finally {
setBusy(false);
}
};
return (
<section>
<div className="row-between">
<h2>Labs</h2>
<button disabled={busy} onClick={() => fileInput.current?.click()}>
{busy ? "Extracting…" : "Upload lab PDF"}
</button>
<input ref={fileInput} type="file" accept="application/pdf" hidden
onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
</div>
{error && <p className="error">{error}</p>}
<p className="hint">Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.</p>
{drafts.length > 0 && (
<>
<h3>Awaiting review</h3>
<ul className="list">
{drafts.map((d) => (
<li key={d.id}>
<Link to={`/labs/draft/${d.id}`}>{d.filename}</Link>
{d.error ? <span className="error"> extraction failed</span> : <span className="muted"> {d.markerCount} markers</span>}
</li>
))}
</ul>
</>
)}
<h3>Draws</h3>
{draws.length === 0 && <p className="muted">No confirmed draws yet.</p>}
<ul className="list">
{draws.map((d) => (
<li key={d.id}>
<Link to={`/labs/draw/${d.id}`}>{d.collectedAt}</Link>
<span className="muted"> {d.labName ?? ""} · {d.markerCount} markers</span>
{d.flaggedCount > 0 && <span className="flag"> {d.flaggedCount} flagged</span>}
</li>
))}
</ul>
</section>
);
}