mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
f3c9e2d7a6
commit
97583cf891
14 files changed
+1620
No files matched your search
@@ -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}",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user