refactor(targets): extract TargetCard + RangeBar + ConsistencyStrip + TargetCoachButton

The /targets page composed everything inline in a 790-line page.tsx.
Break it into focused primitives under src/components/targets/ so the
W3e redesign can iterate per-card without growing the page file:

- target-card.tsx — the redesigned card composition. Headline + verbal
  status pill, big-number row with inline trend chevron, range bar,
  consistency strip, recency + streak chips (both conditional on real
  data), per-card Coach CTA gated by aiEnabled, source link footer.
  MOOD_STABILITY swaps the raw σ headline for a verbal label via the
  moodStabilityLabel helper. Fixed card order is owned by the page;
  this component is presentational.
- range-bar.tsx — extracted intact from page.tsx. Same behaviour, same
  data-slot. Now carries its own test suite.
- consistency-strip.tsx — NEW. 7 dots (one per Berlin-tz day), each
  filled / amber / red / hollow per the day's band classification.
  Caption flips between "X of 7 in range" and "X of 7 logged" when
  cadence is the more informative signal. Accessible: <ul role="list">
  with per-dot aria-labels seeded from i18n. No empty-state copy
  inside the strip — Marc directive: hide entirely when insufficient.
- target-coach-button.tsx — NEW. Per-card "Ask Coach about this" CTA
  that returns null when aiEnabled is false. No greyed-out button,
  no tooltip placeholder — the gate is total per Marc's
  "no broken-button state" directive.

Supporting helpers (each unit-tested):
- src/lib/targets/mood-stability-label.ts — pure σ → verbal-label
  threshold logic (< 1.0 stable, < 2.0 variable, ≥ 2.0
  highlyVariable). Thresholds documented in the helper and confirmed
  in the W3e plan.
- src/lib/ai/coach/target-prompts.ts — 8 metric × 2 locale templates
  for the per-card Coach prefill. Falls back to a general template for
  glucose contexts. PROMPT_VERSION unchanged (the assistant-facing
  prompt does not shift; only user-side prefills do).
- src/lib/ai/coach/target-scope.ts — per-target CoachScope.sources
  mapping. Derived metrics (BMI → weight, MOOD_STABILITY → mood) point
  at the underlying source; unmapped targets return an empty array
  so the drawer falls back to defaults.

i18n keys added: targets.consistency.* (cap + dot aria + relative day
+ insufficient-data), targets.card.{lastMet,streak}, targets.relativeDay.*,
targets.summary.{weekTitle,weekTitleAll,streakHighlight},
targets.coach.cta, targets.mood.stability.* — all EN + DE.

Tests: 17 cases across 5 suites (consistency-strip render variants,
target-coach-button gate branches, range-bar zone painting,
target-card full-data + insufficient-data + aiEnabled-off +
MOOD_STABILITY verbal-headline, mood-stability-label thresholds).

Page.tsx still uses the old inline composition for now — Phase 5
wires it to the new components.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:29:02 +02:00
1 parent f3c9e2d7a6
commit 97583cf891
14 files changed
+1620

No files matched your search

+43
View File
@@ -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}",
+43
View File
@@ -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}",
@@ -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 — `<ConsistencyStrip>` 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<typeof ConsistencyStrip>[0],
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<ConsistencyStrip {...props} />
</I18nProvider>,
);
}
describe("<ConsistencyStrip>", () => {
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(/<li/g) ?? [];
expect(liMatches.length).toBe(7);
// Cap shows "5 of 7 in range".
expect(html).toContain("5 of 7 in range");
});
it("flips the cap to a logged-count when ≤ 2 days were logged", () => {
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");
});
});
@@ -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 — `<RangeBar>` 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<typeof RangeBar>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RangeBar {...props} />
</I18nProvider>,
);
}
describe("<RangeBar>", () => {
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)");
});
});
@@ -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 — `<TargetCard>` 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<typeof TargetCard>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetCard {...props} />
</I18nProvider>,
);
}
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("<TargetCard>", () => {
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,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { TargetCoachButton } from "../target-coach-button";
/**
* v1.4.25 W3e — gate test for the per-card Coach CTA. The button
* MUST disappear when `aiEnabled` is false (Marc directive: no
* broken-button state for users with no AI provider configured).
*/
function render(props: Parameters<typeof TargetCoachButton>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetCoachButton {...props} />
</I18nProvider>,
);
}
describe("<TargetCoachButton>", () => {
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();
});
});
@@ -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<ConsistencyBand>;
daysInRange: number;
daysLogged: number;
className?: string;
}
const BAND_STYLES: Record<NonNullable<ConsistencyBand>, 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<NonNullable<ConsistencyBand>, 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 (
<div
data-slot="consistency-strip"
className={cn(
"flex items-center justify-between gap-3 text-xs",
className,
)}
>
<ul
role="list"
aria-label={t("targets.consistency.ariaLabel")}
className="flex items-center gap-1.5"
>
{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 (
<li
key={`${index}-${band ?? "none"}`}
aria-label={`${dayLabel}: ${statusLabel}`}
data-band={band ?? "none"}
className={cn(
"size-2.5 rounded-full border transition-colors",
band
? BAND_STYLES[band]
: "border-border bg-transparent",
)}
/>
);
})}
</ul>
<span
className="text-muted-foreground tabular-nums"
data-slot="consistency-cap"
>
{cap}
</span>
</div>
);
}
+172
View File
@@ -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 (
<div className="space-y-1.5" data-slot="target-range-bar">
<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>
);
}
+633
View File
@@ -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 — `<TargetCard>` 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<string, LucideIcon> = {
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",
};
const STATUS_CATEGORY_KEY: Record<string, string> = {
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>) => 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 (
<TrendingUp
className="text-dracula-orange size-4"
aria-hidden="true"
data-slot="target-trend-icon"
data-trend="up"
/>
);
}
if (trend === "down") {
return (
<TrendingDown
className="text-dracula-cyan size-4"
aria-hidden="true"
data-slot="target-trend-icon"
data-trend="down"
/>
);
}
if (trend === "stable") {
return (
<Minus
className="text-dracula-green size-4"
aria-hidden="true"
data-slot="target-trend-icon"
data-trend="stable"
/>
);
}
return null;
}
function formatRelativeDay(
isoDate: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
// `isoDate` is a Berlin-tz day key (YYYY-MM-DD). Compare to today's
// Berlin-tz day by parsing both as local Date midnight; we only need
// a day-resolution delta so timezone math is unnecessary.
const today = new Date();
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
if (isoDate === todayKey) return t("targets.relativeDay.today");
const [y, m, d] = isoDate.split("-").map(Number);
const target = new Date(y, (m ?? 1) - 1, d ?? 1);
const todayMid = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const diffDays = Math.round(
(todayMid.getTime() - target.getTime()) / (24 * 60 * 60 * 1000),
);
if (diffDays === 1) return t("targets.relativeDay.yesterday");
if (diffDays < 0) return t("targets.relativeDay.today"); // future, shouldn't happen
return t("targets.relativeDay.daysAgo", { count: String(diffDays) });
}
export function TargetCard({
target,
bpDiastolic,
aiEnabled,
onAskCoach,
sourceLink,
}: TargetCardProps) {
const { t, locale } = 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 localisedLabel = t(`targets.label.${target.type}`);
const titleLabel =
localisedLabel === `targets.label.${target.type}`
? target.label
: localisedLabel;
const statusGroup = target.classification
? statusGroupForCategory(target.classification.category)
: "near";
const statusPillStyle = STATUS_PILL_STYLES[statusGroup];
const statusLabel = target.classification
? translateStatus(target.classification.category, t)
: null;
// MOOD_STABILITY headline switches from numeric σ to a verbal label.
// The numeric stays as a tooltip behind the pill so power users can
// still see the σ value.
const moodStabilityValue =
target.type === "MOOD_STABILITY" && target.current != null
? moodStabilityLabel(target.current)
: null;
const moodStabilityCopy =
moodStabilityValue != null
? t(`targets.mood.stability.${moodStabilityValue}`)
: null;
const showConsistency =
!target.insufficientData &&
target.consistency7d != null &&
target.consistency7d.length > 0;
const showLastMet =
target.lastMetGoalAt != null && !target.insufficientData;
const showStreak =
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.
const coachPrefill = buildTargetPrompt({
type: target.type,
locale,
current: target.current,
range: target.range,
unit: target.unit,
status: statusLabel ?? null,
streakDays: target.streakDays ?? 0,
daysInRange7d: target.daysInRange7d ?? 0,
});
const coachSources = coachScopeForTarget(target.type);
return (
<Card
data-slot="target-card"
data-target-type={target.type}
data-status={statusGroup}
className="flex h-full flex-col"
>
<CardHeader className="gap-2 pb-3 sm:gap-3">
{/* Row 1: icon + label (left) ⋯ status pill (right on sm+,
stacked on mobile). Stacking on mobile keeps the headline
number visible at sub-380px viewports where an inline pill
would push the value below the fold. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Icon
className={cn("size-4 shrink-0", iconColor)}
aria-hidden="true"
/>
<h3
className="text-muted-foreground text-[0.6875rem] font-medium uppercase tracking-[0.08em]"
data-slot="target-card-title"
>
{titleLabel}
</h3>
</div>
{statusLabel ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
data-slot="target-status-pill"
data-status={statusGroup}
className={cn(
"inline-flex items-center self-start rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 sm:self-auto",
statusPillStyle,
)}
>
{statusLabel}
</span>
</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>
) : null}
</div>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
{/* Row 2: big-number headline + trend chevron + unit */}
{target.current != null ? (
<div className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
{moodStabilityValue && moodStabilityCopy ? (
<span
className="text-foreground text-2xl font-semibold leading-none sm:text-3xl"
data-slot="target-headline-value"
data-mood-stability={moodStabilityValue}
>
{moodStabilityCopy}
</span>
) : isBp && bpDiastolic?.current != null ? (
<span
className="text-foreground flex items-baseline gap-1 text-2xl font-semibold leading-none sm:text-3xl"
data-slot="target-headline-value"
>
<span>{Math.round(target.current)}</span>
<span className="text-muted-foreground text-lg">/</span>
<span>{Math.round(bpDiastolic.current)}</span>
</span>
) : (
<span
className="text-foreground text-2xl font-semibold leading-none sm:text-3xl"
data-slot="target-headline-value"
>
{target.type === "BODY_FAT"
? target.current.toFixed(1)
: Math.round(target.current * 10) / 10}
</span>
)}
{!moodStabilityValue && (
<span className="text-muted-foreground text-sm">
{target.unit}
</span>
)}
<span className="ml-auto">
<TrendIcon trend={target.trend} />
</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="size-4" aria-hidden="true" />
<span className="text-sm">{t("targets.noMeasurementYet")}</span>
</div>
)}
{/* Row 3: primary range bar (only for cards whose underlying
metric has a defined band — the helper drops the bar
gracefully for derived ones without a range). */}
{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}
/>
)}
{/* Row 3b: 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>
)}
{/* Row 4: consistency strip (hidden when insufficient data) */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : target.insufficientData ? (
<p
className="text-muted-foreground text-xs italic"
data-slot="target-insufficient-data"
>
{t("targets.consistency.insufficientData")}
</p>
) : null}
{/* Row 5: recency + streak chips. Both conditional. */}
{(showLastMet || showStreak) && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{showLastMet && target.lastMetGoalAt && (
<span data-slot="target-last-met">
{t("targets.card.lastMet", {
when: formatRelativeDay(target.lastMetGoalAt, t),
})}
</span>
)}
{showStreak && (
<span
className="text-foreground inline-flex items-center gap-1.5 rounded-full bg-[var(--dracula-green)]/12 px-2 py-0.5 font-medium ring-1 ring-[var(--dracula-green)]/30"
data-slot="target-streak"
>
<span
className="size-1.5 rounded-full bg-[var(--dracula-green)]"
aria-hidden="true"
/>
{t("targets.card.streak", { count: String(target.streakDays) })}
</span>
)}
</div>
)}
{/* Medication breakdown (regimen-specific detail) */}
{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>
)}
{/* Row 6: footer — Coach CTA on the left (gated), source link
on the right (when available). Margin-top:auto pins the
footer to the card bottom so the grid rows align. */}
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 pt-1">
<TargetCoachButton
prefill={coachPrefill}
sources={coachSources}
onAskCoach={onAskCoach}
aiEnabled={aiEnabled}
className="-ml-2"
/>
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 text-xs"
>
<span>{target.source}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground ml-auto text-xs">
{target.source}
</span>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,78 @@
"use client";
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";
/**
* v1.4.25 W3e — per-card "Ask Coach about this" CTA.
*
* Only renders when the parent passes `aiEnabled === true`. The gate
* lives one level up (the `TargetsPage` reads `/api/insights/provider-chain`
* and threads the flag down); this component is dumb on purpose so
* tests can pin both branches.
*
* On click the button fires `onAskCoach({ prefill, scope })`. The
* parent owns the `CoachDrawer` state; this button doesn't know about
* the drawer at all.
*/
export interface TargetCoachButtonProps {
/**
* Pre-formatted question to seed the Coach drawer's composer. Locale
* pre-resolved by the caller (per `buildTargetPrompt(targetType,
* locale)` in `src/lib/ai/coach/target-prompts.ts`).
*/
prefill: string;
/**
* The single CoachScope.sources entry to narrow the snapshot to this
* metric. Empty array means "do not narrow"; the caller decides.
*/
sources: ReadonlyArray<CoachScopeSource>;
onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void;
/**
* Renders ONLY when this is true. The parent gates on
* `chainData?.activeProvider != null` so a user with no AI provider
* configured never sees a button that opens an empty drawer.
*/
aiEnabled: boolean;
className?: string;
}
export function TargetCoachButton({
prefill,
sources,
onAskCoach,
aiEnabled,
className,
}: TargetCoachButtonProps) {
const { t } = useTranslations();
if (!aiEnabled) return null;
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
onAskCoach({
prefill,
scope: {
sources: [...sources],
window: "last30days",
},
})
}
data-slot="target-coach-cta"
className={className}
>
<Sparkles className="size-3.5" aria-hidden="true" />
<span>{t("targets.coach.cta")}</span>
</Button>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
/**
* v1.4.25 W3e — per-target Coach prompt builder.
*
* Returns a locale-appropriate pre-filled question that seeds the
* Coach drawer's composer when the user clicks "Ask Coach about this"
* on a target card. The prompt mentions the live current value, the
* target range, the user's last-7-day cadence, and the streak (when
* present) so the model has enough context to give a grounded answer
* even before the snapshot lands.
*
* Prompts are deliberately question-shaped (not instructions). The
* user can rewrite freely; the prefill exists to remove the
* cold-start friction of typing the same question every time.
*
* Six metric templates × two locales = 12 prompts. Glucose contexts
* fall back to the "general" template so we don't ship a four-way
* context-aware glucose prompt before the iOS rollout decides whether
* to surface glucose-by-context in the iOS Targets view.
*
* NOTE on PROMPT_VERSION: the Coach prompt itself (the system prompt
* the model sees) does NOT change with this addition. The pre-fill is
* just text the user can edit before sending. PROMPT_VERSION ratchets
* when the assistant-facing prompt shifts; pre-fills do not, so we
* leave PROMPT_VERSION at 4.23.0. Confirm with Marc if a future
* change makes this prefill the canonical first turn.
*/
export interface TargetPromptInput {
type: string;
locale: "en" | "de";
current: number | null;
range: { min: number; max: number } | null;
unit: string;
status: string | null;
streakDays: number;
daysInRange7d: number;
}
type Template = (input: TargetPromptInput) => string;
function fmt(value: number | null, decimals = 1): string {
if (value == null) return "—";
return Number(value.toFixed(decimals)).toString();
}
const TEMPLATES_EN: Record<string, Template> = {
BLOOD_PRESSURE: (i) =>
`My latest blood pressure reading is ${fmt(i.current, 0)} mmHg systolic and my target band is ${i.range ? `${i.range.min}–${i.range.max} mmHg` : "not set"}. Over the last 7 days I've hit the band on ${i.daysInRange7d} days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days)` : ""}. What should I focus on this week?`,
BLOOD_PRESSURE_IN_TARGET: (i) =>
`My blood pressure has been in target ${i.daysInRange7d} of the last 7 days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days)` : ""}. Walk me through what's driving the trend and what I should change.`,
WEIGHT: (i) =>
`My current weight is ${fmt(i.current)} ${i.unit} and my healthy range is ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "not set"}. Over the last 7 days I've been in range on ${i.daysInRange7d} days. What should I focus on?`,
PULSE: (i) =>
`My resting pulse is ${fmt(i.current, 0)} ${i.unit} (target band ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "not set"}). How does this compare to a healthy pattern for me and what should I watch for?`,
BMI: (i) =>
`My current BMI is ${fmt(i.current)} ${i.unit} and the healthy range is ${i.range ? `${i.range.min}–${i.range.max}` : "18.5–24.9"}. How am I trending and what should I focus on?`,
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 verbal =
label === "stable"
? "stable"
: label === "variable"
? "variable"
: "highly variable";
return `My mood pattern is currently ${verbal}. What does this mean for me and what should I keep an eye on?`;
},
MEDICATION_COMPLIANCE: (i) =>
`My medication adherence is ${fmt(i.current, 0)}% over the last 7 days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days at goal)` : ""}. What can I change in my routine?`,
};
const TEMPLATES_DE: Record<string, Template> = {
BLOOD_PRESSURE: (i) =>
`Mein letzter Blutdruck-Wert liegt bei ${fmt(i.current, 0)} mmHg systolisch, mein Zielbereich ist ${i.range ? `${i.range.min}–${i.range.max} mmHg` : "nicht festgelegt"}. In den letzten 7 Tagen war ich an ${i.daysInRange7d} Tagen im Bereich${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage)` : ""}. Worauf sollte ich diese Woche achten?`,
BLOOD_PRESSURE_IN_TARGET: (i) =>
`Mein Blutdruck war an ${i.daysInRange7d} von 7 Tagen im Zielbereich${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage)` : ""}. Erklär mir bitte, was den Trend treibt und was ich ändern sollte.`,
WEIGHT: (i) =>
`Mein aktuelles Gewicht ist ${fmt(i.current)} ${i.unit}, mein gesunder Bereich liegt bei ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "nicht festgelegt"}. In den letzten 7 Tagen war ich an ${i.daysInRange7d} Tagen im Bereich. Worauf sollte ich mich konzentrieren?`,
PULSE: (i) =>
`Mein Ruhepuls ist ${fmt(i.current, 0)} ${i.unit} (Zielbereich ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "nicht festgelegt"}). Wie passt das zu einem gesunden Muster für mich und worauf sollte ich achten?`,
BMI: (i) =>
`Mein aktueller BMI liegt bei ${fmt(i.current)} ${i.unit}, der gesunde Bereich ist ${i.range ? `${i.range.min}–${i.range.max}` : "18,5–24,9"}. Wie ist mein Trend und worauf sollte ich achten?`,
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 verbal =
label === "stable"
? "stabil"
: label === "variable"
? "schwankend"
: "sehr schwankend";
return `Mein Stimmungsmuster ist aktuell ${verbal}. Was bedeutet das für mich und worauf sollte ich achten?`;
},
MEDICATION_COMPLIANCE: (i) =>
`Meine Einnahmetreue liegt in den letzten 7 Tagen bei ${fmt(i.current, 0)}%${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage im Ziel)` : ""}. Was könnte ich an meiner Routine ändern?`,
};
const GENERAL_EN: Template = (i) =>
`I'd like to understand how I'm doing on ${i.type.toLowerCase().replace(/_/g, " ")} this week. Where am I doing well and where should I focus?`;
const GENERAL_DE: Template = (i) =>
`Wie steht es um meine ${i.type.toLowerCase().replace(/_/g, " ")} diese Woche? Wo läuft es gut und worauf sollte ich achten?`;
export function buildTargetPrompt(input: TargetPromptInput): string {
const table = input.locale === "de" ? TEMPLATES_DE : TEMPLATES_EN;
const fallback = input.locale === "de" ? GENERAL_DE : GENERAL_EN;
const template = table[input.type] ?? fallback;
return template(input);
}
+48
View File
@@ -0,0 +1,48 @@
import type { CoachScopeSource } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — per-target Coach scope source mapping.
*
* The per-card "Ask Coach about this" CTA narrows the snapshot to the
* single source the user is asking about. For derived metrics (BMI
* derives from weight; mood stability derives from mood) we map to the
* underlying source — the Coach prompt-builder knows how to talk
* about the derived view but the data it needs lives in the source.
*
* Targets without a CoachScopeSource counterpart (glucose contexts,
* steps, sleep, body-fat as of v1.4.23) fall back to an empty array,
* which the drawer reads as "do not narrow — use defaults". The
* Coach defaults already include the five-source set, so the snapshot
* stays useful even when this map has no opinion.
*/
export function coachScopeForTarget(
targetType: string,
): ReadonlyArray<CoachScopeSource> {
switch (targetType) {
case "WEIGHT":
case "BMI":
return ["weight"];
case "BLOOD_PRESSURE":
case "BLOOD_PRESSURE_IN_TARGET":
return ["bp"];
case "PULSE":
return ["pulse"];
case "MOOD_SCORE":
case "MOOD_STABILITY":
return ["mood"];
case "MEDICATION_COMPLIANCE":
return ["compliance"];
case "SLEEP_DURATION":
return ["sleep"];
case "ACTIVITY_STEPS":
return ["steps"];
case "BODY_FAT":
// No 1:1 source — the snapshot's weight + general blocks carry
// the surrounding context. Empty scope = "use defaults".
return [];
default:
// Glucose contexts + future targets — defaults will surface the
// available scopes in the drawer.
return [];
}
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { moodStabilityLabel } from "../mood-stability-label";
/**
* v1.4.25 W3e — threshold contract for the verbal MOOD_STABILITY label.
* The card switches from raw σ to one of three buckets; the rule lives
* here so the boundaries can be unit-tested without React.
*/
describe("moodStabilityLabel", () => {
it("returns 'stable' for σ below 1.0", () => {
expect(moodStabilityLabel(0)).toBe("stable");
expect(moodStabilityLabel(0.42)).toBe("stable");
expect(moodStabilityLabel(0.999)).toBe("stable");
});
it("returns 'variable' for σ in [1.0, 2.0)", () => {
expect(moodStabilityLabel(1.0)).toBe("variable");
expect(moodStabilityLabel(1.5)).toBe("variable");
expect(moodStabilityLabel(1.999)).toBe("variable");
});
it("returns 'highlyVariable' for σ ≥ 2.0", () => {
expect(moodStabilityLabel(2.0)).toBe("highlyVariable");
expect(moodStabilityLabel(3.5)).toBe("highlyVariable");
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* v1.4.25 W3e — verbal label for the MOOD_STABILITY target headline.
*
* The card used to render the raw σ value ("0.42 σ"), which most users
* cannot interpret. Replace with a three-state verbal label keyed
* against translation entries `targets.mood.stability.{stable,
* variable,highlyVariable}`.
*
* Thresholds (Marc-set, documented in the W3e implementation plan):
* σ < 1.0 → "stable" (stabil / stable)
* σ < 2.0 → "variable" (schwankend / variable)
* σ ≥ 2.0 → "highly-variable" (sehr schwankend / highly variable)
*
* Pure helper so the threshold logic can be unit-tested in isolation
* without React. Returns the stable English key — callers (the card,
* the Coach prompt builder) own the locale resolution.
*/
export type MoodStabilityLabel = "stable" | "variable" | "highlyVariable";
export function moodStabilityLabel(sigma: number): MoodStabilityLabel {
if (sigma < 1.0) return "stable";
if (sigma < 2.0) return "variable";
return "highlyVariable";
}