-
Notifications
You must be signed in to change notification settings - Fork 2
feat: dynamic health status + canonical reference ranges + DB migrations #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -9,13 +9,13 @@ import { | |||||
| } from "@/components/health/status-badge"; | ||||||
| import { TrendChart } from "@/components/health/trend-chart"; | ||||||
| import { | ||||||
| deriveStatus, | ||||||
| deriveOptimalStatus, | ||||||
| formatRange, | ||||||
| } from "@/lib/health-utils"; | ||||||
| import { useDynamicStatus } from "@/hooks/use-dynamic-status"; | ||||||
| import { cn, formatDate, formatObsValue, isDurationMetric } from "@/lib/utils"; | ||||||
| import { DataTable, type DataTableColumn } from "@/components/data-table"; | ||||||
| import { Pill, TrendingUp, TrendingDown, Minus } from "lucide-react"; | ||||||
| import { Pill, TrendingUp, TrendingDown, Minus, Pencil, Trash2, Check, X } from "lucide-react"; | ||||||
|
|
||||||
| const TIME_RANGES = [ | ||||||
| { key: "3m", label: "3M", months: 3 }, | ||||||
|
|
@@ -77,6 +77,7 @@ export default function LabDetailPage({ | |||||
| params: Promise<{ metricCode: string }>; | ||||||
| }) { | ||||||
| const { metricCode } = use(params); | ||||||
| const { getStatus, getRanges } = useDynamicStatus(); | ||||||
|
|
||||||
| const { data, isLoading } = trpc.observations.list.useQuery({ | ||||||
| metricCode, | ||||||
|
|
@@ -88,10 +89,26 @@ export default function LabDetailPage({ | |||||
| metricCode, | ||||||
| }); | ||||||
| const { data: medsData } = trpc.medications.list.useQuery({}); | ||||||
| const displayPrecision = | ||||||
| metricsData?.find((m) => m.id === metricCode)?.displayPrecision ?? null; | ||||||
| const utils = trpc.useUtils(); | ||||||
| const correctMutation = trpc.observations.correct.useMutation({ | ||||||
| onSuccess: () => utils.observations.list.invalidate({ metricCode }), | ||||||
| }); | ||||||
| const deleteMutation = trpc.observations.delete.useMutation({ | ||||||
| onSuccess: () => utils.observations.list.invalidate({ metricCode }), | ||||||
| }); | ||||||
| const [editingId, setEditingId] = useState<string | null>(null); | ||||||
| const [editValue, setEditValue] = useState(""); | ||||||
| const [editNote, setEditNote] = useState(""); | ||||||
| const metricDef = metricsData?.find((m) => m.id === metricCode); | ||||||
| const displayPrecision = metricDef?.displayPrecision ?? null; | ||||||
| const showOptimal = prefsData?.showOptimalRanges ?? true; | ||||||
| const optimalRange = optimalRangesData?.[metricCode] ?? null; | ||||||
| // Get canonical ranges from the dynamic status hook | ||||||
| const canonRanges = getRanges(metricCode); | ||||||
| // Display the active range used for status (optimal first, then reference) | ||||||
| const activeRangeLow = canonRanges?.optimalLow ?? canonRanges?.referenceLow; | ||||||
| const activeRangeHigh = canonRanges?.optimalHigh ?? canonRanges?.referenceHigh; | ||||||
| const canonicalRange = formatRange(activeRangeLow, activeRangeHigh, metricDef?.unit); | ||||||
|
|
||||||
| const items = data?.items ?? []; | ||||||
|
|
||||||
|
|
@@ -145,13 +162,14 @@ export default function LabDetailPage({ | |||||
| header: "Value", | ||||||
| width: "0.8fr", | ||||||
| cell: (obs) => { | ||||||
| const obsStatus = deriveStatus(obs); | ||||||
| const obsStatus = getStatus(obs); | ||||||
| const isAbn = obsStatus !== "normal"; | ||||||
| return ( | ||||||
| <div className="flex items-baseline gap-1.5"> | ||||||
| <span | ||||||
| className={cn( | ||||||
| "text-[15px] font-semibold tracking-[-0.01em] font-mono tabular-nums", | ||||||
| obs.isAbnormal | ||||||
| isAbn | ||||||
| ? obsStatus === "critical" | ||||||
| ? "text-health-critical" | ||||||
| : "text-health-warning" | ||||||
|
|
@@ -176,15 +194,17 @@ export default function LabDetailPage({ | |||||
| }, | ||||||
| { | ||||||
| id: "range", | ||||||
| header: "Ref. Range", | ||||||
| header: "Lab Ref.", | ||||||
| width: "1fr", | ||||||
| cell: (obs) => ( | ||||||
| <div className="text-xs text-neutral-400 font-mono"> | ||||||
| {formatRange( | ||||||
| obs.referenceRangeLow, | ||||||
| obs.referenceRangeHigh, | ||||||
| obs.unit, | ||||||
| )} | ||||||
| <div> | ||||||
| <div className="text-[11px] text-neutral-400 font-mono"> | ||||||
| {formatRange( | ||||||
| obs.referenceRangeLow, | ||||||
| obs.referenceRangeHigh, | ||||||
| obs.unit, | ||||||
| )} | ||||||
| </div> | ||||||
| </div> | ||||||
| ), | ||||||
| }, | ||||||
|
|
@@ -193,8 +213,8 @@ export default function LabDetailPage({ | |||||
| header: "Status", | ||||||
| width: "0.8fr", | ||||||
| cell: (obs) => { | ||||||
| const obsStatus = deriveStatus(obs); | ||||||
| return obs.isAbnormal ? ( | ||||||
| const obsStatus = getStatus(obs); | ||||||
| return obsStatus !== "normal" ? ( | ||||||
| <StatusBadge | ||||||
| status={obsStatus} | ||||||
| label={obsStatus === "critical" ? "High" : "Abnormal"} | ||||||
|
|
@@ -205,27 +225,40 @@ export default function LabDetailPage({ | |||||
| }, | ||||||
| }, | ||||||
| { | ||||||
| id: "source", | ||||||
| header: "Source", | ||||||
| width: "0.8fr", | ||||||
| id: "actions", | ||||||
| header: "", | ||||||
| width: "90px", | ||||||
| cell: (obs) => ( | ||||||
| <div className="text-[11px] text-neutral-400 font-mono truncate"> | ||||||
| {obs.status === "corrected" | ||||||
| ? "Corrected" | ||||||
| : obs.status === "confirmed" | ||||||
| ? "Confirmed" | ||||||
| : "Extracted"} | ||||||
| <div className="flex items-center justify-end gap-1"> | ||||||
| <button | ||||||
| onClick={() => { | ||||||
| setEditingId(obs.id); | ||||||
| setEditValue(obs.valueNumeric != null ? String(obs.valueNumeric) : ""); | ||||||
| setEditNote(""); | ||||||
| }} | ||||||
| className="rounded-md p-1 text-neutral-300 transition-all hover:bg-neutral-100 hover:text-neutral-500" | ||||||
| title="Edit" | ||||||
| > | ||||||
| <Pencil className="h-3 w-3" /> | ||||||
| </button> | ||||||
| <button | ||||||
| onClick={() => deleteMutation.mutate({ id: obs.id })} | ||||||
| className="rounded-md p-1 text-neutral-300 transition-all hover:bg-red-50 hover:text-red-500" | ||||||
| title="Remove" | ||||||
| > | ||||||
| <Trash2 className="h-3 w-3" /> | ||||||
| </button> | ||||||
| </div> | ||||||
| ), | ||||||
| }, | ||||||
| ], | ||||||
| [metricCode, displayPrecision], | ||||||
| [metricCode, displayPrecision, deleteMutation], | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| ); | ||||||
|
|
||||||
| const latest = sorted[0]; | ||||||
| const previous = sorted[1]; | ||||||
| const metricName = formatMetricName(metricCode); | ||||||
| const status: HealthStatus = latest ? deriveStatus(latest) : "neutral"; | ||||||
| const status: HealthStatus = latest ? getStatus(latest) : "neutral"; | ||||||
|
|
||||||
| const [timeRange, setTimeRange] = useState<TimeRangeKey>("all"); | ||||||
|
|
||||||
|
|
@@ -298,7 +331,8 @@ export default function LabDetailPage({ | |||||
| ); | ||||||
| } | ||||||
|
|
||||||
| const refRange = formatRange( | ||||||
| // Use canonical range for the summary card (consistent standard) | ||||||
| const refRange = canonicalRange !== "—" ? canonicalRange : formatRange( | ||||||
| latest?.referenceRangeLow, | ||||||
| latest?.referenceRangeHigh, | ||||||
| latest?.unit, | ||||||
|
|
@@ -372,7 +406,11 @@ export default function LabDetailPage({ | |||||
| } | ||||||
| /> | ||||||
| <SummaryCard label="Total tests" value={String(items.length)} /> | ||||||
| <SummaryCard label="Reference range" value={refRange} /> | ||||||
| <SummaryCard | ||||||
| label={optimalRange ? "Active range" : "Reference range"} | ||||||
| value={refRange} | ||||||
| subtext={optimalRange ? "Optimal" : undefined} | ||||||
| /> | ||||||
| {showOptimal && optimalRange && ( | ||||||
| <SummaryCard | ||||||
| label="Optimal range" | ||||||
|
|
@@ -478,11 +516,64 @@ export default function LabDetailPage({ | |||||
| rowConfig={{ | ||||||
| getRowKey: (obs) => obs.id, | ||||||
| getRowTint: (obs) => | ||||||
| obs.isAbnormal | ||||||
| getStatus(obs) !== "normal" | ||||||
| ? "bg-health-warning-bg/40" | ||||||
| : undefined, | ||||||
| }} | ||||||
| /> | ||||||
|
|
||||||
| {/* Inline edit overlay */} | ||||||
| {editingId && ( | ||||||
| <div className="card mt-2 border-accent-200 bg-accent-50/30 p-4"> | ||||||
| <div className="flex flex-wrap items-end gap-3"> | ||||||
| <label className="flex flex-col gap-1"> | ||||||
| <span className="text-[10px] font-semibold uppercase tracking-[0.06em] text-neutral-400 font-mono"> | ||||||
| Value | ||||||
| </span> | ||||||
| <input | ||||||
| type="number" | ||||||
| value={editValue} | ||||||
| onChange={(e) => setEditValue(e.target.value)} | ||||||
| className="w-28 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[13px] text-neutral-900 focus:border-accent-300 focus:outline-none focus:ring-2 focus:ring-accent-100 transition-all" | ||||||
| /> | ||||||
| </label> | ||||||
| <label className="flex flex-col gap-1"> | ||||||
| <span className="text-[10px] font-semibold uppercase tracking-[0.06em] text-neutral-400 font-mono"> | ||||||
| Note | ||||||
| </span> | ||||||
| <input | ||||||
| type="text" | ||||||
| value={editNote} | ||||||
| onChange={(e) => setEditNote(e.target.value)} | ||||||
| placeholder="Reason for correction" | ||||||
| className="w-48 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[13px] text-neutral-900 placeholder:text-neutral-400 focus:border-accent-300 focus:outline-none focus:ring-2 focus:ring-accent-100 transition-all" | ||||||
| /> | ||||||
| </label> | ||||||
| <button | ||||||
| onClick={async () => { | ||||||
| await correctMutation.mutateAsync({ | ||||||
| id: editingId, | ||||||
| ...(editValue !== "" && { valueNumeric: Number(editValue) }), | ||||||
| ...(editNote !== "" && { correctionNote: editNote }), | ||||||
| }); | ||||||
| setEditingId(null); | ||||||
| }} | ||||||
| disabled={correctMutation.isPending} | ||||||
| className="flex items-center gap-1.5 rounded-lg bg-accent-600 px-4 py-2 text-[13px] font-medium text-white shadow-sm hover:bg-accent-700 transition-colors disabled:opacity-50" | ||||||
| > | ||||||
| <Check className="h-3 w-3" /> | ||||||
| Save | ||||||
| </button> | ||||||
| <button | ||||||
| onClick={() => setEditingId(null)} | ||||||
| className="flex items-center gap-1.5 rounded-lg border border-neutral-200 px-4 py-2 text-[13px] font-medium text-neutral-600 shadow-sm hover:bg-neutral-50 transition-colors" | ||||||
| > | ||||||
| <X className="h-3 w-3" /> | ||||||
| Cancel | ||||||
| </button> | ||||||
| </div> | ||||||
| </div> | ||||||
| )} | ||||||
| </div> | ||||||
| ); | ||||||
| } | ||||||
|
|
@@ -493,6 +584,7 @@ function MetricContext({ | |||||
| metricName, | ||||||
| }: { | ||||||
| observations: Array<{ | ||||||
| metricCode: string; | ||||||
| valueNumeric?: number | null; | ||||||
| isAbnormal?: boolean | null; | ||||||
| observedAt: string | Date; | ||||||
|
|
@@ -507,6 +599,7 @@ function MetricContext({ | |||||
| }>; | ||||||
| metricName: string; | ||||||
| }) { | ||||||
| const { isAbnormal: isObsAbnormal } = useDynamicStatus(); | ||||||
| if (observations.length < 2) return null; | ||||||
|
|
||||||
| const oldest = new Date(observations[observations.length - 1]!.observedAt).getTime(); | ||||||
|
|
@@ -533,7 +626,7 @@ function MetricContext({ | |||||
| const secondAvg = secondHalf.reduce((s, v) => s + v, 0) / secondHalf.length; | ||||||
| const changePct = firstAvg !== 0 ? ((secondAvg - firstAvg) / Math.abs(firstAvg)) * 100 : 0; | ||||||
|
|
||||||
| const latestAbnormal = observations[0]?.isAbnormal; | ||||||
| const latestAbnormal = observations[0] ? isObsAbnormal(observations[0]) : null; | ||||||
| const trendDirection = Math.abs(changePct) < 3 ? 'stable' : changePct > 0 ? 'rising' : 'falling'; | ||||||
|
|
||||||
| if (overlapping.length === 0 && trendDirection === 'stable') return null; | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { useMemo } from 'react'; | ||
| import { trpc } from '@/lib/trpc/client'; | ||
| import { | ||
| deriveStatus, | ||
| isValueAbnormal, | ||
| type CanonicalRanges, | ||
| } from '@/lib/health-utils'; | ||
| import type { HealthStatus } from '@/components/health/status-badge'; | ||
|
|
||
| /** | ||
| * Hook that provides dynamic health status calculations using current optimal | ||
| * and reference ranges. Replaces static `obs.isAbnormal` and bare `deriveStatus(obs)` | ||
| * calls so that changes to ranges update the UI instantly. | ||
| */ | ||
| export function useDynamicStatus() { | ||
| const { data: metricsData, isLoading: metricsLoading } = | ||
| trpc.metrics.list.useQuery(); | ||
| const { data: optimalRangesData, isLoading: optimalLoading } = | ||
| trpc.optimalRanges.forUser.useQuery(); | ||
|
|
||
| const isLoading = metricsLoading || optimalLoading; | ||
|
|
||
| const rangesMap = useMemo(() => { | ||
| const map = new Map<string, CanonicalRanges>(); | ||
| const metricDefs = metricsData ?? []; | ||
|
|
||
| // Seed from metric definitions (reference ranges) | ||
| for (const def of metricDefs) { | ||
| map.set(def.id, { | ||
| optimalLow: optimalRangesData?.[def.id]?.rangeLow ?? null, | ||
| optimalHigh: optimalRangesData?.[def.id]?.rangeHigh ?? null, | ||
| referenceLow: def.referenceRangeLow ?? null, | ||
| referenceHigh: def.referenceRangeHigh ?? null, | ||
| }); | ||
| } | ||
|
|
||
| // Include optimal ranges for metrics not in definitions | ||
| if (optimalRangesData) { | ||
| for (const code of Object.keys(optimalRangesData)) { | ||
| if (!map.has(code)) { | ||
| map.set(code, { | ||
| optimalLow: optimalRangesData[code]?.rangeLow ?? null, | ||
| optimalHigh: optimalRangesData[code]?.rangeHigh ?? null, | ||
| referenceLow: null, | ||
| referenceHigh: null, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return map; | ||
| }, [metricsData, optimalRangesData]); | ||
|
|
||
| /** Derive the display HealthStatus for an observation. */ | ||
| function getStatus(obs: { | ||
| metricCode: string; | ||
| valueNumeric?: number | null; | ||
| isAbnormal?: boolean | null; | ||
| referenceRangeLow?: number | null; | ||
| referenceRangeHigh?: number | null; | ||
| }): HealthStatus { | ||
| const ranges = rangesMap.get(obs.metricCode); | ||
| return deriveStatus(obs, ranges); | ||
| } | ||
|
|
||
| /** Check whether an observation value is abnormal (true/false/null). */ | ||
| function isAbnormal(obs: { | ||
| metricCode: string; | ||
| valueNumeric?: number | null; | ||
| isAbnormal?: boolean | null; | ||
| referenceRangeLow?: number | null; | ||
| referenceRangeHigh?: number | null; | ||
| }): boolean | null { | ||
| const ranges = rangesMap.get(obs.metricCode); | ||
| if (ranges) { | ||
| return isValueAbnormal(obs.valueNumeric, ranges); | ||
| } | ||
| // Fall back to stored value when no ranges available | ||
| return obs.isAbnormal ?? null; | ||
| } | ||
|
|
||
| /** Get the canonical ranges for a specific metric code. */ | ||
| function getRanges(metricCode: string): CanonicalRanges | undefined { | ||
| return rangesMap.get(metricCode); | ||
| } | ||
|
|
||
| return { getStatus, isAbnormal, getRanges, isLoading }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Trash2 button fires deleteMutation.mutate directly on click with no confirmation step. The server-side delete procedure does a hard ctx.db.delete with no soft-delete or recycle bin. A single misclick irrecoverably removes a health observation. Consider adding a window.confirm or inline confirm state before triggering the mutation.