diff --git a/apps/web/app/(dashboard)/(main)/home/page.tsx b/apps/web/app/(dashboard)/(main)/home/page.tsx index 50083ba..81d2397 100644 --- a/apps/web/app/(dashboard)/(main)/home/page.tsx +++ b/apps/web/app/(dashboard)/(main)/home/page.tsx @@ -1,48 +1,80 @@ -'use client'; - -import { useMemo } from 'react'; -import { trpc } from '@/lib/trpc/client'; -import { useSession } from '@/lib/auth/client'; -import { deriveStatus } from '@/lib/health-utils'; -import { GreetingHeader } from '@/components/home/greeting-header'; -import { OnboardingChecklist, type ChecklistItem } from '@/components/home/onboarding-checklist'; -import { DashboardStats } from '@/components/home/dashboard-stats'; -import { AttentionMetrics, type AttentionMetric } from '@/components/home/attention-metrics'; -import { CategoryOverview } from '@/components/home/category-overview'; -import { UpcomingRetests, type RetestItem } from '@/components/home/upcoming-retests'; -import { HealthInsights, generateInsights } from '@/components/home/health-insights'; -import { HealthScore, calculateHealthScore } from '@/components/home/health-score'; -import { AdherenceSummary } from '@/components/home/adherence-summary'; -import { FeaturePreviewCard } from '@/components/home/feature-preview-card'; +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { trpc } from "@/lib/trpc/client"; +import { useSession } from "@/lib/auth/client"; +import { useDynamicStatus } from "@/hooks/use-dynamic-status"; +import { + formatRange, + isTrendImproving, + deriveStatus, +} from "@/lib/health-utils"; +import { PANELS } from "@/lib/panel-config"; +import { GreetingHeader } from "@/components/home/greeting-header"; +import { + OnboardingChecklist, + type ChecklistItem, +} from "@/components/home/onboarding-checklist"; +import { BiomarkerPanelCard } from "@/components/home/biomarker-panel-card"; +import { EmptyMetricCard } from "@/components/home/empty-metric-card"; +import { PanelSectionHeader } from "@/components/home/panel-section-header"; +import { WhatChanged, type ChangeItem } from "@/components/home/what-changed"; +import { + HealthScore, + calculateHealthScore, +} from "@/components/home/health-score"; +import { DashboardStats } from "@/components/home/dashboard-stats"; +import { + HealthInsights, + generateInsights, +} from "@/components/home/health-insights"; +import { + AttentionMetrics, + type AttentionMetric, +} from "@/components/home/attention-metrics"; import { - LabsPreviewContent, - MedicationsPreviewContent, - UploadsPreviewContent, - AIChatPreviewContent, -} from '@/components/home/feature-cards'; -import { TestTubes, Pill, Upload, MessageSquare, ListChecks, HeartPulse, FileText } from 'lucide-react'; + UpcomingRetests, + type RetestItem, +} from "@/components/home/upcoming-retests"; +import { + Upload, + Pill, + HeartPulse, + ListChecks, + FileText, + MessageSquare, + Sparkles, +} from "lucide-react"; export default function HomePage() { const { data: session } = useSession(); - const observations = trpc.observations.list.useQuery({ limit: 200 }); + const { + getStatus, + isAbnormal: isObsAbnormal, + getRanges, + } = useDynamicStatus(); + const observations = trpc.observations.list.useQuery({ limit: 500 }); const medications = trpc.medications.list.useQuery({}); const importJobs = trpc.importJobs.list.useQuery({ limit: 20 }); - const retests = trpc.testing['retest.getRecommendations'].useQuery(undefined, { - enabled: (observations.data?.items?.length ?? 0) > 0, - }); const metricDefs = trpc.metrics.list.useQuery(undefined, { enabled: (observations.data?.items?.length ?? 0) > 0, }); const conditionsQuery = trpc.conditions.list.useQuery(); + const retests = trpc.testing["retest.getRecommendations"].useQuery( + undefined, + { enabled: (observations.data?.items?.length ?? 0) > 0 }, + ); - const isLoading = observations.isLoading || medications.isLoading || importJobs.isLoading; + const isLoading = + observations.isLoading || medications.isLoading || importJobs.isLoading; const obsItems = observations.data?.items ?? []; const medItems = medications.data?.items ?? []; const jobItems = importJobs.data?.items ?? []; - const retestItems = retests.data ?? []; const metricDefsList = metricDefs.data ?? []; const condItems = conditionsQuery.data ?? []; + const retestItems = retests.data ?? []; const hasData = obsItems.length > 0; // Build metric name lookup @@ -54,78 +86,67 @@ export default function HomePage() { return map; }, [metricDefsList]); - // Aggregate stats - const stats = useMemo(() => { - const metricCodes = new Set(obsItems.map((o) => o.metricCode)); - let flaggedCount = 0; - let criticalCount = 0; - let warningCount = 0; - let normalCount = 0; - - // Group by metric to get latest observation per metric - const byMetric = new Map(); + // Group observations by metric (sorted newest first) + const byMetric = useMemo(() => { + const map = new Map(); for (const obs of obsItems) { - const existing = byMetric.get(obs.metricCode) ?? []; + const existing = map.get(obs.metricCode) ?? []; existing.push(obs); - byMetric.set(obs.metricCode, existing); + map.set(obs.metricCode, existing); } + for (const [, arr] of map) { + arr.sort( + (a, b) => + new Date(b.observedAt).getTime() - new Date(a.observedAt).getTime(), + ); + } + return map; + }, [obsItems]); + + // Aggregate stats for health score + summary + const stats = useMemo(() => { + let normalCount = 0; + let warningCount = 0; + let criticalCount = 0; - // Count flagged/critical based on latest per metric for (const [, metricObs] of byMetric) { - const sorted = [...metricObs].sort( - (a, b) => new Date(b.observedAt).getTime() - new Date(a.observedAt).getTime(), - ); - const latest = sorted[0]!; - const status = deriveStatus(latest); - if (status === 'critical') { - criticalCount++; - flaggedCount++; - } else if (status === 'warning') { - warningCount++; - flaggedCount++; - } else { - normalCount++; - } + const latest = metricObs[0]!; + const status = getStatus(latest); + if (status === "critical") criticalCount++; + else if (status === "warning") warningCount++; + else normalCount++; } - const activeMeds = medItems.filter((m) => m.isActive).length; - const retestsDue = retestItems.filter( - (r) => r.urgency === 'overdue' || r.urgency === 'due_soon', - ).length; + return { normalCount, warningCount, criticalCount }; + }, [byMetric, getStatus]); - return { - metricCount: metricCodes.size, - flaggedCount, - criticalCount, - warningCount, - normalCount, - activeMedCount: activeMeds, - retestsDueCount: retestsDue, - byMetric, - }; - }, [obsItems, medItems, retestItems]); + const healthScore = calculateHealthScore( + stats.normalCount, + stats.warningCount, + stats.criticalCount, + ); - // Attention metrics (flagged, sorted by severity) + // Attention metrics (top flagged) const attentionMetrics = useMemo(() => { const result: AttentionMetric[] = []; const now = Date.now(); - for (const [code, metricObs] of stats.byMetric) { - const sorted = [...metricObs].sort( - (a, b) => new Date(b.observedAt).getTime() - new Date(a.observedAt).getTime(), - ); - const latest = sorted[0]!; - const status = deriveStatus(latest); - if (status === 'normal') continue; + for (const [code, metricObs] of byMetric) { + const latest = metricObs[0]!; + const status = getStatus(latest); + if (status === "normal") continue; - const sparkData = sorted.slice(0, 8).reverse().map((o) => o.valueNumeric ?? 0); + const sparkData = metricObs + .slice(0, 8) + .reverse() + .map((o) => o.valueNumeric ?? 0); const daysSinceTest = Math.floor( (now - new Date(latest.observedAt).getTime()) / (1000 * 60 * 60 * 24), ); result.push({ metricCode: code, - metricName: metricNameMap.get(code) ?? code.replace(/_/g, ' '), + metricName: metricNameMap.get(code) ?? code.replace(/_/g, " "), latestValue: latest.valueNumeric ?? null, unit: latest.unit ?? null, status, @@ -134,42 +155,30 @@ export default function HomePage() { }); } - // Sort: critical first, then warning - return result.sort((a, b) => { - const order = { critical: 0, warning: 1, normal: 2, info: 3, neutral: 4 }; - return (order[a.status] ?? 4) - (order[b.status] ?? 4); - }); - }, [stats.byMetric, metricNameMap]); - - // Category stats - const categoryStats = useMemo(() => { - const catMap = new Map(); - - for (const [code, metricObs] of stats.byMetric) { - const sorted = [...metricObs].sort( - (a, b) => new Date(b.observedAt).getTime() - new Date(a.observedAt).getTime(), - ); - const latest = sorted[0]!; - const category = latest.category ?? 'other'; - const status = deriveStatus(latest); - - const existing = catMap.get(category) ?? { total: 0, normal: 0, warning: 0, critical: 0 }; - existing.total++; - if (status === 'critical') existing.critical++; - else if (status === 'warning') existing.warning++; - else existing.normal++; - catMap.set(category, existing); - } - - return Array.from(catMap.entries()) - .map(([category, data]) => ({ category, ...data })) - .sort((a, b) => (b.warning + b.critical) - (a.warning + a.critical) || b.total - a.total); - }, [stats.byMetric]); - - // Retest items for dashboard + return result + .sort((a, b) => { + const order = { + critical: 0, + warning: 1, + normal: 2, + info: 3, + neutral: 4, + }; + return (order[a.status] ?? 4) - (order[b.status] ?? 4); + }) + .slice(0, 5); + }, [byMetric, metricNameMap, getStatus]); + + // Retest items — tested within last 3 years + prevention gaps (never_tested) + const MAX_RETEST_AGE_DAYS = 365 * 3; const upcomingRetests = useMemo(() => { return retestItems - .filter((r) => !r.isPaused) + .filter( + (r) => + !r.isPaused && + (r.urgency === "never_tested" || + r.daysSinceLastTest < MAX_RETEST_AGE_DAYS), + ) .map((r) => ({ metricCode: r.metricCode, metricName: r.metricName, @@ -177,147 +186,485 @@ export default function HomePage() { dueInDays: r.dueInDays, daysSinceLastTest: r.daysSinceLastTest, healthStatus: r.healthStatus, + preventionPanel: r.preventionPanel, + preventionWhy: r.preventionWhy, })); }, [retestItems]); + // Auto-calculate derived metrics (HOMA-IR = glucose × insulin / 405) + const calculatedMetrics = useMemo(() => { + const map = new Map(); + const glucoseObs = byMetric.get("glucose"); + const insulinObs = byMetric.get("insulin"); + + if (glucoseObs && insulinObs) { + const latestGlucose = glucoseObs.find((o) => o.valueNumeric != null); + const latestInsulin = insulinObs.find((o) => o.valueNumeric != null); + if (latestGlucose?.valueNumeric && latestInsulin?.valueNumeric) { + const homaIr = + (latestGlucose.valueNumeric * latestInsulin.valueNumeric) / 405; + map.set("homa_ir", { + value: Math.round(homaIr * 100) / 100, + unit: "", + }); + } + } + return map; + }, [byMetric]); + + // Panel data: filled metrics + empty metrics + const panelData = useMemo(() => { + return PANELS.map((panel) => { + type FilledMetric = { + type: "filled"; + metricCode: string; + name: string; + value: number; + unit: string; + sparkData: number[]; + trendDelta: number | null; + trendImproving: boolean | null; + optimalRange: string; + status: "normal" | "warning" | "critical" | "info" | "neutral"; + }; + type EmptyMetric = { + type: "empty"; + metricCode: string; + name: string; + reason: string; + }; + + const allMetrics: (FilledMetric | EmptyMetric)[] = []; + let inRangeCount = 0; + let panelWarningCount = 0; + let panelCriticalCount = 0; + let totalTested = 0; + + for (const metricDef of panel.metrics) { + const code = metricDef.code; + // Check primary code and aliases for observations + const codesToCheck = [code, ...(metricDef.aliases ?? [])]; + let metricObs: typeof obsItems | undefined; + let resolvedCode = code; + for (const c of codesToCheck) { + const obs = byMetric.get(c); + if (obs && obs.length > 0) { + metricObs = obs; + resolvedCode = c; + break; + } + } + + // Check calculated metrics as fallback (e.g., HOMA-IR) + const calculated = calculatedMetrics.get(code); + + if ((!metricObs || metricObs.length === 0) && !calculated) { + allMetrics.push({ + type: "empty", + metricCode: code, + name: metricNameMap.get(code) ?? code.replace(/_/g, " "), + reason: metricDef.reason, + }); + continue; + } + + // Use calculated value if no observations + if ((!metricObs || metricObs.length === 0) && calculated) { + const ranges = getRanges(code); + const calcStatus = deriveStatus( + { valueNumeric: calculated.value }, + ranges, + ); + totalTested++; + if (calcStatus === "normal") inRangeCount++; + else if (calcStatus === "warning") panelWarningCount++; + else if (calcStatus === "critical") panelCriticalCount++; + + const hasOptimal = + ranges?.optimalLow != null || ranges?.optimalHigh != null; + const rangeLabel = hasOptimal ? "optimal" : "ref"; + const optimalRange = `${rangeLabel} ${formatRange( + ranges?.optimalLow ?? ranges?.referenceLow, + ranges?.optimalHigh ?? ranges?.referenceHigh, + calculated.unit, + )}`; + allMetrics.push({ + type: "filled", + metricCode: code, + name: metricNameMap.get(code) ?? code.replace(/_/g, " "), + value: calculated.value, + unit: calculated.unit, + sparkData: [calculated.value], + trendDelta: null, + trendImproving: null, + optimalRange, + status: calcStatus, + }); + continue; + } + + // At this point metricObs is guaranteed non-empty (empty cases handled above) + const validObs = metricObs ?? []; + const latestWithValue = validObs.find((o) => o.valueNumeric != null); + if (!latestWithValue) { + allMetrics.push({ + type: "empty", + metricCode: code, + name: metricNameMap.get(code) ?? code.replace(/_/g, " "), + reason: metricDef.reason, + }); + continue; + } + + const latest = latestWithValue; + const value = latest.valueNumeric!; + + totalTested++; + const status = getStatus(latest); + if (status === "normal") inRangeCount++; + else if (status === "warning") panelWarningCount++; + else if (status === "critical") panelCriticalCount++; + + const withValues = validObs.filter((o) => o.valueNumeric != null); + const previous = withValues[1]; + const sparkData = withValues + .slice(0, 8) + .reverse() + .map((o) => o.valueNumeric ?? 0); + const ranges = getRanges(resolvedCode) ?? getRanges(code); + const hasOptimal = + ranges?.optimalLow != null || ranges?.optimalHigh != null; + const rangeLabel = hasOptimal ? "optimal" : "ref"; + const optimalRange = `${rangeLabel} ${formatRange( + ranges?.optimalLow ?? ranges?.referenceLow, + ranges?.optimalHigh ?? ranges?.referenceHigh, + latest.unit, + )}`; + + let trendDelta: number | null = null; + if (previous?.valueNumeric && previous.valueNumeric !== 0) { + trendDelta = + ((value - previous.valueNumeric) / + Math.abs(previous.valueNumeric)) * + 100; + } + + const trendImproving = + trendDelta != null + ? isTrendImproving(trendDelta, ranges, value) + : null; + + allMetrics.push({ + type: "filled", + metricCode: code, + name: metricNameMap.get(code) ?? code.replace(/_/g, " "), + value, + unit: latest.unit ?? "", + sparkData, + trendDelta, + trendImproving, + optimalRange, + status, + }); + } + + return { + ...panel, + allMetrics, + inRangeCount, + panelWarningCount, + panelCriticalCount, + totalTested, + totalMetrics: panel.metrics.length, + }; + }); + }, [byMetric, metricNameMap, getStatus, getRanges, calculatedMetrics]); + // Health insights const healthInsights = useMemo(() => { if (!hasData) return []; - return generateInsights(stats.byMetric, metricNameMap); - }, [hasData, stats.byMetric, metricNameMap]); + return generateInsights(byMetric, metricNameMap); + }, [hasData, byMetric, metricNameMap]); + + // What Changed + const whatChanged = useMemo(() => { + const allDates = new Set(); + for (const obs of obsItems) { + allDates.add(new Date(obs.observedAt).toISOString().slice(0, 10)); + } + const sortedDates = [...allDates].sort().reverse(); + if (sortedDates.length < 2) + return { changes: [], previousDate: "", currentDate: "" }; + + const currentDate = sortedDates[0]!; + const previousDate = sortedDates[1]!; + const changes: ChangeItem[] = []; + + for (const [code, metricObs] of byMetric) { + const currentObs = metricObs.find( + (o) => + new Date(o.observedAt).toISOString().slice(0, 10) === currentDate, + ); + const previousObs = metricObs.find( + (o) => + new Date(o.observedAt).toISOString().slice(0, 10) === previousDate, + ); + + if (!currentObs?.valueNumeric || !previousObs?.valueNumeric) continue; + if (previousObs.valueNumeric === 0) continue; + + const pct = + ((currentObs.valueNumeric - previousObs.valueNumeric) / + Math.abs(previousObs.valueNumeric)) * + 100; + if (Math.abs(pct) < 5) continue; + + const ranges = getRanges(code); + const trendResult = isTrendImproving( + pct, + ranges, + currentObs.valueNumeric, + ); + const improved = trendResult ?? getStatus(currentObs) === "normal"; + + changes.push({ + metricCode: code, + name: metricNameMap.get(code) ?? code.replace(/_/g, " "), + oldValue: previousObs.valueNumeric, + newValue: currentObs.valueNumeric, + unit: currentObs.unit ?? "", + percentChange: pct, + improved, + }); + } + + changes.sort( + (a, b) => Math.abs(b.percentChange) - Math.abs(a.percentChange), + ); + + const fmt = (d: string) => + new Date(d).toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }); + return { + changes, + previousDate: fmt(previousDate), + currentDate: fmt(currentDate), + }; + }, [obsItems, byMetric, metricNameMap, getStatus, getRanges]); // Derive display values - const fullName = session?.user?.name ?? ''; - const firstName = fullName.split(/\s+/)[0] ?? ''; - const activeConds = condItems.filter((c) => c.status === 'active').length; + const fullName = session?.user?.name ?? ""; + const firstName = fullName.split(/\s+/)[0] ?? ""; + const metricCount = byMetric.size; + const retestsDueCount = retestItems.filter( + (r) => r.urgency === "overdue" || r.urgency === "due_soon", + ).length; const summaryParts = []; - if (hasData) summaryParts.push(`${stats.metricCount} metrics`); - if (stats.activeMedCount > 0) summaryParts.push(`${stats.activeMedCount} medications`); - if (activeConds > 0) summaryParts.push(`${activeConds} conditions`); - const summaryLine = summaryParts.length > 0 - ? summaryParts.join(' · ') - : 'Upload your first lab report to get started'; - const abnormalCount = obsItems.filter((o) => o.isAbnormal).length; - - // Onboarding checklist items + if (hasData) summaryParts.push(`${metricCount} metrics`); + if (stats.warningCount + stats.criticalCount > 0) + summaryParts.push(`${stats.warningCount + stats.criticalCount} flagged`); + if (retestsDueCount > 0) summaryParts.push(`${retestsDueCount} retests due`); + const summaryLine = + summaryParts.length > 0 + ? summaryParts.join(" · ") + : "Upload your first lab report to get started"; + const abnormalCount = obsItems.filter((o) => isObsAbnormal(o)).length; + + // Onboarding checklist const checklistItems: ChecklistItem[] = [ - { label: 'Upload a lab report', description: 'Import your lab results from any provider to start tracking your biomarkers over time.', href: '/uploads', completed: jobItems.length > 0, icon: Upload }, - { label: 'Add a medication', description: 'Track your medications and supplements so AI insights can factor in what you\'re taking.', href: '/medications', completed: medItems.length > 0, icon: Pill }, - { label: 'Track a condition', description: 'Record your health conditions and diagnoses to build a complete health picture.', href: '/conditions', completed: condItems.length > 0, icon: HeartPulse }, - { label: 'Review your biomarkers', description: 'Explore your lab results organized by category with reference ranges and trend lines.', href: '/biomarkers', completed: obsItems.length > 0, icon: ListChecks }, - { label: 'Generate a health report', description: 'Create a comprehensive health report to share with your doctor at your next visit.', href: '/reports', completed: false, icon: FileText }, - { label: 'Ask AI a question', description: 'Chat with your health data — ask about trends, get explanations, or request a summary.', href: '/ai', completed: false, icon: MessageSquare }, + { + label: "Upload a lab report", + description: "Import your lab results to start tracking biomarkers.", + href: "/uploads", + completed: jobItems.length > 0, + icon: Upload, + }, + { + label: "Add a medication", + description: "Track medications so AI insights can factor them in.", + href: "/medications", + completed: medItems.length > 0, + icon: Pill, + }, + { + label: "Track a condition", + description: "Record health conditions to build a complete picture.", + href: "/conditions", + completed: condItems.length > 0, + icon: HeartPulse, + }, + { + label: "Review your biomarkers", + description: "Explore lab results with reference ranges and trends.", + href: "/biomarkers", + completed: obsItems.length > 0, + icon: ListChecks, + }, + { + label: "Generate a health report", + description: "Create a report to share with your doctor.", + href: "/reports", + completed: false, + icon: FileText, + }, + { + label: "Ask AI a question", + description: "Chat with your health data for insights.", + href: "/ai", + completed: false, + icon: MessageSquare, + }, ]; if (isLoading) { return (
-
+
{Array.from({ length: 4 }).map((_, i) => (
))}
-
-
-
-
); } return (
+ {/* Greeting */} - {/* Onboarding checklist (shown until dismissed or complete) */} -
- + {/* Health Score + Dashboard Stats */} + {hasData && ( +
+ + m.isActive).length} + discontinuedMedCount={medItems.filter((m) => !m.isActive).length} + retestsDueCount={retestsDueCount} + overdueCount={ + retestItems.filter((r) => r.urgency === "overdue").length + } + /> +
+ )} + + {/* Quick actions */} +
+ + + Upload Blood Work + + + + Ask AI Coach +
- {hasData ? ( - <> - {/* Health score + Stats overview */} -
- - !m.isActive).length} - retestsDueCount={stats.retestsDueCount} - overdueCount={retestItems.filter((r) => r.urgency === 'overdue').length} - /> -
+ {/* Onboarding checklist (new users only) */} + {!hasData && ( +
+ +
+ )} - {/* Insights + Attention + Categories / Retests */} - {healthInsights.length > 0 && ( -
- -
+ {/* Insights */} + {healthInsights.length > 0 && ( +
+ +
+ )} + + {/* Needs Attention + Upcoming Retests (side-by-side) */} + {(attentionMetrics.length > 0 || upcomingRetests.length > 0) && ( +
+ {attentionMetrics.length > 0 && ( + + )} + {upcomingRetests.length > 0 && ( + )} +
+ )} -
-
- - -
-
- - - {/* Adherence + Quick links */} - m.isActive).map((m) => ({ id: m.id, name: m.name }))} - /> -
- - - - - - -
-
+ {/* Panel sections — always show all panels */} + {panelData.map((panel) => ( +
+ +
+ {panel.allMetrics.map((m) => + m.type === "filled" ? ( + + ) : ( + + ), + )}
- - ) : ( - /* Feature cards grid for new users */ -
- - - - - - - - - - - - - - - +
+ ))} + + {/* What Changed */} + {whatChanged.changes.length > 0 && ( +
+
)} + + {/* AI Coach Suggestions placeholder */} +
+
+
+ +

+ AI Coach Suggestions +

+
+

+ Upload your latest blood work and the AI coach will analyze trends + and suggest next steps. +

+ + Ask AI Coach → + +
+
); } diff --git a/apps/web/components/home/biomarker-panel-card.tsx b/apps/web/components/home/biomarker-panel-card.tsx new file mode 100644 index 0000000..592379a --- /dev/null +++ b/apps/web/components/home/biomarker-panel-card.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; +import { MiniSparkline } from "@/components/health/mini-sparkline"; +import { cn } from "@/lib/utils"; +import type { HealthStatus } from "@/components/health/status-badge"; + +interface BiomarkerPanelCardProps { + metricCode: string; + name: string; + value: number; + unit: string; + sparkData: number[]; + trendDelta: number | null; + trendImproving: boolean | null; + optimalRange: string; + status: HealthStatus; +} + +const statusValueColor: Record = { + normal: "text-neutral-900", + warning: "text-[var(--color-health-warning)]", + critical: "text-[var(--color-health-critical)]", + info: "text-neutral-900", + neutral: "text-neutral-600", +}; + +const sparkColor: Record = { + normal: "var(--color-accent-500)", + warning: "var(--color-health-warning)", + critical: "var(--color-health-critical)", + info: "var(--color-accent-500)", + neutral: "var(--color-neutral-400)", +}; + +const statusDotColor: Record = { + normal: "bg-[var(--color-health-normal)]", + warning: "bg-[var(--color-health-warning)]", + critical: "bg-[var(--color-health-critical)]", + info: "bg-[var(--color-health-info)]", + neutral: "bg-neutral-400", +}; + +function formatDelta(delta: number): string { + const sign = delta > 0 ? "+" : ""; + return `${sign}${Math.round(delta)}%`; +} + +export function BiomarkerPanelCard({ + metricCode, + name, + value, + unit, + sparkData, + trendDelta, + trendImproving, + optimalRange, + status, +}: BiomarkerPanelCardProps) { + return ( + + {/* Row 1: name + trend delta */} +
+
+ + + {name} + +
+ {trendDelta !== null && ( + + {formatDelta(trendDelta)} + + )} +
+ + {/* Row 2: value + sparkline */} +
+
+ + {Number.isInteger(value) ? value : value.toFixed(1)} + + {unit} +
+ +
+ + {/* Row 3: range info */} + + {optimalRange} + + + ); +} diff --git a/apps/web/components/home/empty-metric-card.tsx b/apps/web/components/home/empty-metric-card.tsx new file mode 100644 index 0000000..a4ed4f3 --- /dev/null +++ b/apps/web/components/home/empty-metric-card.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; +import { cn } from "@/lib/utils"; + +interface EmptyMetricCardProps { + metricCode: string; + name: string; + reason: string; +} + +export function EmptyMetricCard({ + metricCode, + name, + reason, +}: EmptyMetricCardProps) { + return ( + +
+ + + {name} + +
+ + No data yet + + + {reason} + + + ); +} diff --git a/apps/web/components/home/feature-cards.tsx b/apps/web/components/home/feature-cards.tsx index 455b7a9..9e0363d 100644 --- a/apps/web/components/home/feature-cards.tsx +++ b/apps/web/components/home/feature-cards.tsx @@ -2,7 +2,7 @@ import { MiniSparkline } from '@/components/health/mini-sparkline'; import { StatusBadge } from '@/components/health/status-badge'; -import { deriveStatus } from '@/lib/health-utils'; +import { useDynamicStatus } from '@/hooks/use-dynamic-status'; import { formatDate } from '@/lib/utils'; // --- Labs Preview --- @@ -19,6 +19,8 @@ interface LabsPreviewContentProps { } export function LabsPreviewContent({ items }: LabsPreviewContentProps) { + const { getStatus, isAbnormal: isObsAbnormal } = useDynamicStatus(); + if (items.length === 0) { return

No lab results yet

; } @@ -30,7 +32,7 @@ export function LabsPreviewContent({ items }: LabsPreviewContentProps) { byMetric.set(item.metricCode, existing); } - const abnormalCount = items.filter((i) => i.isAbnormal).length; + const abnormalCount = items.filter((i) => isObsAbnormal(i)).length; const metricCount = byMetric.size; // Top 3 metrics by count, abnormal first @@ -40,7 +42,7 @@ export function LabsPreviewContent({ items }: LabsPreviewContentProps) { (a, b) => new Date(b.observedAt).getTime() - new Date(a.observedAt).getTime(), ); const sparkData = sorted.slice(0, 6).reverse().map((o) => o.valueNumeric ?? 0); - const status = deriveStatus(sorted[0]!); + const status = getStatus(sorted[0]!); return { code, sparkData, status }; }) .sort((a, b) => { diff --git a/apps/web/components/home/panel-section-header.tsx b/apps/web/components/home/panel-section-header.tsx new file mode 100644 index 0000000..fd3028a --- /dev/null +++ b/apps/web/components/home/panel-section-header.tsx @@ -0,0 +1,78 @@ +import { cn } from "@/lib/utils"; + +interface PanelSectionHeaderProps { + label: string; + inRangeCount: number; + warningCount: number; + criticalCount: number; + totalTested: number; + totalMetrics: number; +} + +export function PanelSectionHeader({ + label, + inRangeCount, + warningCount, + criticalCount, + totalTested, + totalMetrics, +}: PanelSectionHeaderProps) { + const untestedCount = totalMetrics - totalTested; + + return ( +
+
+

+ {label} +

+
+ {totalTested > 0 && ( + + {inRangeCount}/{totalTested} in range + + )} + {untestedCount > 0 && ( + + {totalTested === 0 + ? `0/${totalMetrics} tested` + : `· ${untestedCount} untested`} + + )} +
+
+ {/* Progress bar — full width, untested shown as striped pattern */} +
+ {inRangeCount > 0 && ( +
+ )} + {warningCount > 0 && ( +
+ )} + {criticalCount > 0 && ( +
+ )} + {/* Untested: striped pattern */} + {untestedCount > 0 && ( +
+ )} + {/* Nothing tested at all */} + {totalTested === 0 &&
} +
+
+ ); +} diff --git a/apps/web/components/home/upcoming-retests.tsx b/apps/web/components/home/upcoming-retests.tsx index f9434d3..7578000 100644 --- a/apps/web/components/home/upcoming-retests.tsx +++ b/apps/web/components/home/upcoming-retests.tsx @@ -1,51 +1,65 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import { Clock, ChevronRight } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import Link from "next/link"; +import { Clock, ChevronRight, FlaskConical } from "lucide-react"; +import { cn } from "@/lib/utils"; export interface RetestItem { metricCode: string; metricName: string; - urgency: 'overdue' | 'due_soon' | 'upcoming' | 'on_track'; + urgency: "overdue" | "due_soon" | "upcoming" | "on_track" | "never_tested"; dueInDays: number; daysSinceLastTest: number; healthStatus: string; + preventionPanel?: string | null; + preventionWhy?: string | null; } interface UpcomingRetestsProps { items: RetestItem[]; } -const urgencyStyles: Record = { +const urgencyStyles: Record< + string, + { dot: string; text: string; label: string } +> = { overdue: { - dot: 'bg-testing-overdue', - text: 'text-testing-overdue', - label: 'OVERDUE', + dot: "bg-testing-overdue", + text: "text-testing-overdue", + label: "OVERDUE", }, due_soon: { - dot: 'bg-testing-due', - text: 'text-testing-due', - label: 'DUE SOON', + dot: "bg-testing-due", + text: "text-testing-due", + label: "DUE SOON", }, upcoming: { - dot: 'bg-testing-upcoming', - text: 'text-testing-upcoming', - label: 'UPCOMING', + dot: "bg-testing-upcoming", + text: "text-testing-upcoming", + label: "UPCOMING", }, on_track: { - dot: 'bg-testing-on-track', - text: 'text-testing-on-track', - label: 'ON TRACK', + dot: "bg-testing-on-track", + text: "text-testing-on-track", + label: "ON TRACK", + }, + never_tested: { + dot: "bg-neutral-300", + text: "text-neutral-500", + label: "GET TESTED", }, }; export function UpcomingRetests({ items }: UpcomingRetestsProps) { - // Only show actionable items (overdue, due_soon, upcoming) - const actionable = items.filter((i) => i.urgency !== 'on_track').slice(0, 5); + // Show actionable items: overdue/due_soon/upcoming first, then prevention gaps + const actionable = items.filter((i) => i.urgency !== "on_track").slice(0, 8); if (actionable.length === 0) return null; + // Split into retests and prevention gaps for visual grouping + const retests = actionable.filter((i) => i.urgency !== "never_tested"); + const gaps = actionable.filter((i) => i.urgency === "never_tested"); + return (
@@ -64,7 +78,7 @@ export function UpcomingRetests({ items }: UpcomingRetestsProps) {
- {actionable.map((item) => { + {retests.map((item) => { const style = urgencyStyles[item.urgency] ?? urgencyStyles.on_track; const dueText = item.dueInDays <= 0 @@ -73,7 +87,12 @@ export function UpcomingRetests({ items }: UpcomingRetestsProps) { return (
- +
{item.metricName} @@ -82,12 +101,49 @@ export function UpcomingRetests({ items }: UpcomingRetestsProps) { Last tested {item.daysSinceLastTest}d ago
- + {dueText}
); })} + + {/* Prevention gap separator */} + {gaps.length > 0 && retests.length > 0 && ( +
+ + + Recommended tests + +
+ )} + + {gaps.map((item) => ( +
+ +
+ + {item.metricName} + + {item.preventionPanel && ( + + {item.preventionPanel} + + )} +
+ + Get tested + +
+ ))}
); diff --git a/apps/web/components/home/what-changed.tsx b/apps/web/components/home/what-changed.tsx new file mode 100644 index 0000000..554e61f --- /dev/null +++ b/apps/web/components/home/what-changed.tsx @@ -0,0 +1,68 @@ +import { cn } from "@/lib/utils"; + +interface ChangeItem { + metricCode: string; + name: string; + oldValue: number; + newValue: number; + unit: string; + percentChange: number; + improved: boolean; +} + +interface WhatChangedProps { + changes: ChangeItem[]; + previousDate: string; + currentDate: string; +} + +export type { ChangeItem }; + +export function WhatChanged({ + changes, + previousDate, + currentDate, +}: WhatChangedProps) { + if (changes.length === 0) return null; + + return ( +
+
+

+ What Changed +

+ + {previousDate} vs {currentDate} + +
+
+ {changes.map((c) => ( +
+
+ + {c.name} + + + {c.oldValue.toFixed(1)} → {c.newValue.toFixed(1)} {c.unit} + +
+ + {c.percentChange > 0 ? "+" : ""} + {c.percentChange.toFixed(0)}% + +
+ ))} +
+
+ ); +} diff --git a/apps/web/components/testing/retest-item.tsx b/apps/web/components/testing/retest-item.tsx index c5d494d..0db5b32 100644 --- a/apps/web/components/testing/retest-item.tsx +++ b/apps/web/components/testing/retest-item.tsx @@ -15,7 +15,12 @@ import { } from "lucide-react"; import { useState, useRef, useEffect } from "react"; -type Urgency = "overdue" | "due_soon" | "upcoming" | "on_track"; +type Urgency = + | "overdue" + | "due_soon" + | "upcoming" + | "on_track" + | "never_tested"; interface RetestRecommendation { metricCode: string; @@ -23,7 +28,7 @@ interface RetestRecommendation { category: string; unit: string | null; lastValue: number | null; - lastObservedAt: string; + lastObservedAt: string | null; daysSinceLastTest: number; healthStatus: HealthStatus; optimalStatus: "optimal" | "suboptimal" | "unknown"; @@ -33,6 +38,8 @@ interface RetestRecommendation { isPaused: boolean; urgency: Urgency; dueInDays: number; + preventionPanel?: string | null; + preventionWhy?: string | null; } interface RetestItemProps { @@ -58,6 +65,10 @@ const urgencyStyles: Record = { text: "text-[var(--color-testing-on-track)]", bar: "bg-[var(--color-testing-on-track)]", }, + never_tested: { + text: "text-neutral-500", + bar: "bg-neutral-300", + }, }; function formatDueText(dueInDays: number): string { diff --git a/apps/web/lib/prevention-panels.ts b/apps/web/lib/prevention-panels.ts new file mode 100644 index 0000000..0bf0259 --- /dev/null +++ b/apps/web/lib/prevention-panels.ts @@ -0,0 +1,101 @@ +/** + * Prevention-based testing panels. + * + * Defines universal biomarker panels that everyone should track + * regardless of current health status. Based on longevity medicine + * frameworks (Attia's "4 Horsemen", Function Health, etc.) + * + * Used by the retest recommendation engine to suggest: + * 1. Retests for flagged metrics (priority 1) + * 2. Prevention panel gaps — never tested (priority 2) + * 3. Routine rechecks (priority 3) + */ + +export interface PreventionPanel { + id: string; + label: string; + frequency: string; + frequencyDays: number; + metrics: string[]; + why: string; +} + +export const PREVENTION_PANELS: PreventionPanel[] = [ + { + id: "metabolic", + label: "Metabolic Health", + frequency: "every 6 months", + frequencyDays: 180, + metrics: ["glucose", "hba1c", "insulin"], + why: "Insulin resistance is detectable 10+ years before diabetes diagnosis", + }, + { + id: "cardiovascular", + label: "Cardiovascular Risk", + frequency: "annually", + frequencyDays: 365, + metrics: [ + "ldl_cholesterol", + "hdl_cholesterol", + "triglycerides", + "apolipoprotein_b", + ], + why: "Heart disease is the #1 killer — ApoB is the best early predictor", + }, + { + id: "inflammation", + label: "Systemic Inflammation", + frequency: "annually", + frequencyDays: 365, + metrics: ["crp", "homocysteine"], + why: "Chronic inflammation drives all 4 major disease categories", + }, + { + id: "thyroid", + label: "Thyroid Function", + frequency: "annually", + frequencyDays: 365, + metrics: ["tsh", "free_t3", "free_t4"], + why: "Thyroid dysfunction affects energy, metabolism, and mood", + }, + { + id: "nutrients", + label: "Key Nutrients", + frequency: "every 6 months", + frequencyDays: 180, + metrics: ["vitamin_d", "vitamin_b12", "ferritin", "magnesium"], + why: "Deficiencies are common and easily correctable", + }, +]; + +/** + * Get all unique metric codes across all prevention panels. + */ +export function getAllPreventionMetrics(): string[] { + const codes = new Set(); + for (const panel of PREVENTION_PANELS) { + for (const code of panel.metrics) { + codes.add(code); + } + } + return [...codes]; +} + +/** + * Get the recommended retest frequency for a metric from prevention panels. + * Returns null if the metric isn't in any prevention panel. + */ +export function getPreventionFrequency( + metricCode: string, +): { frequencyDays: number; panelLabel: string; why: string } | null { + for (const panel of PREVENTION_PANELS) { + if (panel.metrics.includes(metricCode)) { + return { + frequencyDays: panel.frequencyDays, + panelLabel: panel.label, + why: panel.why, + }; + } + } + return null; +} diff --git a/apps/web/server/trpc/routers/testing.ts b/apps/web/server/trpc/routers/testing.ts index 170e3aa..dd4c37e 100644 --- a/apps/web/server/trpc/routers/testing.ts +++ b/apps/web/server/trpc/routers/testing.ts @@ -14,6 +14,10 @@ import { } from "@openvitals/database"; import { computeAge } from "@/lib/demographics"; import { deriveStatus, deriveOptimalStatus } from "@/lib/health-utils"; +import { + getAllPreventionMetrics, + getPreventionFrequency, +} from "@/lib/prevention-panels"; // Categories that are continuously measured (not lab-tested) const EXCLUDED_CATEGORIES = ["wearable", "vital_sign"]; @@ -243,13 +247,17 @@ export const testingRouter = createRouter({ if (allObs.length === 0) return []; const metricCodes = allObs.map((o) => o.metricCode); + // Also include prevention panel metrics for gap detection + const allCodes = [ + ...new Set([...metricCodes, ...getAllPreventionMetrics()]), + ]; // Get metric definitions, optimal ranges, and user overrides in parallel const [metricDefs, optRanges, userOverrides] = await Promise.all([ ctx.db .select() .from(metricDefinitions) - .where(inArray(metricDefinitions.id, metricCodes)), + .where(inArray(metricDefinitions.id, allCodes)), ctx.db .select() .from(optimalRanges) @@ -277,7 +285,32 @@ export const testingRouter = createRouter({ const now = Date.now(); - const recommendations = allObs.map((obs) => { + type Recommendation = { + metricCode: string; + metricName: string; + category: string; + unit: string | null; + lastValue: number | null; + lastObservedAt: string | null; + daysSinceLastTest: number; + healthStatus: "normal" | "warning" | "critical" | "info" | "neutral"; + optimalStatus: "optimal" | "suboptimal" | "unknown"; + recommendedIntervalDays: number; + userOverrideIntervalDays: number | null; + effectiveIntervalDays: number; + isPaused: boolean; + urgency: + | "overdue" + | "due_soon" + | "upcoming" + | "on_track" + | "never_tested"; + dueInDays: number; + preventionPanel: string | null; + preventionWhy: string | null; + }; + + const recommendations: Recommendation[] = allObs.map((obs) => { const def = defMap.get(obs.metricCode); const opt = optMap.get(obs.metricCode); const override = overrideMap.get(obs.metricCode); @@ -287,7 +320,7 @@ export const testingRouter = createRouter({ referenceRangeLow: obs.referenceRangeLow, referenceRangeHigh: obs.referenceRangeHigh, valueNumeric: obs.valueNumeric, - }); + }) as Recommendation["healthStatus"]; const optimalStatus = deriveOptimalStatus({ valueNumeric: obs.valueNumeric, @@ -319,7 +352,12 @@ export const testingRouter = createRouter({ ); const dueInDays = effectiveIntervalDays - daysSinceLastTest; - let urgency: "overdue" | "due_soon" | "upcoming" | "on_track"; + let urgency: + | "overdue" + | "due_soon" + | "upcoming" + | "on_track" + | "never_tested"; if (dueInDays <= -30) { urgency = "overdue"; } else if (dueInDays <= 0) { @@ -330,6 +368,9 @@ export const testingRouter = createRouter({ urgency = "on_track"; } + // Check if this metric is in a prevention panel + const prevention = getPreventionFrequency(obs.metricCode); + return { metricCode: obs.metricCode, metricName: def?.name ?? obs.metricCode, @@ -346,13 +387,76 @@ export const testingRouter = createRouter({ isPaused, urgency, dueInDays, + preventionPanel: prevention?.panelLabel ?? null, + preventionWhy: prevention?.why ?? null, }; }); - // Sort: overdue first, then due_soon, upcoming, on_track; within each by dueInDays asc - const urgencyOrder = { overdue: 0, due_soon: 1, upcoming: 2, on_track: 3 }; + // ── Prevention gap items (never tested but recommended) ────────── + const testedCodes = new Set(allObs.map((o) => o.metricCode)); + const preventionMetrics = getAllPreventionMetrics(); + + // Also check aliases — if user has "25_hydroxyvitamin_d", don't suggest "vitamin_d" + const ALIAS_MAP: Record = { + vitamin_d: ["25_hydroxyvitamin_d", "vitamin_d_25_hydroxyvitamin_d"], + crp: ["c_reactive_protein", "hs_crp"], + hba1c: ["hemoglobin_a1c"], + }; + + for (const code of preventionMetrics) { + // Skip if already tested (primary code or aliases) + const aliases = ALIAS_MAP[code] ?? []; + const isTested = + testedCodes.has(code) || aliases.some((a) => testedCodes.has(a)); + if (isTested) continue; + + // Skip calculated metrics (they're computed, not tested) + if (code === "homa_ir") continue; + + const def = defMap.get(code); + const prevention = getPreventionFrequency(code); + if (!prevention) continue; + + recommendations.push({ + metricCode: code, + metricName: def?.name ?? code.replace(/_/g, " "), + category: def?.category ?? "lab_result", + unit: def?.unit ?? null, + lastValue: null, + lastObservedAt: null, + daysSinceLastTest: Infinity, + healthStatus: "neutral", + optimalStatus: "unknown", + recommendedIntervalDays: prevention.frequencyDays, + userOverrideIntervalDays: null, + effectiveIntervalDays: prevention.frequencyDays, + isPaused: false, + urgency: "never_tested" as const, + dueInDays: 0, + preventionPanel: prevention.panelLabel, + preventionWhy: prevention.why, + }); + } + + // Sort: flagged retests first, then prevention gaps, then routine + // Within each group: overdue → due_soon → upcoming → on_track → never_tested + const urgencyOrder: Record = { + overdue: 0, + due_soon: 1, + upcoming: 2, + on_track: 3, + never_tested: 4, + }; recommendations.sort((a, b) => { - const groupDiff = urgencyOrder[a.urgency] - urgencyOrder[b.urgency]; + // Flagged (critical/warning) metrics always first + const aFlagged = + a.healthStatus === "critical" || a.healthStatus === "warning" ? 0 : 1; + const bFlagged = + b.healthStatus === "critical" || b.healthStatus === "warning" ? 0 : 1; + if (aFlagged !== bFlagged) return aFlagged - bFlagged; + + const groupDiff = + (urgencyOrder[a.urgency] ?? 5) - (urgencyOrder[b.urgency] ?? 5); if (groupDiff !== 0) return groupDiff; return a.dueInDays - b.dueInDays; }); diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 236c282..4e0e33d 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -40,3 +40,8 @@ export { updateSyncStatus, ensureDataSource, } from "./queries/integrations"; +export { + computeCalculatedMetrics, + CALCULATED_METRICS, + type CalculatedMetricDef, +} from "./queries/calculated-metrics"; diff --git a/packages/database/src/queries/calculated-metrics.ts b/packages/database/src/queries/calculated-metrics.ts new file mode 100644 index 0000000..df3765d --- /dev/null +++ b/packages/database/src/queries/calculated-metrics.ts @@ -0,0 +1,282 @@ +/** + * Calculated/Derived Biomarkers Engine + * + * Computes derived metrics from existing observations and stores them + * as real observations with metadataJson: { source: "calculated", formula: "..." }. + * This ensures they appear on /labs detail pages, trend charts, and reports. + * + * Key principle: inputs must be from the SAME observation date (same lab draw). + * When new lab data is ingested, the engine checks if paired inputs exist on + * the same date and computes the derived metric if so. + */ + +import { and, eq, desc, isNotNull, inArray, sql } from "drizzle-orm"; +import { observations } from "../schema/observations"; +import type { Database } from "../client"; + +// ── Formula Definitions ──────────────────────────────────────────────────────── + +export interface CalculatedMetricDef { + code: string; + name: string; + category: string; + unit: string; + inputs: string[]; + /** Calculate from input values keyed by metric code */ + calculate: (values: Record) => number | null; + /** Human-readable formula for display */ + formulaText: string; + precision: number; +} + +export const CALCULATED_METRICS: CalculatedMetricDef[] = [ + { + code: "homa_ir", + name: "HOMA-IR", + category: "metabolic", + unit: "", + inputs: ["glucose", "insulin"], + calculate: (v) => { + if (v.glucose == null || v.insulin == null) return null; + if (v.glucose === 0 || v.insulin === 0) return null; + return (v.glucose * v.insulin) / 405; + }, + formulaText: "(glucose × insulin) / 405", + precision: 2, + }, + { + code: "cholesterol_hdl_ratio", + name: "Cholesterol/HDL Ratio", + category: "lipid", + unit: "", + inputs: ["total_cholesterol", "hdl_cholesterol"], + calculate: (v) => { + if (v.total_cholesterol == null || v.hdl_cholesterol == null) return null; + if (v.hdl_cholesterol === 0) return null; + return v.total_cholesterol / v.hdl_cholesterol; + }, + formulaText: "total cholesterol / HDL cholesterol", + precision: 1, + }, + { + code: "triglyceride_hdl_ratio", + name: "Triglyceride/HDL Ratio", + category: "lipid", + unit: "", + inputs: ["triglycerides", "hdl_cholesterol"], + calculate: (v) => { + if (v.triglycerides == null || v.hdl_cholesterol == null) return null; + if (v.hdl_cholesterol === 0) return null; + return v.triglycerides / v.hdl_cholesterol; + }, + formulaText: "triglycerides / HDL cholesterol", + precision: 1, + }, + { + code: "non_hdl_cholesterol", + name: "Non-HDL Cholesterol", + category: "lipid", + unit: "mg/dL", + inputs: ["total_cholesterol", "hdl_cholesterol"], + calculate: (v) => { + if (v.total_cholesterol == null || v.hdl_cholesterol == null) return null; + return v.total_cholesterol - v.hdl_cholesterol; + }, + formulaText: "total cholesterol − HDL cholesterol", + precision: 0, + }, +]; + +// ── Code aliases for input resolution ────────────────────────────────────────── +const INPUT_ALIASES: Record = { + // After migration 0011, canonical is "total_cholesterol" + // Keep alias for safety during transition + total_cholesterol: ["total_cholesterol", "cholesterol_total"], +}; + +/** Get all metric codes (including aliases) that could be an input for any formula */ +function getAllInputCodes(): string[] { + const codes = new Set(); + for (const formula of CALCULATED_METRICS) { + for (const input of formula.inputs) { + codes.add(input); + for (const alias of INPUT_ALIASES[input] ?? []) { + codes.add(alias); + } + } + } + return [...codes]; +} + +// ── Core computation function ────────────────────────────────────────────────── + +/** + * Compute all calculable derived metrics for a user. + * + * Inputs must be from the SAME observation date (same lab draw). + * + * - If `triggerMetricCodes` is set: only evaluates formulas whose inputs overlap + * with the trigger codes, and only for dates where those triggers appear. + * - If not set (full recalculate): evaluates ALL formulas for ALL dates. + */ +export async function computeCalculatedMetrics( + db: Database, + params: { + userId: string; + /** If set, only compute for metrics whose inputs include these codes */ + triggerMetricCodes?: string[]; + /** If set, link the calculated observation to this import job */ + importJobId?: string; + /** If set, link to this source artifact */ + sourceArtifactId?: string; + }, +): Promise<{ computed: string[]; skipped: string[] }> { + const computed: string[] = []; + const skipped: string[] = []; + + // Determine which formulas to evaluate + const formulasToEvaluate = params.triggerMetricCodes + ? CALCULATED_METRICS.filter((m) => + m.inputs.some( + (input) => + params.triggerMetricCodes!.includes(input) || + INPUT_ALIASES[input]?.some((alias) => + params.triggerMetricCodes!.includes(alias), + ), + ), + ) + : CALCULATED_METRICS; + + if (formulasToEvaluate.length === 0) { + return { computed, skipped }; + } + + // Get all observations for this user with relevant metric codes + const allInputCodes = getAllInputCodes(); + const userObs = await db + .select({ + metricCode: observations.metricCode, + valueNumeric: observations.valueNumeric, + observedAt: observations.observedAt, + }) + .from(observations) + .where( + and( + eq(observations.userId, params.userId), + inArray(observations.metricCode, allInputCodes), + isNotNull(observations.valueNumeric), + ), + ) + .orderBy(desc(observations.observedAt)); + + // Group by date → metricCode → value + const byDate = new Map>(); + for (const obs of userObs) { + const dateKey = obs.observedAt.toISOString(); + if (!byDate.has(dateKey)) byDate.set(dateKey, new Map()); + const dateMap = byDate.get(dateKey)!; + // Only take the first (most recent if duplicates on same date) + if (!dateMap.has(obs.metricCode) && obs.valueNumeric != null) { + dateMap.set(obs.metricCode, obs.valueNumeric); + } + } + + // Check existing calculated observations to avoid duplicates + const existingCalc = await db + .select({ + metricCode: observations.metricCode, + observedAt: observations.observedAt, + }) + .from(observations) + .where( + and( + eq(observations.userId, params.userId), + inArray( + observations.metricCode, + formulasToEvaluate.map((f) => f.code), + ), + sql`${observations.metadataJson}->>'source' = 'calculated'`, + ), + ); + + const existingKeys = new Set( + existingCalc.map((e) => `${e.metricCode}:${e.observedAt.toISOString()}`), + ); + + // For each date, check if all inputs exist and compute + for (const [dateKey, dateMetrics] of byDate) { + const obsDate = new Date(dateKey); + + for (const formula of formulasToEvaluate) { + // Check if all inputs are present on this date + const inputValues: Record = {}; + let allFound = true; + + for (const inputCode of formula.inputs) { + const codesToCheck = [inputCode, ...(INPUT_ALIASES[inputCode] ?? [])]; + let value: number | undefined; + + for (const code of codesToCheck) { + if (dateMetrics.has(code)) { + value = dateMetrics.get(code); + break; + } + } + + if (value != null) { + inputValues[inputCode] = value; + } else { + allFound = false; + break; + } + } + + if (!allFound) continue; + + // Skip if already calculated for this date + const key = `${formula.code}:${dateKey}`; + if (existingKeys.has(key)) continue; + + // Calculate + const result = formula.calculate(inputValues); + if (result == null) continue; + + const roundedValue = + Math.round(result * 10 ** formula.precision) / 10 ** formula.precision; + + await db.insert(observations).values({ + userId: params.userId, + metricCode: formula.code, + category: formula.category, + valueNumeric: roundedValue, + valueText: String(roundedValue), + unit: formula.unit, + status: "confirmed", + confidenceScore: 1.0, + observedAt: obsDate, + importJobId: params.importJobId ?? null, + sourceArtifactId: params.sourceArtifactId ?? null, + metadataJson: { + source: "calculated", + formula: formula.code, + formulaText: formula.formulaText, + inputs: inputValues, + }, + }); + + existingKeys.add(key); + if (!computed.includes(formula.code)) { + computed.push(formula.code); + } + } + } + + // Mark formulas that were evaluated but never computed + for (const formula of formulasToEvaluate) { + if (!computed.includes(formula.code)) { + skipped.push(formula.code); + } + } + + return { computed, skipped }; +} diff --git a/services/ingestion-worker/src/steps/compute-derived.ts b/services/ingestion-worker/src/steps/compute-derived.ts new file mode 100644 index 0000000..0960033 --- /dev/null +++ b/services/ingestion-worker/src/steps/compute-derived.ts @@ -0,0 +1,39 @@ +/** + * Post-materialization step: compute derived/calculated biomarkers. + * + * After observations are inserted, this step checks if any calculated metrics + * (HOMA-IR, Cholesterol/HDL ratio, etc.) can be computed from the newly + * ingested data and stores them as real observations. + */ + +import { getDb } from "@openvitals/database/client"; +import { computeCalculatedMetrics } from "@openvitals/database"; +import type { WorkflowContext } from "../workflow"; + +export async function computeDerived( + ctx: WorkflowContext, + /** Metric codes that were just ingested — used to scope which formulas to evaluate */ + ingestedMetricCodes: string[], +): Promise { + if (ingestedMetricCodes.length === 0) return; + + const db = getDb(); + + const { computed, skipped } = await computeCalculatedMetrics(db, { + userId: ctx.userId, + triggerMetricCodes: ingestedMetricCodes, + importJobId: ctx.importJobId, + sourceArtifactId: ctx.artifactId, + }); + + if (computed.length > 0) { + console.log( + `[compute-derived] Computed ${computed.length} derived metrics: ${computed.join(", ")}`, + ); + } + if (skipped.length > 0) { + console.log( + `[compute-derived] Skipped ${skipped.length} (missing inputs): ${skipped.join(", ")}`, + ); + } +}