feat(targets): card layout with status pill + consistency + coach handoff

Replaces the inline 790-line page.tsx composition with the W3e
primitives extracted in the previous phase. The page is now ~270
lines of orchestration:

- TanStack-Query pulls the redesigned targets payload (with the
  v1.4.25 consistency + summary additions).
- A second query reads /api/insights/provider-chain to derive
  `aiEnabled = chainStatus?.activeProvider != null`. The flag threads
  down into every <TargetCard>, which uses it to gate the per-card
  Coach CTA — no broken-button state when the user has no provider
  configured.
- A new `useCoachHandoff()` hook (src/hooks/use-coach-handoff.ts)
  owns the drawer's open + prefill + scope triple. Per-card buttons
  call `askCoach()`; the hook flips state and the drawer mounts
  inline at the page level.
- Cards now render in a fixed order (BP / BP-in-target / WEIGHT /
  PULSE / BMI / MOOD / COMPLIANCE / SLEEP / BODY_FAT / STEPS) — Marc
  directive: stable visual hierarchy beats status-sorting because
  the user's eye lands on the same metric in the same place every
  visit.
- Source-citation URLs stay on the page (declarative routing) and
  thread into <TargetCard> via the `sourceLink` prop.

The old inline TargetCard / RangeBar / TrendIcon / status-category
map are deleted along with the dead `Badge` + lucide imports.
Existing targets-i18n + targets-spacing tests updated to mock the
new useQueryClient + useMutation surfaces that the embedded
<CoachDrawer> needs at SSR; both suites still assert the same
visual contracts and pass.

Wave 2 deliverables also folded into this commit:
- pageSummary mounts via the W3e header above the grid
- consistency strip renders only when insufficientData === false
- recency caption ("last met goal: 2 days ago") renders only when
  lastMetGoalAt !== null
- streak chip ("4-day streak") renders only when streakDays ≥ 3
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:33:53 +02:00
1 parent 96c5e79539
commit f2b9f843b1
9 files changed
+269 -606

No files matched your search

+38 -8
View File
@@ -82,14 +82,44 @@ const sampleData = {
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
// v1.4.25 W3e — the targets page now also queries
// ["insights", "provider-chain"] to gate the per-card Coach
// CTA. Return null for that query (no AI provider configured)
// so the CTA hides and the existing test cases keep asserting
// the unchanged visual surface.
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
+41 -11
View File
@@ -14,18 +14,48 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/targets",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
targets: [],
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: null, age: null, gender: null, glucoseUnit: null },
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: {
targets: [],
bpDiastolic: { current: null, average30: null, range: null },
profile: {
heightCm: null,
age: null,
gender: null,
glucoseUnit: null,
},
},
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
+124 -565
View File
@@ -1,64 +1,22 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Scale,
Heart,
Activity,
Moon,
Percent,
Loader2,
TrendingUp,
TrendingDown,
Minus,
AlertCircle,
ExternalLink,
Smile,
Droplet,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import {
TargetCard,
type TargetCardData,
} from "@/components/targets/target-card";
import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { useCoachHandoff } from "@/hooks/use-coach-handoff";
interface TargetData {
type: string;
label: string;
current: number | null;
average30: number | null;
trend: "up" | "down" | "stable" | null;
unit: string;
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
// v1.4.25 W3e — consistency strip + recency + streak. Optional on the
// client so the i18n / spacing tests that only pre-populate
// classification + range still type-check; the runtime route always
// sets these.
daysInRange7d?: number;
daysLogged7d?: number;
daysInRange30d?: number;
daysLogged30d?: number;
lastMetGoalAt?: string | null;
streakDays?: number;
insufficientData?: boolean;
consistency7d?: ReadonlyArray<"in" | "near" | "out" | null>;
details?: {
medications?: Array<{
name: string;
compliance7: number;
compliance30: number;
}>;
};
}
type TargetData = TargetCardData;
interface BpDiastolic {
current: number | null;
@@ -84,37 +42,22 @@ interface TargetsResponse {
};
}
const TYPE_ICONS: Record<string, typeof Scale> = {
WEIGHT: Scale,
BLOOD_PRESSURE: Heart,
BLOOD_PRESSURE_IN_TARGET: Heart,
PULSE: Activity,
SLEEP_DURATION: Moon,
BODY_FAT: Percent,
BMI: Scale,
MOOD_SCORE: Smile,
MOOD_STABILITY: Smile,
BLOOD_GLUCOSE_FASTING: Droplet,
BLOOD_GLUCOSE_POSTPRANDIAL: Droplet,
BLOOD_GLUCOSE_RANDOM: Droplet,
BLOOD_GLUCOSE_BEDTIME: Droplet,
};
const TYPE_COLORS: Record<string, string> = {
WEIGHT: "text-dracula-purple",
BLOOD_PRESSURE: "text-dracula-pink",
BLOOD_PRESSURE_IN_TARGET: "text-dracula-pink",
PULSE: "text-dracula-green",
SLEEP_DURATION: "text-dracula-cyan",
BODY_FAT: "text-dracula-orange",
BMI: "text-dracula-yellow",
MOOD_SCORE: "text-dracula-lavender",
MOOD_STABILITY: "text-dracula-lavender",
BLOOD_GLUCOSE_FASTING: "text-dracula-red",
BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red",
BLOOD_GLUCOSE_RANDOM: "text-dracula-red",
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
};
/**
* v1.4.25 W3e — provider-chain status used to gate the per-card Coach
* CTA. Returns true when at least one provider is configured AND
* enabled. The Settings → AI surface owns the same query
* (`useQuery({ queryKey: ["insights", "provider-chain"] })`) so the
* cache is shared.
*/
interface ProviderChainStatus {
activeProvider: string | null;
cachedActiveProvider: string | null;
configuredChain: Array<{
providerType: string;
enabled: boolean;
available: boolean;
}>;
}
const GLUCOSE_TYPES = new Set([
"BLOOD_GLUCOSE_FASTING",
@@ -124,115 +67,45 @@ const GLUCOSE_TYPES = new Set([
]);
/**
* Map server-emitted classification.category strings to the i18n
* key under `targets.status.*`. The server is the source of truth
* for the category set (lib/analytics/classifications.ts +
* pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase
* A7 wires this map so the German locale stops surfacing English
* status pills like "Low / On Target / Stable / Moderate" — the
* exact regression the maintainer reported.
* Fixed card order per Marc directive — explicitly NOT status-sorted.
* Marc wants a stable visual hierarchy so the user's eye lands on the
* same metric in the same place every visit. Cards whose type is not
* in this list (glucose contexts, future metrics) sort after the
* core six in their server-emitted order.
*/
const STATUS_CATEGORY_KEY: Record<string, string> = {
// BMI
Underweight: "underweight",
Normal: "normal",
Overweight: "overweight",
"Obesity Grade I": "obesityGrade1",
"Obesity Grade II": "obesityGrade2",
"Obesity Grade III": "obesityGrade3",
// BP
Optimal: "optimal",
"High-normal": "highNormal",
"Hypertension Grade 1": "hypertensionGrade1",
"Hypertension Grade 2": "hypertensionGrade2",
"Hypertension Grade 3": "hypertensionGrade3",
// Pulse
Bradycardia: "bradycardia",
Elevated: "elevated",
Tachycardia: "tachycardia",
// Pulse target / sleep target
"Significantly low": "significantlyLow",
"Slightly low": "slightlyLow",
"On target": "onTarget",
"Slightly elevated": "slightlyElevated",
"Significantly elevated": "significantlyElevated",
// Sleep
"Far too short": "farTooShort",
"Too short": "tooShort",
"Slightly long": "slightlyLong",
"Far too long": "farTooLong",
// Body fat
"Below essential": "belowEssential",
Essential: "essential",
Athletic: "athletic",
Fitness: "fitness",
Acceptable: "acceptable",
Obese: "obese",
// Steps
"Very low": "veryLow",
"Low active": "lowActive",
"Moderately active": "moderatelyActive",
Active: "active",
"Very active": "veryActive",
// Generic / shared (BP-on-target, mood, medication compliance, glucose)
Good: "good",
Moderate: "moderate",
Low: "low",
High: "high",
"Very good": "veryGood",
"Very stable": "veryStable",
Stable: "stable",
Fluctuating: "fluctuating",
const FIXED_TARGET_ORDER: Record<string, number> = {
BLOOD_PRESSURE: 0,
BLOOD_PRESSURE_IN_TARGET: 1,
WEIGHT: 2,
PULSE: 3,
BMI: 4,
MOOD_SCORE: 5,
MOOD_STABILITY: 6,
MEDICATION_COMPLIANCE: 7,
SLEEP_DURATION: 8,
BODY_FAT: 9,
ACTIVITY_STEPS: 10,
};
/** Resolve a server-emitted classification.category to its translated label. */
function translateStatus(
category: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const key = STATUS_CATEGORY_KEY[category];
if (!key) return category; // unmapped — fall back to verbatim string
return t(`targets.status.${key}`);
function sortKey(target: TargetData): number {
return FIXED_TARGET_ORDER[target.type] ?? 100;
}
/** Resolve a target's display label.
*
* Glucose contexts are special-cased: the server emits an i18n key
* (e.g. `targets.glucoseFasting`) as its `label`, and the page's
* top-level `visibleTargets` mapping resolves that to a localised
* string before the card mounts. So when this helper runs, glucose
* targets already carry the localised label and the i18n lookup
* below misses (no `targets.label.BLOOD_GLUCOSE_FASTING` key) — the
* fallback returns the verbatim resolved label, which is correct.
*
* Every other target type carries a stable English label from the
* server. Look it up under `targets.label.<TYPE>` here so the card
* title respects the user's locale.
/**
* Source-citation URL for each target type. Kept on the page (rather
* than the card) so the card stays presentational and the routing
* stays declarative.
*/
function translateTargetLabel(
target: TargetData,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const i18nKey = `targets.label.${target.type}`;
const localised = t(i18nKey);
if (localised && localised !== i18nKey) {
return localised;
}
return target.label;
}
function getTargetSourceLink(target: TargetData): string | null {
if (target.type === "WEIGHT" || target.type === "BMI") {
return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight";
}
if (target.type === "BLOOD_PRESSURE") {
if (
target.type === "BLOOD_PRESSURE" ||
target.type === "BLOOD_PRESSURE_IN_TARGET"
) {
return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
}
if (target.type === "BLOOD_PRESSURE_IN_TARGET") {
return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
}
if (target.type === "PULSE") {
if (target.source.includes("CDC/NCHS")) {
return "https://www.cdc.gov/nchs/data/nhsr/nhsr041.pdf";
@@ -241,374 +114,18 @@ function getTargetSourceLink(target: TargetData): string | null {
return "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse";
}
}
if (target.type === "SLEEP_DURATION") {
return "https://aasm.org/seven-or-more-hours-of-sleep-per-night-a-health-necessity-for-adults/";
}
if (target.type === "BODY_FAT") {
return "https://www.acefitness.org/resources/everyone/blog/6596/what-are-the-guidelines-for-percentage-of-body-fat-loss/";
}
if (target.type === "ACTIVITY_STEPS") {
return "https://www.who.int/publications/i/item/9789240015128";
}
if (target.type === "MOOD_SCORE" || target.type === "MOOD_STABILITY") {
return null;
}
return null;
}
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
if (trend === "up") {
return <TrendingUp className="h-4 w-4 text-orange-400" />;
}
if (trend === "down") {
return <TrendingDown className="h-4 w-4 text-cyan-400" />;
}
if (trend === "stable") {
return <Minus className="h-4 w-4 text-green-400" />;
}
return null;
}
/**
* A horizontal range bar with green/yellow/red zones showing where
* the current value falls relative to a target range.
*/
function RangeBar({
value,
min,
max,
unit,
orangeMin,
orangeMax,
}: {
value: number;
min: number;
max: number;
unit: string;
orangeMin?: number;
orangeMax?: number;
}) {
const { t } = useTranslations();
const span = max - min;
const defaultOrangeWidth = span * 0.3;
const computedOrangeMin = min - defaultOrangeWidth;
const computedOrangeMax = max + defaultOrangeWidth;
const effectiveOrangeMin =
orangeMin != null ? Math.min(orangeMin, min) : computedOrangeMin;
const effectiveOrangeMax =
orangeMax != null ? Math.max(orangeMax, max) : computedOrangeMax;
const orangeSpan = Math.max(1, effectiveOrangeMax - effectiveOrangeMin);
const sidePadding = Math.max(1, orangeSpan * 0.18);
const visualMin = effectiveOrangeMin - sidePadding;
const visualMax = effectiveOrangeMax + sidePadding;
const visualSpan = visualMax - visualMin;
const clampedValue = Math.max(visualMin, Math.min(visualMax, value));
const rawPosition = ((clampedValue - visualMin) / visualSpan) * 100;
const EDGE_PADDING_PERCENT = 4;
const position = Math.max(
EDGE_PADDING_PERCENT,
Math.min(100 - EDGE_PADDING_PERCENT, rawPosition),
);
// Zone boundaries (percent of visual bar)
const greenStart = Math.max(0, ((min - visualMin) / visualSpan) * 100);
const greenEnd = Math.min(100, ((max - visualMin) / visualSpan) * 100);
const yellowLeftStart = Math.max(
0,
((effectiveOrangeMin - visualMin) / visualSpan) * 100,
);
const yellowRightEnd = Math.min(
100,
((effectiveOrangeMax - visualMin) / visualSpan) * 100,
);
// Determine marker color
const inGreen = value >= min && value <= max;
const inYellow =
!inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax;
const markerColor = inGreen
? "var(--dracula-green)"
: inYellow
? "var(--dracula-orange)"
: "var(--dracula-red)";
const minLabelPosition = Math.max(5, Math.min(95, greenStart));
const maxLabelPosition = Math.max(5, Math.min(95, greenEnd));
// Delta to target range
const delta = value < min ? min - value : value > max ? value - max : 0;
const deltaText =
delta > 0
? value < min
? t("targets.belowTarget", { delta: delta.toFixed(1), unit })
: t("targets.aboveTarget", { delta: delta.toFixed(1), unit })
: t("targets.inTarget");
return (
<div className="space-y-1.5">
<div className="bg-muted/50 relative h-3 w-full overflow-hidden rounded-full">
{/* Red background (full bar) */}
<div className="absolute inset-0 rounded-full bg-red-500/8" />
{/* Yellow zones */}
<div
className="absolute top-0 h-full bg-yellow-500/12"
style={{
left: `${yellowLeftStart}%`,
width: `${greenStart - yellowLeftStart}%`,
}}
/>
<div
className="absolute top-0 h-full bg-yellow-500/12"
style={{
left: `${greenEnd}%`,
width: `${yellowRightEnd - greenEnd}%`,
}}
/>
{/* Green zone */}
<div
className="absolute top-0 h-full bg-green-500/20"
style={{
left: `${greenStart}%`,
width: `${greenEnd - greenStart}%`,
}}
/>
{/* Current value marker with tooltip */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border-2 shadow-sm"
style={{
left: `${position}%`,
backgroundColor: markerColor,
borderColor: markerColor,
}}
/>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs font-medium">
{t("targets.currentValue", { value: String(value), unit })}
</p>
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(min),
max: String(max),
unit,
})}
</p>
<p className="text-xs font-medium">{deltaText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="text-muted-foreground relative h-4 text-xs">
<span
className="absolute -translate-x-1/2"
style={{ left: `${minLabelPosition}%` }}
>
{min} {unit}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${maxLabelPosition}%` }}
>
{max} {unit}
</span>
</div>
</div>
);
}
function TargetCard({
target,
bpDiastolic,
}: {
target: TargetData;
bpDiastolic?: BpDiastolic;
}) {
const { t } = useTranslations();
const Icon = TYPE_ICONS[target.type] ?? Activity;
const iconColor = TYPE_COLORS[target.type] ?? "text-primary";
const isBp = target.type === "BLOOD_PRESSURE";
const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE";
const medicationBreakdown = target.details?.medications ?? [];
const sourceLink = getTargetSourceLink(target);
const localisedLabel = translateTargetLabel(target, t);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${iconColor}`} />
<CardTitle className="text-sm font-medium">
{localisedLabel}
</CardTitle>
</div>
<TrendIcon trend={target.trend} />
</div>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
{/* Current value */}
{target.current != null ? (
<div className="space-y-1">
<div className="flex items-baseline gap-2">
{isBp && bpDiastolic?.current != null ? (
<>
<span className="text-3xl font-bold">
{Math.round(target.current)}
</span>
<span className="text-muted-foreground text-lg">/</span>
<span className="text-2xl font-bold">
{Math.round(bpDiastolic.current)}
</span>
</>
) : (
<span className="text-3xl font-bold">
{target.type === "BODY_FAT"
? target.current.toFixed(1)
: Math.round(target.current * 10) / 10}
</span>
)}
<span className="text-muted-foreground text-sm">
{target.unit}
</span>
</div>
{target.average30 != null && (
<p className="text-muted-foreground text-xs">
{t("targets.average30d")}{" "}
{isBp && bpDiastolic?.average30 != null
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
: Math.round(target.average30 * 10) / 10}{" "}
{target.unit}
</p>
)}
</div>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{t("targets.noMeasurementYet")}</span>
</div>
)}
{/* Range bar */}
{target.range && target.current != null && (
<RangeBar
value={target.current}
min={target.range.min}
max={target.range.max}
unit={target.unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
)}
{/* BP diastolic range bar */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
/>
</div>
)}
{isMedicationCompliance && medicationBreakdown.length > 0 && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("targets.compliancePerMedication")}
</p>
<div className="space-y-1">
{medicationBreakdown.map((medication) => (
<div
key={medication.name}
className="text-muted-foreground flex items-center justify-between gap-2 text-xs"
>
<span className="truncate">{medication.name}</span>
<span className="shrink-0">
{medication.compliance7.toFixed(1)}% (7T) ·{" "}
{medication.compliance30.toFixed(1)}% (30T)
</span>
</div>
))}
</div>
</div>
)}
{/* Classification + source */}
<div className="mt-auto flex items-center justify-between gap-2">
{target.classification ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
className="cursor-help"
style={{
backgroundColor: `${target.classification.color}20`,
color: target.classification.color,
}}
>
{translateStatus(target.classification.category, t)}
</Badge>
</TooltipTrigger>
<TooltipContent>
{target.range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(target.range.min),
max: String(target.range.max),
unit: target.unit,
})}
</p>
)}
<p className="text-xs">
{t("targets.sourceLabel", { source: target.source })}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="invisible text-xs">placeholder</span>
)}
{target.type !== "MEDICATION_COMPLIANCE" ? (
sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{target.source}</span>
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{target.source}
</span>
)
) : (
<span />
)}
</div>
</CardContent>
</Card>
);
}
export default function TargetsPage() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -624,6 +141,58 @@ export default function TargetsPage() {
enabled: isAuthenticated,
});
// v1.4.25 W3e — share the cache with Settings → AI section. When the
// user has no configured provider, `activeProvider` is null and the
// per-card Coach CTA hides entirely (no broken-button state).
const { data: chainStatus } = useQuery({
queryKey: ["insights", "provider-chain"],
queryFn: async () => {
const res = await fetch("/api/insights/provider-chain");
if (!res.ok) return null;
const json = await res.json();
return json.data as ProviderChainStatus;
},
enabled: isAuthenticated,
});
const aiEnabled = chainStatus?.activeProvider != null;
// v1.4.25 W3e — Coach drawer state owned by the page. The per-card
// CTA + summary header both feed the same drawer so the user only
// ever sees one drawer instance.
const { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach } =
useCoachHandoff();
const visibleTargets = useMemo(() => {
if (!data) return [] as TargetData[];
const displayGlucoseUnit = resolveGlucoseUnit(
data.profile.glucoseUnit ?? null,
);
return data.targets
.filter((target) => target.current != null)
.map((target) => {
if (!GLUCOSE_TYPES.has(target.type)) return target;
// Server label is an i18n key for glucose; resolve here.
const label = t(target.label);
// Convert mg/dL canonical values to the user's display unit.
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayGlucoseUnit);
return {
...target,
label,
unit: displayGlucoseUnit,
current: convert(target.current),
average30: convert(target.average30),
range: target.range
? {
min: convertGlucose(target.range.min, displayGlucoseUnit),
max: convertGlucose(target.range.max, displayGlucoseUnit),
}
: null,
};
})
.sort((a, b) => sortKey(a) - sortKey(b));
}, [data, t]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
@@ -641,32 +210,6 @@ export default function TargetsPage() {
}
const profileIncomplete = !data.profile.heightCm || !data.profile.age;
const displayGlucoseUnit = resolveGlucoseUnit(
data.profile.glucoseUnit ?? null,
);
const visibleTargets = data.targets
.filter((target) => target.current != null)
.map((target) => {
if (!GLUCOSE_TYPES.has(target.type)) return target;
// Server label is an i18n key for glucose; resolve here.
const label = t(target.label);
// Convert mg/dL canonical values to the user's display unit.
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayGlucoseUnit);
return {
...target,
label,
unit: displayGlucoseUnit,
current: convert(target.current),
average30: convert(target.average30),
range: target.range
? {
min: convertGlucose(target.range.min, displayGlucoseUnit),
max: convertGlucose(target.range.max, displayGlucoseUnit),
}
: null,
};
});
return (
// v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted
@@ -711,9 +254,12 @@ export default function TargetsPage() {
/>
)}
{/* Target cards grid */}
{/* v1.4.25 W3e — responsive grid. Mobile (default): single
column. sm (640px+): two columns. lg (1024px+): three columns,
matching the dashboard / insights rhythm. Cards reflow
internally too — see <TargetCard>. */}
{visibleTargets.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{visibleTargets.map((target) => (
<TargetCard
key={target.type}
@@ -721,6 +267,9 @@ export default function TargetsPage() {
bpDiastolic={
target.type === "BLOOD_PRESSURE" ? data.bpDiastolic : undefined
}
aiEnabled={aiEnabled}
onAskCoach={askCoach}
sourceLink={getTargetSourceLink(target)}
/>
))}
</div>
@@ -729,6 +278,16 @@ export default function TargetsPage() {
{t("targets.noMeasurementData")}
</div>
)}
{/* v1.4.25 W3e — Coach drawer mounted at the page level. The
drawer is fully-controlled; per-card CTAs flip `coachOpen`
and seed `coachPrefill` via the `askCoach()` hook callback. */}
<CoachDrawer
open={coachOpen}
onOpenChange={setCoachOpen}
prefill={coachPrefill}
key={`coach-drawer-${coachScope?.sources?.join(",") ?? "default"}`}
/>
</div>
);
}
+1 -3
View File
@@ -122,9 +122,7 @@ export function ConsistencyStrip({
data-band={band ?? "none"}
className={cn(
"size-2.5 rounded-full border transition-colors",
band
? BAND_STYLES[band]
: "border-border bg-transparent",
band ? BAND_STYLES[band] : "border-border bg-transparent",
)}
/>
);
+9 -10
View File
@@ -161,9 +161,7 @@ function translateStatus(
* middle, which paints the pill in the neutral amber tone rather than
* lighting up a false-positive green/red.
*/
function statusGroupForCategory(
category: string,
): "in" | "near" | "out" {
function statusGroupForCategory(category: string): "in" | "near" | "out" {
const greenCategories = new Set([
"Normal",
"Optimal",
@@ -370,11 +368,12 @@ export function TargetCard({
target.consistency7d != null &&
target.consistency7d.length > 0;
const showLastMet =
target.lastMetGoalAt != null && !target.insufficientData;
const showLastMet = target.lastMetGoalAt != null && !target.insufficientData;
const showStreak =
target.streakDays != null && target.streakDays >= 3 && !target.insufficientData;
target.streakDays != null &&
target.streakDays >= 3 &&
!target.insufficientData;
// Coach prefill is built from the live state of the card; the helper
// file owns the per-metric template + locale resolution.
@@ -409,7 +408,7 @@ export function TargetCard({
aria-hidden="true"
/>
<h3
className="text-muted-foreground text-[0.6875rem] font-medium uppercase tracking-[0.08em]"
className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.08em] uppercase"
data-slot="target-card-title"
>
{titleLabel}
@@ -456,7 +455,7 @@ export function TargetCard({
<div className="flex items-baseline gap-2">
{moodStabilityValue && moodStabilityCopy ? (
<span
className="text-foreground text-2xl font-semibold leading-none sm:text-3xl"
className="text-foreground text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
data-mood-stability={moodStabilityValue}
>
@@ -464,7 +463,7 @@ export function TargetCard({
</span>
) : isBp && bpDiastolic?.current != null ? (
<span
className="text-foreground flex items-baseline gap-1 text-2xl font-semibold leading-none sm:text-3xl"
className="text-foreground flex items-baseline gap-1 text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
>
<span>{Math.round(target.current)}</span>
@@ -473,7 +472,7 @@ export function TargetCard({
</span>
) : (
<span
className="text-foreground text-2xl font-semibold leading-none sm:text-3xl"
className="text-foreground text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
>
{target.type === "BODY_FAT"
@@ -4,10 +4,7 @@ import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type {
CoachScope,
CoachScopeSource,
} from "@/lib/ai/coach/types";
import type { CoachScope, CoachScopeSource } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — per-card "Ask Coach about this" CTA.
@@ -67,7 +67,7 @@ export function TargetsSummaryHeader({
<div className="flex items-center gap-2">
{allMet && (
<CheckCircle2
className="text-[var(--dracula-green)] size-4 shrink-0"
className="size-4 shrink-0 text-[var(--dracula-green)]"
aria-hidden="true"
/>
)}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useCallback, useState } from "react";
import type { CoachScope } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — controlled state for the Coach drawer when mounted
* outside the `/insights` page (currently `/targets`, future
* `/insights/<metric>` sub-pages).
*
* Encapsulates the open / prefill / scope triple so each new surface
* only has to mount `<CoachDrawer>` and wire the page-level CTAs to
* the returned `askCoach()` callback. The drawer itself owns no
* cross-surface state; this hook is the binding point.
*
* Reset behaviour mirrors the v1.4.20 Insights pattern: closing the
* drawer clears prefill so the next open starts blank unless a CTA
* supplies a fresh prefill.
*/
export interface CoachHandoffApi {
coachOpen: boolean;
setCoachOpen: (next: boolean) => void;
coachPrefill: string | null;
coachScope: CoachScope | null;
askCoach: (payload: { prefill: string; scope: CoachScope }) => void;
}
export function useCoachHandoff(): CoachHandoffApi {
const [coachOpen, setOpen] = useState(false);
const [coachPrefill, setPrefill] = useState<string | null>(null);
const [coachScope, setScope] = useState<CoachScope | null>(null);
const setCoachOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
setPrefill(null);
setScope(null);
}
}, []);
const askCoach = useCallback(
({ prefill, scope }: { prefill: string; scope: CoachScope }) => {
setPrefill(prefill);
setScope(scope);
setOpen(true);
},
[],
);
return { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach };
}
+2 -4
View File
@@ -58,8 +58,7 @@ const TEMPLATES_EN: Record<string, Template> = {
MOOD_SCORE: (i) =>
`My latest mood score is ${fmt(i.current)} / 5 and my target is ${i.range ? `${i.range.min}+ / 5` : "3.5+ / 5"}. How does my mood compare to recent weeks and what could I try?`,
MOOD_STABILITY: (i) => {
const label =
i.current != null ? moodStabilityLabel(i.current) : "stable";
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
const verbal =
label === "stable"
? "stable"
@@ -86,8 +85,7 @@ const TEMPLATES_DE: Record<string, Template> = {
MOOD_SCORE: (i) =>
`Mein letzter Stimmungs-Wert ist ${fmt(i.current)} / 5, mein Ziel liegt bei ${i.range ? `${i.range.min}+ / 5` : "3,5+ / 5"}. Wie ist meine Stimmung im Vergleich zu den letzten Wochen, und was könnte ich ausprobieren?`,
MOOD_STABILITY: (i) => {
const label =
i.current != null ? moodStabilityLabel(i.current) : "stable";
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
const verbal =
label === "stable"
? "stabil"