From f2b9f843b11ec159e3944cbc2f4cb9b45f49206a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 12:33:53 +0200 Subject: [PATCH] feat(targets): card layout with status pill + consistency + coach handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 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 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 --- src/app/__tests__/targets-i18n.test.tsx | 46 +- src/app/__tests__/targets-spacing.test.tsx | 52 +- src/app/targets/page.tsx | 689 ++++-------------- src/components/targets/consistency-strip.tsx | 4 +- src/components/targets/target-card.tsx | 19 +- .../targets/target-coach-button.tsx | 5 +- .../targets/targets-summary-header.tsx | 2 +- src/hooks/use-coach-handoff.ts | 52 ++ src/lib/ai/coach/target-prompts.ts | 6 +- 9 files changed, 269 insertions(+), 606 deletions(-) create mode 100644 src/hooks/use-coach-handoff.ts diff --git a/src/app/__tests__/targets-i18n.test.tsx b/src/app/__tests__/targets-i18n.test.tsx index 88a5382c2..911d85108 100644 --- a/src/app/__tests__/targets-i18n.test.tsx +++ b/src/app/__tests__/targets-i18n.test.tsx @@ -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: () => ({ diff --git a/src/app/__tests__/targets-spacing.test.tsx b/src/app/__tests__/targets-spacing.test.tsx index 90dc0c129..62c720520 100644 --- a/src/app/__tests__/targets-spacing.test.tsx +++ b/src/app/__tests__/targets-spacing.test.tsx @@ -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: () => ({ diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index a7169f80d..b36940b9e 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -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 = { - 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 = { - 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 = { - // 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 = { + 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 { - 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.` 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 { - 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 ; - } - if (trend === "down") { - return ; - } - if (trend === "stable") { - return ; - } - 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 ( -
-
- {/* Red background (full bar) */} -
- {/* Yellow zones */} -
-
- {/* Green zone */} -
- {/* Current value marker with tooltip */} - - - -
- - -

- {t("targets.currentValue", { value: String(value), unit })} -

-

- {t("targets.targetRangeValue", { - min: String(min), - max: String(max), - unit, - })} -

-

{deltaText}

-
- - -
-
- - {min} {unit} - - - {max} {unit} - -
-
- ); -} - -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 ( - - -
-
- - - {localisedLabel} - -
- -
-
- - {/* Current value */} - {target.current != null ? ( -
-
- {isBp && bpDiastolic?.current != null ? ( - <> - - {Math.round(target.current)} - - / - - {Math.round(bpDiastolic.current)} - - - ) : ( - - {target.type === "BODY_FAT" - ? target.current.toFixed(1) - : Math.round(target.current * 10) / 10} - - )} - - {target.unit} - -
- {target.average30 != null && ( -

- {t("targets.average30d")}{" "} - {isBp && bpDiastolic?.average30 != null - ? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}` - : Math.round(target.average30 * 10) / 10}{" "} - {target.unit} -

- )} -
- ) : ( -
- - {t("targets.noMeasurementYet")} -
- )} - - {/* Range bar */} - {target.range && target.current != null && ( - - )} - - {/* BP diastolic range bar */} - {isBp && bpDiastolic?.range && bpDiastolic.current != null && ( -
-

- {t("targets.diastolic")} -

- -
- )} - - {isMedicationCompliance && medicationBreakdown.length > 0 && ( -
-

- {t("targets.compliancePerMedication")} -

-
- {medicationBreakdown.map((medication) => ( -
- {medication.name} - - {medication.compliance7.toFixed(1)}% (7T) ·{" "} - {medication.compliance30.toFixed(1)}% (30T) - -
- ))} -
-
- )} - - {/* Classification + source */} -
- {target.classification ? ( - - - - - {translateStatus(target.classification.category, t)} - - - - {target.range && ( -

- {t("targets.targetRangeValue", { - min: String(target.range.min), - max: String(target.range.max), - unit: target.unit, - })} -

- )} -

- {t("targets.sourceLabel", { source: target.source })} -

-
-
-
- ) : ( - placeholder - )} - {target.type !== "MEDICATION_COMPLIANCE" ? ( - sourceLink ? ( - - {target.source} - - - ) : ( - - {target.source} - - ) - ) : ( - - )} -
-
-
- ); -} - 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 (
@@ -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 . */} {visibleTargets.length > 0 ? ( -
+
{visibleTargets.map((target) => ( ))}
@@ -729,6 +278,16 @@ export default function TargetsPage() { {t("targets.noMeasurementData")}
)} + + {/* 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. */} +
); } diff --git a/src/components/targets/consistency-strip.tsx b/src/components/targets/consistency-strip.tsx index a913565ab..42355fc6d 100644 --- a/src/components/targets/consistency-strip.tsx +++ b/src/components/targets/consistency-strip.tsx @@ -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", )} /> ); diff --git a/src/components/targets/target-card.tsx b/src/components/targets/target-card.tsx index 1244b1d0a..79e7a8a50 100644 --- a/src/components/targets/target-card.tsx +++ b/src/components/targets/target-card.tsx @@ -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" />

{titleLabel} @@ -456,7 +455,7 @@ export function TargetCard({
{moodStabilityValue && moodStabilityCopy ? ( @@ -464,7 +463,7 @@ export function TargetCard({ ) : isBp && bpDiastolic?.current != null ? ( {Math.round(target.current)} @@ -473,7 +472,7 @@ export function TargetCard({ ) : ( {target.type === "BODY_FAT" diff --git a/src/components/targets/target-coach-button.tsx b/src/components/targets/target-coach-button.tsx index e1cbd5d87..bdd908d0c 100644 --- a/src/components/targets/target-coach-button.tsx +++ b/src/components/targets/target-coach-button.tsx @@ -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. diff --git a/src/components/targets/targets-summary-header.tsx b/src/components/targets/targets-summary-header.tsx index 290670d1b..2d99a0e82 100644 --- a/src/components/targets/targets-summary-header.tsx +++ b/src/components/targets/targets-summary-header.tsx @@ -67,7 +67,7 @@ export function TargetsSummaryHeader({
{allMet && (