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

+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";
}