Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
777 changes: 562 additions & 215 deletions apps/web/app/(dashboard)/(main)/home/page.tsx

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions apps/web/components/home/biomarker-panel-card.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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 (
<Link
href={`/labs/${metricCode}`}
className={cn(
"card flex flex-col gap-2 p-4 min-w-0",
"hover:border-accent-200 transition-all cursor-pointer",
)}
>
{/* Row 1: name + trend delta */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={cn(
"size-[6px] shrink-0 rounded-full",
statusDotColor[status],
)}
/>
<span className="text-[12px] font-medium tracking-[0.02em] text-neutral-500 font-display truncate uppercase">
{name}
</span>
</div>
{trendDelta !== null && (
<span
className={cn(
"text-[11px] font-mono font-medium shrink-0",
trendImproving === true
? "text-[var(--color-health-normal)]"
: trendImproving === false
? "text-[var(--color-health-warning)]"
: "text-neutral-400",
)}
>
{formatDelta(trendDelta)}
</span>
)}
</div>

{/* Row 2: value + sparkline */}
<div className="flex items-end justify-between gap-2">
<div className="flex items-baseline gap-1">
<span
className={cn(
"text-[22px] font-medium tracking-[-0.03em] font-display",
statusValueColor[status] ?? statusValueColor.normal,
)}
>
{Number.isInteger(value) ? value : value.toFixed(1)}
</span>
<span className="text-[11px] text-neutral-400 font-mono">{unit}</span>
</div>
<MiniSparkline
data={sparkData}
color={sparkColor[status] ?? sparkColor.normal!}
width={80}
height={24}
/>
</div>

{/* Row 3: range info */}
<span className="text-[10px] text-neutral-400 font-mono truncate">
{optimalRange}
</span>
</Link>
);
}
37 changes: 37 additions & 0 deletions apps/web/components/home/empty-metric-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
href={`/labs/${metricCode}`}
className={cn(
"card flex flex-col gap-2 p-4 min-w-0 border-dashed",
"hover:border-accent-200 transition-all cursor-pointer",
)}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="size-[6px] shrink-0 rounded-full bg-neutral-200" />
<span className="text-[12px] font-medium tracking-[0.02em] text-neutral-400 font-display truncate uppercase">
{name}
</span>
</div>
<span className="text-[14px] font-medium text-neutral-300 font-display">
No data yet
</span>
<span className="text-[10px] text-neutral-400 font-mono truncate">
{reason}
</span>
</Link>
);
}
8 changes: 5 additions & 3 deletions apps/web/components/home/feature-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand All @@ -19,6 +19,8 @@ interface LabsPreviewContentProps {
}

export function LabsPreviewContent({ items }: LabsPreviewContentProps) {
const { getStatus, isAbnormal: isObsAbnormal } = useDynamicStatus();

if (items.length === 0) {
return <p className="text-[13px] text-neutral-400 font-body">No lab results yet</p>;
}
Expand All @@ -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
Expand All @@ -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) => {
Expand Down
78 changes: 78 additions & 0 deletions apps/web/components/home/panel-section-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-[15px] font-medium font-display tracking-[-0.02em] text-neutral-900">
{label}
</h2>
<div className="flex items-center gap-2">
{totalTested > 0 && (
<span className="text-[11px] font-mono text-neutral-500">
{inRangeCount}/{totalTested} in range
</span>
)}
{untestedCount > 0 && (
<span className="text-[11px] font-mono text-neutral-300">
{totalTested === 0
? `0/${totalMetrics} tested`
: `· ${untestedCount} untested`}
</span>
)}
</div>
</div>
{/* Progress bar — full width, untested shown as striped pattern */}
<div className="flex h-1.5 w-full overflow-hidden rounded-full">
{inRangeCount > 0 && (
<div
className="bg-[var(--color-health-normal)]"
style={{ width: `${(inRangeCount / totalMetrics) * 100}%` }}
/>
)}
{warningCount > 0 && (
<div
className="bg-[var(--color-health-warning)]"
style={{ width: `${(warningCount / totalMetrics) * 100}%` }}
/>
)}
{criticalCount > 0 && (
<div
className="bg-[var(--color-health-critical)]"
style={{ width: `${(criticalCount / totalMetrics) * 100}%` }}
/>
)}
{/* Untested: striped pattern */}
{untestedCount > 0 && (
<div
className="flex-1"
style={{
backgroundImage:
"repeating-linear-gradient(135deg, var(--color-neutral-200) 0px, var(--color-neutral-200) 2px, transparent 2px, transparent 5px)",
}}
/>
)}
{/* Nothing tested at all */}
{totalTested === 0 && <div className="flex-1 bg-neutral-100" />}
</div>
</div>
);
}
Loading