diff --git a/messages/de.json b/messages/de.json index 27eedcaca..97b93b7c3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2627,6 +2627,49 @@ "moodStability": "Stimmungsstabilität", "steps": "Schritte", "average30d": "30-Tage-Schnitt:", + "consistency": { + "ariaLabel": "Letzte 7 Tage, im-Zielbereich-Status pro Tag", + "daysInRange": "{count} von {total} im Bereich", + "daysLogged": "{count} von {total} erfasst", + "dotInRange": "im Bereich", + "dotNearRange": "nahe am Bereich", + "dotOutOfRange": "außerhalb", + "dotNotLogged": "nicht erfasst", + "insufficientData": "Noch zu wenige Daten — bitte weiter erfassen.", + "relativeDay": { + "today": "heute", + "yesterday": "gestern", + "twoDaysAgo": "vor 2 Tagen", + "threeDaysAgo": "vor 3 Tagen", + "fourDaysAgo": "vor 4 Tagen", + "fiveDaysAgo": "vor 5 Tagen", + "sixDaysAgo": "vor 6 Tagen" + } + }, + "card": { + "lastMet": "Zuletzt im Ziel: {when}", + "streak": "{count} Tage in Folge" + }, + "relativeDay": { + "today": "heute", + "yesterday": "gestern", + "daysAgo": "vor {count} Tagen" + }, + "summary": { + "weekTitle": "{met} von {total} Zielwerten diese Woche erreicht", + "weekTitleAll": "Alle {total} Zielwerte diese Woche erreicht", + "streakHighlight": "{count} Tage in Folge: {metric}" + }, + "coach": { + "cta": "Coach fragen" + }, + "mood": { + "stability": { + "stable": "stabil", + "variable": "schwankend", + "highlyVariable": "sehr schwankend" + } + }, "currentValue": "Aktuell: {value} {unit}", "targetRangeValue": "Zielbereich: {min}–{max} {unit}", "sourceLabel": "Quelle: {source}", diff --git a/messages/en.json b/messages/en.json index 97991c1e7..de2d424ab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2627,6 +2627,49 @@ "moodStability": "Mood stability", "steps": "steps", "average30d": "30-day average:", + "consistency": { + "ariaLabel": "Last 7 days, in-range status per day", + "daysInRange": "{count} of {total} in range", + "daysLogged": "{count} of {total} logged", + "dotInRange": "in range", + "dotNearRange": "near range", + "dotOutOfRange": "out of range", + "dotNotLogged": "not logged", + "insufficientData": "Not enough data yet — keep logging.", + "relativeDay": { + "today": "today", + "yesterday": "yesterday", + "twoDaysAgo": "2 days ago", + "threeDaysAgo": "3 days ago", + "fourDaysAgo": "4 days ago", + "fiveDaysAgo": "5 days ago", + "sixDaysAgo": "6 days ago" + } + }, + "card": { + "lastMet": "Last met goal: {when}", + "streak": "{count}-day streak" + }, + "relativeDay": { + "today": "today", + "yesterday": "yesterday", + "daysAgo": "{count} days ago" + }, + "summary": { + "weekTitle": "{met} of {total} targets met this week", + "weekTitleAll": "All {total} targets met this week", + "streakHighlight": "{count}-day streak: {metric}" + }, + "coach": { + "cta": "Ask Coach" + }, + "mood": { + "stability": { + "stable": "stable", + "variable": "variable", + "highlyVariable": "highly variable" + } + }, "currentValue": "Current: {value} {unit}", "targetRangeValue": "Target: {min}–{max} {unit}", "sourceLabel": "Source: {source}", diff --git a/src/components/targets/__tests__/consistency-strip.test.tsx b/src/components/targets/__tests__/consistency-strip.test.tsx new file mode 100644 index 000000000..18639cc3e --- /dev/null +++ b/src/components/targets/__tests__/consistency-strip.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ConsistencyStrip } from "../consistency-strip"; + +/** + * v1.4.25 W3e — `` replaces the v1.4.22 per-card + * sparkline. Seven dots, one per Berlin-tz day; band-coloured when the + * day's mean reading was in range, hollow when no readings, and a + * trailing caption that flips between "in range" and "logged" depending + * on the cadence. + */ + +function render( + props: Parameters[0], + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("renders 7 dots with their band class and the caption", () => { + const html = render({ + days: ["in", "in", "near", "in", "out", "in", "in"], + daysInRange: 5, + daysLogged: 7, + }); + expect(html).toContain('data-slot="consistency-strip"'); + // Seven li elements, one per day. + const liMatches = html.match(/
  • { + const html = render({ + days: [null, null, null, null, null, "in", "in"], + daysInRange: 2, + daysLogged: 2, + }); + // Misleading "2 of 7 in range" suppressed; honest "2 of 7 logged" + // surfaced instead. + expect(html).toContain("2 of 7 logged"); + expect(html).not.toContain("2 of 7 in range"); + }); + + it("renders all-hollow when every day has null (no readings yet)", () => { + const html = render({ + days: [null, null, null, null, null, null, null], + daysInRange: 0, + daysLogged: 0, + }); + // No band classes should be present; cap shows the logged variant. + expect(html).not.toContain("var(--dracula-green)"); + expect(html).toContain("0 of 7 logged"); + }); + + it("German locale surfaces the localised cap", () => { + const html = render( + { + days: ["in", "in", "in", "in", "in", "in", "in"], + daysInRange: 7, + daysLogged: 7, + }, + "de", + ); + expect(html).toContain("7 von 7 im Bereich"); + }); +}); diff --git a/src/components/targets/__tests__/range-bar.test.tsx b/src/components/targets/__tests__/range-bar.test.tsx new file mode 100644 index 000000000..dcd317315 --- /dev/null +++ b/src/components/targets/__tests__/range-bar.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { RangeBar } from "../range-bar"; + +/** + * v1.4.25 W3e — `` was extracted from the inline 790-line + * `targets/page.tsx`. Behaviour is intentionally unchanged from the + * v1.4.22 inline version; these tests pin the contract so the + * extraction is provably side-effect-free. + */ + +function render(props: Parameters[0]) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("renders the bar slot + green/yellow/red zones", () => { + const html = render({ + value: 72, + min: 60, + max: 100, + unit: "bpm", + }); + expect(html).toContain('data-slot="target-range-bar"'); + expect(html).toContain("bg-green-500/20"); + expect(html).toContain("bg-yellow-500/12"); + }); + + it("paints the marker with the green token when the value is in range", () => { + const html = render({ + value: 80, + min: 60, + max: 100, + unit: "bpm", + }); + expect(html).toContain("var(--dracula-green)"); + }); + + it("paints the marker with the red token when the value is out of band", () => { + const html = render({ + value: 180, + min: 60, + max: 100, + unit: "bpm", + }); + expect(html).toContain("var(--dracula-red)"); + }); +}); diff --git a/src/components/targets/__tests__/target-card.test.tsx b/src/components/targets/__tests__/target-card.test.tsx new file mode 100644 index 000000000..84e4487d6 --- /dev/null +++ b/src/components/targets/__tests__/target-card.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { TargetCard, type TargetCardData } from "../target-card"; + +/** + * v1.4.25 W3e — `` composition. Three load-bearing cases: + * + * 1. Full-data card — headline + status pill + range bar + + * consistency strip + Coach CTA + source link all visible. + * 2. Insufficient-data card — strip + recency line + streak chip + * all suppressed; the explicit "not enough data yet" inline + * message renders instead. + * 3. AI gate — Coach CTA suppressed when `aiEnabled` is false. + */ + +function render(props: Parameters[0]) { + return renderToStaticMarkup( + + + , + ); +} + +const fullDataTarget: TargetCardData = { + type: "WEIGHT", + label: "Weight", + current: 78.2, + average30: 79.1, + trend: "down", + unit: "kg", + range: { min: 60, max: 80 }, + classification: { category: "Normal", color: "#50fa7b" }, + source: "WHO BMI", + daysInRange7d: 5, + daysLogged7d: 7, + lastMetGoalAt: "2026-05-10", + streakDays: 4, + insufficientData: false, + consistency7d: ["in", "in", "near", "in", "out", "in", "in"], +}; + +const sparseTarget: TargetCardData = { + type: "PULSE", + label: "Resting pulse", + current: 72, + average30: 72, + trend: null, + unit: "bpm", + range: { min: 60, max: 100 }, + classification: { category: "On target", color: "#50fa7b" }, + source: "AHA", + daysInRange7d: 0, + daysLogged7d: 0, + lastMetGoalAt: null, + streakDays: 0, + insufficientData: true, + consistency7d: [null, null, null, null, null, null, null], +}; + +describe("", () => { + it("renders the full layout when the target has rich data", () => { + const html = render({ + target: fullDataTarget, + aiEnabled: true, + onAskCoach: vi.fn(), + }); + expect(html).toContain('data-slot="target-card"'); + expect(html).toContain('data-slot="target-status-pill"'); + expect(html).toContain('data-slot="target-range-bar"'); + expect(html).toContain('data-slot="consistency-strip"'); + expect(html).toContain('data-slot="target-coach-cta"'); + expect(html).toContain('data-slot="target-streak"'); + // Streak chip surfaces the count from i18n + expect(html).toContain("4-day streak"); + // Headline number (78.2 → 78.2) + the kg unit + expect(html).toContain("78.2"); + }); + + it("hides the consistency strip + recency + streak when insufficient data", () => { + const html = render({ + target: sparseTarget, + aiEnabled: true, + onAskCoach: vi.fn(), + }); + expect(html).not.toContain('data-slot="consistency-strip"'); + expect(html).not.toContain('data-slot="target-last-met"'); + expect(html).not.toContain('data-slot="target-streak"'); + // The explicit "not enough data yet" message replaces the strip + expect(html).toContain('data-slot="target-insufficient-data"'); + }); + + it("suppresses the Coach CTA when aiEnabled is false", () => { + const html = render({ + target: fullDataTarget, + aiEnabled: false, + onAskCoach: vi.fn(), + }); + expect(html).not.toContain('data-slot="target-coach-cta"'); + }); + + it("renders MOOD_STABILITY headline as a verbal label, not the σ value", () => { + const moodStability: TargetCardData = { + ...fullDataTarget, + type: "MOOD_STABILITY", + label: "Mood stability", + current: 0.6, + unit: "σ", + range: { min: 0, max: 0.5 }, + classification: { category: "Stable", color: "#f1fa8c" }, + }; + const html = render({ + target: moodStability, + aiEnabled: false, + onAskCoach: vi.fn(), + }); + // The big-number slot should carry the EN verbal label, not "0.6". + expect(html).toContain('data-mood-stability="stable"'); + expect(html).toContain("stable"); + expect(html).not.toMatch(/>0\.6[0]) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + const baseProps = { + prefill: "How am I doing on weight?", + sources: ["weight"] as const, + onAskCoach: vi.fn(), + }; + + it("renders the button when aiEnabled is true", () => { + const html = render({ ...baseProps, aiEnabled: true }); + expect(html).toContain('data-slot="target-coach-cta"'); + expect(html).toContain("Ask Coach"); + }); + + it("renders NOTHING when aiEnabled is false (no broken-button state)", () => { + const html = render({ ...baseProps, aiEnabled: false }); + expect(html).toBe(""); + }); + + it("does not call onAskCoach during SSR", () => { + const onAskCoach = vi.fn(); + render({ ...baseProps, onAskCoach, aiEnabled: true }); + // Server-side render does not fire click handlers. + expect(onAskCoach).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/targets/consistency-strip.tsx b/src/components/targets/consistency-strip.tsx new file mode 100644 index 000000000..a913565ab --- /dev/null +++ b/src/components/targets/consistency-strip.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.4.25 W3e — 7-day consistency strip. + * + * Renders one dot per Berlin-tz day in `days` (oldest → newest, index 6 + * is today). Each dot's appearance encodes both whether the day was + * logged and whether the day's mean reading landed in the target's + * green band: + * + * • Solid green — `"in"` : day's mean was in the target band + * • Solid amber — `"near"` : day's mean was in the orange band + * • Solid red — `"out"` : day's mean was outside both bands + * • Hollow ring — `null` : day had no readings (dim border only) + * + * Cap to the right: "5 of 7 in range" (or the user's locale's + * equivalent). The cap reads off `daysInRange` and `daysLogged`. When + * the cap would be misleading on thin data (≤ 2 days logged) we surface + * "X of 7 logged" instead so the user sees the cadence problem, not the + * misleading range count. + * + * Hidden by the parent when the target's `insufficientData` flag is + * true — there is no "— not enough data" empty-state mode here; the + * card surfaces that copy in a different slot per Marc's "no empty hint + * inside the strip" directive. + * + * Visual rationale (frontend-design skill): seven dots over the v1.4.22 + * 24-px sparkline trades a continuous-but-distorted signal for seven + * honest atomic answers. The hollow-ring "no data" state means the + * gap is visible without leaving the user wondering whether the + * sparkline simply ended there. + */ +export type ConsistencyBand = "in" | "near" | "out" | null; + +export interface ConsistencyStripProps { + /** + * Seven entries, index 0 = six days ago, index 6 = today. Anything + * other than 7 is rendered as-is (so callers can pass a short array + * for tests), but the live API always sends exactly 7. + */ + days: ReadonlyArray; + daysInRange: number; + daysLogged: number; + className?: string; +} + +const BAND_STYLES: Record, string> = { + in: "bg-[var(--dracula-green)] border-[var(--dracula-green)] shadow-[0_0_0_1px_var(--dracula-green)]/20", + near: "bg-[var(--dracula-orange)] border-[var(--dracula-orange)]", + out: "bg-[var(--dracula-red)] border-[var(--dracula-red)]", +}; + +const BAND_ARIA: Record, string> = { + in: "targets.consistency.dotInRange", + near: "targets.consistency.dotNearRange", + out: "targets.consistency.dotOutOfRange", +}; + +const RELATIVE_DAY_KEYS = [ + "targets.consistency.relativeDay.sixDaysAgo", + "targets.consistency.relativeDay.fiveDaysAgo", + "targets.consistency.relativeDay.fourDaysAgo", + "targets.consistency.relativeDay.threeDaysAgo", + "targets.consistency.relativeDay.twoDaysAgo", + "targets.consistency.relativeDay.yesterday", + "targets.consistency.relativeDay.today", +]; + +export function ConsistencyStrip({ + days, + daysInRange, + daysLogged, + className, +}: ConsistencyStripProps) { + const { t } = useTranslations(); + + // Cap rule: if very few days logged, surface the cadence problem + // ("X of 7 logged") rather than the misleading range count. The + // threshold matches the server's `insufficientData < 3 readings` + // guard one tier lower so the strip can still render gracefully + // when the parent decided to mount it. + const showLoggedCap = daysLogged <= 2; + const cap = showLoggedCap + ? t("targets.consistency.daysLogged", { + count: String(daysLogged), + total: "7", + }) + : t("targets.consistency.daysInRange", { + count: String(daysInRange), + total: "7", + }); + + return ( +
    +
      + {days.map((band, index) => { + const relativeKey = + RELATIVE_DAY_KEYS[ + index + (RELATIVE_DAY_KEYS.length - days.length) + ] ?? RELATIVE_DAY_KEYS[RELATIVE_DAY_KEYS.length - 1]; + const dayLabel = t(relativeKey); + const statusLabel = band + ? t(BAND_ARIA[band]) + : t("targets.consistency.dotNotLogged"); + return ( +
    • + ); + })} +
    + + {cap} + +
    + ); +} diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx new file mode 100644 index 000000000..56bf4842a --- /dev/null +++ b/src/components/targets/range-bar.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.4.25 W3e — extracted from `src/app/targets/page.tsx` (the page + * was 790 lines; the redesign breaks it into focused primitives so + * each component carries its own contract + test suite). + * + * A horizontal range bar with green/yellow/red zones showing where + * the current value falls relative to a target range. The marker dot + * carries a tooltip with the current value + the target range + a + * delta sentence (e.g. "6 mmHg above target band"). + * + * Behaviour is unchanged from the v1.4.22 inline version; only the + * file boundary moves. + */ +export interface RangeBarProps { + value: number; + min: number; + max: number; + unit: string; + orangeMin?: number; + orangeMax?: number; +} + +export function RangeBar({ + value, + min, + max, + unit, + orangeMin, + orangeMax, +}: RangeBarProps) { + 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} + +
    +
    + ); +} diff --git a/src/components/targets/target-card.tsx b/src/components/targets/target-card.tsx new file mode 100644 index 000000000..1244b1d0a --- /dev/null +++ b/src/components/targets/target-card.tsx @@ -0,0 +1,633 @@ +"use client"; + +import { + AlertCircle, + Activity, + Droplet, + ExternalLink, + Heart, + Minus, + Moon, + Percent, + Scale, + Smile, + TrendingDown, + TrendingUp, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { moodStabilityLabel } from "@/lib/targets/mood-stability-label"; +import { ConsistencyStrip } from "./consistency-strip"; +import { RangeBar } from "./range-bar"; +import { TargetCoachButton } from "./target-coach-button"; +import { buildTargetPrompt } from "@/lib/ai/coach/target-prompts"; +import { coachScopeForTarget } from "@/lib/ai/coach/target-scope"; +import type { CoachScope } from "@/lib/ai/coach/types"; + +/** + * v1.4.25 W3e — `` extracted from `src/app/targets/page.tsx` + * with a Marc-directive redesign. Visual rationale: + * + * • Headline row pairs the metric label with a verbal status pill — + * Q1 ("am I on track right now?") answered before the user's eye + * moves below the title. + * • Big value with the unit small + muted; the inline trend chevron + * sits next to the value, not in a separate row. + * • Range bar carries the precise placement; the bar is the + * "spatial" answer to Q2 (delta to goal). + * • Consistency strip beneath the bar answers Q7 ("how consistent + * am I?"). Hidden when `insufficientData` is true so cold-start + * accounts don't see a dead-grey row. + * • Recency caption ("last met goal: 2 days ago") + streak chip + * when ≥ 3 days. Both are conditional on real data — no empty + * placeholders. + * • Footer row: per-card Coach CTA on the left (only when the user + * has an AI provider configured), guideline-source link on the + * right. + * + * Per Marc's "muss nicht so sein wie die KI Sache": the card stays + * quiet. No gradients, no chips with emoji, no animation. Editorial + * spacing rhythm; one decisive number and one decisive status pill. + */ + +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", +}; + +const STATUS_CATEGORY_KEY: Record = { + Underweight: "underweight", + Normal: "normal", + Overweight: "overweight", + "Obesity Grade I": "obesityGrade1", + "Obesity Grade II": "obesityGrade2", + "Obesity Grade III": "obesityGrade3", + Optimal: "optimal", + "High-normal": "highNormal", + "Hypertension Grade 1": "hypertensionGrade1", + "Hypertension Grade 2": "hypertensionGrade2", + "Hypertension Grade 3": "hypertensionGrade3", + Bradycardia: "bradycardia", + Elevated: "elevated", + Tachycardia: "tachycardia", + "Significantly low": "significantlyLow", + "Slightly low": "slightlyLow", + "On target": "onTarget", + "Slightly elevated": "slightlyElevated", + "Significantly elevated": "significantlyElevated", + "Far too short": "farTooShort", + "Too short": "tooShort", + "Slightly long": "slightlyLong", + "Far too long": "farTooLong", + "Below essential": "belowEssential", + Essential: "essential", + Athletic: "athletic", + Fitness: "fitness", + Acceptable: "acceptable", + Obese: "obese", + "Very low": "veryLow", + "Low active": "lowActive", + "Moderately active": "moderatelyActive", + Active: "active", + "Very active": "veryActive", + Good: "good", + Moderate: "moderate", + Low: "low", + High: "high", + "Very good": "veryGood", + "Very stable": "veryStable", + Stable: "stable", + Fluctuating: "fluctuating", +}; + +/** 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; + return t(`targets.status.${key}`); +} + +/** + * Map an in-band classification category to one of three semantic + * groups. Used to drive the status-pill style without re-classifying + * the value from scratch; the server's category is canonical. + * + * "in" = inside the green band ("On target", "Normal", "Optimal", …). + * "near" = inside the orange band ("Slightly elevated", "Overweight", + * …) or a step inside an obesity / hypertension grade-1 band. + * "out" = outside all bands ("Hypertension Grade 3", "Obesity Grade + * III", "Tachycardia" away from the target band, …). + * + * When the category is unmapped we fall back to "near" — the safe + * 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" { + const greenCategories = new Set([ + "Normal", + "Optimal", + "On target", + "Good", + "Very good", + "Very stable", + "Stable", + "Athletic", + "Fitness", + "Acceptable", + "Active", + "Very active", + "Moderately active", + "Essential", + ]); + const redCategories = new Set([ + "Obesity Grade III", + "Hypertension Grade 3", + "Significantly elevated", + "Significantly low", + "Far too short", + "Far too long", + "Tachycardia", + "Bradycardia", + "Low", + "High", + "Fluctuating", + "Below essential", + "Very low", + ]); + if (greenCategories.has(category)) return "in"; + if (redCategories.has(category)) return "out"; + return "near"; +} + +const STATUS_PILL_STYLES: Record<"in" | "near" | "out", string> = { + in: "bg-[var(--dracula-green)]/12 text-[var(--dracula-green)] ring-[var(--dracula-green)]/30", + near: "bg-[var(--dracula-orange)]/14 text-[var(--dracula-orange)] ring-[var(--dracula-orange)]/30", + out: "bg-[var(--dracula-red)]/12 text-[var(--dracula-red)] ring-[var(--dracula-red)]/30", +}; + +interface TargetClassification { + category: string; + color: string; +} + +interface BpDiastolic { + current: number | null; + average30: number | null; + range: { min: number; max: number } | null; +} + +export interface TargetCardData { + type: string; + label: string; + current: number | null; + average30: number | null; + trend: "up" | "down" | "stable" | null; + unit: string; + range: { min: number; max: number } | null; + classification: TargetClassification | null; + source: string; + daysInRange7d?: number; + daysLogged7d?: number; + lastMetGoalAt?: string | null; + streakDays?: number; + insufficientData?: boolean; + consistency7d?: ReadonlyArray<"in" | "near" | "out" | null>; + details?: { + medications?: Array<{ + name: string; + compliance7: number; + compliance30: number; + }>; + }; +} + +export interface TargetCardProps { + target: TargetCardData; + bpDiastolic?: BpDiastolic; + /** + * AI gate. When false, the per-card Coach CTA is suppressed entirely + * (no greyed-out button, no tooltip placeholder). The parent reads + * `/api/insights/provider-chain` and threads this flag. + */ + aiEnabled: boolean; + /** + * Drawer handoff. Receives the pre-formatted prefill + the + * narrowed scope (single source for 1:1 metrics, derived source + * for BMI / mood stability, all sources for the rest). + */ + onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void; + /** + * Optional source URL for the guideline link. Threaded from the + * page rather than computed in the card so the resolution stays + * declarative + testable. + */ + sourceLink?: string | null; +} + +function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) { + if (trend === "up") { + return ( +