mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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
@@ -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