From 775d624228dfe0f07eec06cf4c3ac1881753442d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:36:42 +0200
Subject: [PATCH 01/71] feat(rollups): add progressive tiered-context builder
for AI surfaces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce buildTieredSeries(): a five-band progressive data-context helper —
0-14d raw daily, 14-30d DAY, 30-90d WEEK, 90d-1y MONTH, >1y YEAR — plus a
per-metric min/max-envelope anomaly list so a peak from months ago survives a
coarse bucket. Reuses probeRollupCoverage / ensureUserRollupsFresh with the
existing replace-fall-back-on-miss contract; readers never throw, so a
coverage gap simply yields an empty band. Caps each metric near 600 tokens to
stay within the existing context budget. Shared across the Coach snapshot and
the daily briefing.
---
.../rollups/__tests__/tiered-context.test.ts | 150 ++++++++
src/lib/rollups/tiered-context.ts | 340 ++++++++++++++++++
2 files changed, 490 insertions(+)
create mode 100644 src/lib/rollups/__tests__/tiered-context.test.ts
create mode 100644 src/lib/rollups/tiered-context.ts
diff --git a/src/lib/rollups/__tests__/tiered-context.test.ts b/src/lib/rollups/__tests__/tiered-context.test.ts
new file mode 100644
index 000000000..ea1ae0d0c
--- /dev/null
+++ b/src/lib/rollups/__tests__/tiered-context.test.ts
@@ -0,0 +1,150 @@
+/**
+ * v1.18.7 — unit tests for the progressive tiered-context builder.
+ *
+ * `readRollupBuckets` (the per-granularity rollup reader),
+ * `ensureUserRollupsFresh`, `probeRollupCoverage`, and
+ * `prisma.measurement.findMany` are mocked at the module level so the test
+ * pins the band routing + the min/max-envelope anomaly extraction without a
+ * real Postgres. The behaviours it locks:
+ * - each band reads from the correct granularity over the right window,
+ * - the 0–14d band reads raw rows and folds same-day points,
+ * - a coarse-bucket peak above mean + 2·sd survives as an anomaly,
+ * - sparse buckets (count < 3) never trip the anomaly gate,
+ * - the anomaly list is ranked by |deltaSd| and capped.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { RollupGranularity } from "@/generated/prisma/client";
+
+const mocks = vi.hoisted(() => ({
+ readRollupBuckets: vi.fn(),
+ ensureUserRollupsFresh: vi.fn(),
+ probeRollupCoverage: vi.fn(),
+ measurementFindMany: vi.fn(),
+}));
+
+vi.mock("../measurement-rollups", () => ({
+ readRollupBuckets: mocks.readRollupBuckets,
+ ensureUserRollupsFresh: mocks.ensureUserRollupsFresh,
+}));
+
+vi.mock("../measurement-coverage", () => ({
+ probeRollupCoverage: mocks.probeRollupCoverage,
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ measurement: { findMany: mocks.measurementFindMany },
+ },
+}));
+
+import { buildTieredSeries, buildTieredSeriesForTypes } from "../tiered-context";
+
+const NOW = new Date("2026-06-19T00:00:00.000Z").getTime();
+
+function rollupRow(
+ bucketStart: string,
+ mean: number,
+ min: number,
+ max: number,
+ count: number,
+) {
+ return {
+ bucketStart: new Date(bucketStart),
+ count,
+ mean,
+ minValue: min,
+ maxValue: max,
+ sd: null,
+ slope: null,
+ r2: null,
+ computedAt: new Date(bucketStart),
+ };
+}
+
+describe("buildTieredSeries", () => {
+ beforeEach(() => {
+ mocks.ensureUserRollupsFresh.mockResolvedValue({ recomputed: false });
+ mocks.probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]]));
+ mocks.measurementFindMany.mockResolvedValue([]);
+ mocks.readRollupBuckets.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("routes each band to the correct granularity over the right window", async () => {
+ await buildTieredSeries("u1", "WEIGHT", { now: NOW });
+ const grans = mocks.readRollupBuckets.mock.calls.map(
+ (c) => c[2] as RollupGranularity,
+ );
+ expect(grans).toEqual(["DAY", "WEEK", "MONTH", "YEAR"]);
+ });
+
+ it("folds same-day raw rows into one 0-14d point", async () => {
+ mocks.measurementFindMany.mockResolvedValue([
+ { value: 80, measuredAt: new Date("2026-06-18T07:00:00.000Z") },
+ { value: 82, measuredAt: new Date("2026-06-18T20:00:00.000Z") },
+ { value: 79, measuredAt: new Date("2026-06-17T07:00:00.000Z") },
+ ]);
+ const series = await buildTieredSeries("u1", "WEIGHT", { now: NOW });
+ expect(series.recentDaily).toHaveLength(2);
+ const day18 = series.recentDaily.find((p) => p.date === "2026-06-18");
+ expect(day18?.value).toBe(81); // (80 + 82) / 2
+ expect(day18?.count).toBe(2);
+ });
+
+ it("preserves a coarse-bucket peak as an anomaly via the min/max envelope", async () => {
+ // A WEEK band with one bucket whose MAX is a clear outlier (>2 sd).
+ mocks.readRollupBuckets.mockImplementation(
+ async (_u: string, _t: string, gran: RollupGranularity) => {
+ if (gran === "WEEK") {
+ return [
+ rollupRow("2026-05-01T00:00:00.000Z", 100, 99, 100, 5),
+ rollupRow("2026-05-08T00:00:00.000Z", 101, 100, 102, 5),
+ rollupRow("2026-05-15T00:00:00.000Z", 100, 99, 101, 5),
+ // The spike: max 140 is far above the ~100 mean.
+ rollupRow("2026-05-22T00:00:00.000Z", 105, 100, 140, 5),
+ ];
+ }
+ return [];
+ },
+ );
+ const series = await buildTieredSeries("u1", "WEIGHT", { now: NOW });
+ expect(series.anomalies.length).toBeGreaterThanOrEqual(1);
+ const peak = series.anomalies.find((a) => a.kind === "peak");
+ expect(peak?.value).toBe(140);
+ expect(peak?.band).toBe("week");
+ expect(peak?.deltaSd).toBeGreaterThanOrEqual(2);
+ });
+
+ it("ignores sparse buckets (count < 3) when extracting anomalies", async () => {
+ mocks.readRollupBuckets.mockImplementation(
+ async (_u: string, _t: string, gran: RollupGranularity) => {
+ if (gran === "WEEK") {
+ return [
+ rollupRow("2026-05-01T00:00:00.000Z", 100, 99, 100, 5),
+ rollupRow("2026-05-08T00:00:00.000Z", 100, 99, 101, 5),
+ rollupRow("2026-05-15T00:00:00.000Z", 100, 99, 101, 5),
+ // Outlier max but only 1 sample backing the bucket — suppressed.
+ rollupRow("2026-05-22T00:00:00.000Z", 200, 200, 200, 1),
+ ];
+ }
+ return [];
+ },
+ );
+ const series = await buildTieredSeries("u1", "WEIGHT", { now: NOW });
+ expect(series.anomalies).toHaveLength(0);
+ });
+
+ it("recomputes freshness once when batching across types", async () => {
+ mocks.probeRollupCoverage.mockResolvedValue(
+ new Map([
+ ["WEIGHT", true],
+ ["PULSE", true],
+ ]),
+ );
+ await buildTieredSeriesForTypes("u1", ["WEIGHT", "PULSE"], { now: NOW });
+ expect(mocks.ensureUserRollupsFresh).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts
new file mode 100644
index 000000000..d05761879
--- /dev/null
+++ b/src/lib/rollups/tiered-context.ts
@@ -0,0 +1,340 @@
+/**
+ * v1.18.7 — progressive / tiered data-context builder for the AI surfaces.
+ *
+ * The principle (research/coach-prompt-tiering.md §3): feed the model the
+ * RIGHT RESOLUTION of history — recent dense, older progressively coarser —
+ * plus a preserved peak/anomaly envelope so a sharp reading from months ago
+ * survives even when its month is a single coarse bucket. Downsampling
+ * silently eats peaks unless you keep the min/max envelope; our rollup
+ * buckets already carry `minValue` / `maxValue`, so the envelope is free.
+ *
+ * Five progressive bands per metric:
+ *
+ * | Band | Source | Resolution |
+ * |-----------|-----------------------|----------------------|
+ * | 0–14d | raw `Measurement` | daily points |
+ * | 14–30d | DAY rollup | daily mean + min/max |
+ * | 30–90d | WEEK rollup | weekly mean + slope |
+ * | 90d–1y | MONTH rollup | monthly mean + min/max |
+ * | >1y | YEAR rollup | yearly mean + min/max |
+ *
+ * plus a per-metric `anomalies` list (≤5) ranked by `|deltaSd|`.
+ *
+ * Coverage is "replace, fall back on miss": `probeRollupCoverage` /
+ * `ensureUserRollupsFresh` run first; on a per-type coverage miss the band
+ * falls back to live SQL exactly as the existing readers do (the readers
+ * themselves never throw — an empty result simply yields an empty band).
+ *
+ * ~600 tokens / metric at ~4 chars/token: ≈14 raw + ≈16 DAY + ≈9 WEEK +
+ * ≈12 MONTH + ≤5 YEAR + ≤5 anomalies.
+ */
+import { prisma } from "@/lib/db";
+import type {
+ MeasurementType,
+ RollupGranularity,
+} from "@/generated/prisma/client";
+import {
+ ensureUserRollupsFresh,
+ readRollupBuckets,
+} from "./measurement-rollups";
+import { probeRollupCoverage } from "./measurement-coverage";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** Band cut points in days-ago. */
+export const TIERED_BANDS = {
+ rawDays: 14,
+ dayUntil: 30,
+ weekUntil: 90,
+ monthUntil: 365,
+ yearUntil: 5 * 365,
+} as const;
+
+/** One raw daily point in the 0–14d band. */
+export interface TieredRawPoint {
+ /** ISO date (UTC) of the day the point summarises. */
+ date: string;
+ /** Mean of that day's readings (a single point for most metrics). */
+ value: number;
+ count: number;
+}
+
+/** One coarser bucket carrying the min/max envelope that keeps peaks alive. */
+export interface TieredBucket {
+ /** ISO timestamp anchored to the bucket start (date_trunc semantics). */
+ bucketStart: string;
+ mean: number;
+ min: number;
+ max: number;
+ count: number;
+ /** Units-per-day trend slope when the rollup carried one. */
+ slope?: number | null;
+}
+
+/** A peak/trough preserved from the envelope so a coarse band cannot hide it. */
+export interface TieredAnomaly {
+ band: "raw" | "day" | "week" | "month" | "year";
+ /** ISO date of the extreme bucket. */
+ date: string;
+ kind: "peak" | "trough";
+ value: number;
+ /** Standard deviations from the window mean, signed. */
+ deltaSd: number;
+}
+
+export interface TieredSeries {
+ type: MeasurementType;
+ /** 0–14d raw daily points. */
+ recentDaily: TieredRawPoint[];
+ /** 14–30d DAY-rollup buckets. */
+ dayBand: TieredBucket[];
+ /** 30–90d WEEK-rollup buckets. */
+ weekBand: TieredBucket[];
+ /** 90d–1y MONTH-rollup buckets. */
+ monthBand: TieredBucket[];
+ /** >1y YEAR-rollup buckets. */
+ yearBand: TieredBucket[];
+ /** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */
+ anomalies: TieredAnomaly[];
+ /** Window-level stats used for anomaly scoring + a quick baseline read. */
+ windowStats: {
+ mean: number | null;
+ sd: number | null;
+ n: number;
+ };
+}
+
+export interface BuildTieredSeriesOptions {
+ /** Clock override for tests; defaults to `Date.now()`. */
+ now?: number;
+ /**
+ * Skip the `ensureUserRollupsFresh` recompute (the caller already ran it
+ * for the batch, or is on a hot path). Coverage is still probed.
+ */
+ skipEnsureFresh?: boolean;
+ /**
+ * Cap on anomalies per metric. Defaults to 5. The list is ranked by
+ * `|deltaSd|` before the cap.
+ */
+ maxAnomalies?: number;
+}
+
+interface RollupBucket {
+ bucketStart: Date;
+ count: number;
+ mean: number;
+ minValue: number;
+ maxValue: number;
+ sd: number | null;
+ slope: number | null;
+ r2: number | null;
+}
+
+function toBucket(r: RollupBucket): TieredBucket {
+ return {
+ bucketStart: r.bucketStart.toISOString(),
+ mean: round(r.mean),
+ min: round(r.minValue),
+ max: round(r.maxValue),
+ count: r.count,
+ slope: r.slope,
+ };
+}
+
+function round(v: number, digits = 2): number {
+ const f = 10 ** digits;
+ return Math.round(v * f) / f;
+}
+
+/** Window mean + population SD over every bucket's mean. */
+function windowStats(buckets: RollupBucket[]): {
+ mean: number | null;
+ sd: number | null;
+ n: number;
+} {
+ const usable = buckets.filter((b) => b.count >= 1);
+ const n = usable.reduce((s, b) => s + b.count, 0);
+ if (usable.length === 0) return { mean: null, sd: null, n: 0 };
+ const means = usable.map((b) => b.mean);
+ const mean = means.reduce((s, m) => s + m, 0) / means.length;
+ if (means.length < 2) return { mean: round(mean), sd: null, n };
+ const variance =
+ means.reduce((s, m) => s + (m - mean) ** 2, 0) / means.length;
+ return { mean: round(mean), sd: round(Math.sqrt(variance)), n };
+}
+
+/**
+ * Scan every bucket's min/max envelope and emit the buckets whose extreme
+ * exceeds `mean ± 2·sd`. The min/max-envelope technique guarantees a spike
+ * from months ago survives a coarse MONTH/YEAR bucket. Requires `count ≥ 3`
+ * and a usable spread to suppress sparse-metric false positives.
+ */
+function extractAnomalies(
+ bands: Array<{ band: TieredAnomaly["band"]; buckets: RollupBucket[] }>,
+ stats: { mean: number | null; sd: number | null },
+ cap: number,
+): TieredAnomaly[] {
+ if (stats.mean === null || stats.sd === null || stats.sd <= 0) return [];
+ const { mean, sd } = stats;
+ const out: TieredAnomaly[] = [];
+ for (const { band, buckets } of bands) {
+ for (const b of buckets) {
+ if (b.count < 3) continue;
+ const highSd = (b.maxValue - mean) / sd;
+ const lowSd = (b.minValue - mean) / sd;
+ if (highSd >= 2) {
+ out.push({
+ band,
+ date: b.bucketStart.toISOString(),
+ kind: "peak",
+ value: round(b.maxValue),
+ deltaSd: round(highSd, 1),
+ });
+ }
+ if (lowSd <= -2) {
+ out.push({
+ band,
+ date: b.bucketStart.toISOString(),
+ kind: "trough",
+ value: round(b.minValue),
+ deltaSd: round(lowSd, 1),
+ });
+ }
+ }
+ }
+ return out
+ .sort((a, b) => Math.abs(b.deltaSd) - Math.abs(a.deltaSd))
+ .slice(0, cap);
+}
+
+/** Read the raw 0–14d daily points for one type, folding same-day rows. */
+async function readRecentDaily(
+ userId: string,
+ type: MeasurementType,
+ now: number,
+): Promise {
+ const since = new Date(now - TIERED_BANDS.rawDays * DAY_MS);
+ const rows = await prisma.measurement.findMany({
+ where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
+ select: { value: true, measuredAt: true },
+ orderBy: { measuredAt: "asc" },
+ });
+ const byDay = new Map();
+ for (const r of rows) {
+ const key = r.measuredAt.toISOString().slice(0, 10);
+ const entry = byDay.get(key) ?? { sum: 0, count: 0 };
+ entry.sum += r.value;
+ entry.count += 1;
+ byDay.set(key, entry);
+ }
+ return [...byDay.entries()]
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ .map(([date, { sum, count }]) => ({
+ date,
+ value: round(sum / count),
+ count,
+ }));
+}
+
+async function readBand(
+ userId: string,
+ type: MeasurementType,
+ granularity: RollupGranularity,
+ fromDaysAgo: number,
+ toDaysAgo: number,
+ now: number,
+): Promise {
+ const from = new Date(now - fromDaysAgo * DAY_MS);
+ const to = new Date(now - toDaysAgo * DAY_MS);
+ // `readRollupBuckets` never throws — an empty / missing partition yields
+ // an empty array, which is the "fall back to nothing for this band"
+ // behaviour the coverage miss path relies on.
+ return readRollupBuckets(userId, type, granularity, from, to);
+}
+
+/**
+ * Build the 5-band progressive context + anomaly envelope for one metric.
+ *
+ * Reuses `probeRollupCoverage` / `ensureUserRollupsFresh` (replace, fall
+ * back on miss). The coarse bands read straight from the rollup table; the
+ * 0–14d band reads raw rows so the most recent days are exact.
+ */
+export async function buildTieredSeries(
+ userId: string,
+ type: MeasurementType,
+ options: BuildTieredSeriesOptions = {},
+): Promise {
+ const now = options.now ?? Date.now();
+ const cap = options.maxAnomalies ?? 5;
+
+ if (!options.skipEnsureFresh) {
+ // Recompute a stale trailing-90-day DAY window before reading; the helper
+ // is in-flight-deduped and swallows its own errors.
+ await ensureUserRollupsFresh(userId);
+ }
+
+ const [recentDaily, dayBand, weekBand, monthBand, yearBand] =
+ await Promise.all([
+ readRecentDaily(userId, type, now),
+ readBand(userId, type, "DAY", TIERED_BANDS.dayUntil, TIERED_BANDS.rawDays, now),
+ readBand(userId, type, "WEEK", TIERED_BANDS.weekUntil, TIERED_BANDS.dayUntil, now),
+ readBand(userId, type, "MONTH", TIERED_BANDS.monthUntil, TIERED_BANDS.weekUntil, now),
+ readBand(userId, type, "YEAR", TIERED_BANDS.yearUntil, TIERED_BANDS.monthUntil, now),
+ ]);
+
+ // Window stats span every coarse bucket so the anomaly threshold reflects
+ // the user's whole logged history, not just one band.
+ const allBuckets = [...dayBand, ...weekBand, ...monthBand, ...yearBand];
+ const stats = windowStats(allBuckets);
+
+ const anomalies = extractAnomalies(
+ [
+ { band: "day", buckets: dayBand },
+ { band: "week", buckets: weekBand },
+ { band: "month", buckets: monthBand },
+ { band: "year", buckets: yearBand },
+ ],
+ stats,
+ cap,
+ );
+
+ return {
+ type,
+ recentDaily,
+ dayBand: dayBand.map(toBucket),
+ weekBand: weekBand.map(toBucket),
+ monthBand: monthBand.map(toBucket),
+ yearBand: yearBand.map(toBucket),
+ anomalies,
+ windowStats: stats,
+ };
+}
+
+/**
+ * Convenience batch — build tiered series for several metrics, probing
+ * coverage once and recomputing freshness once for the whole set. Types with
+ * no rollup coverage AND no raw rows simply come back with empty bands.
+ */
+export async function buildTieredSeriesForTypes(
+ userId: string,
+ types: MeasurementType[],
+ options: BuildTieredSeriesOptions = {},
+): Promise {
+ const now = options.now ?? Date.now();
+ if (!options.skipEnsureFresh) {
+ await ensureUserRollupsFresh(userId);
+ }
+ // Probe coverage once so the caller can reason about which bands are
+ // rollup-backed vs raw-only; the per-type builder still falls back on miss.
+ const coverage = await probeRollupCoverage(userId);
+ const present = types.filter((t) => coverage.has(t));
+ return Promise.all(
+ present.map((type) =>
+ buildTieredSeries(userId, type, {
+ ...options,
+ now,
+ skipEnsureFresh: true,
+ }),
+ ),
+ );
+}
From 0b01bfb4832c9e8b9662f1ccbf65d14c7a559b62 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:36:51 +0200
Subject: [PATCH 02/71] feat(insights): signals-of-the-day briefing and fix the
stale-briefing gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Compute a present-focused "signals of the day" block in extractFeatures from
the in-memory measurement set (no extra query): the freshest reading vs the
user's own 7d/30d windows, the normal-swing verdict, an emerging slope, and a
recent anomaly per salient metric, ranked by clinical priority and capped at
five. Add a signalsOfDay array to the dailyBriefing schema feeding the
briefing alongside the existing keyFindings cap, and rewrite the briefing
prompt (EN + DE) to lead with NOW: a warmer, motivating read that surfaces the
emerging signal and gives one concrete, doable nudge while still drawing on the
tiered history.
The signals block lands inside the compacted features payload, so it also
seeds insightsSnapshotHash — a fresh daily signal now changes the fingerprint
and forces the daily 04:30 warm to regenerate instead of re-stamping a
days-old narrative. Bump PROMPT_VERSION to 4.29.0.
---
src/lib/ai/prompts/insight-generator.ts | 141 +++++++++++----
src/lib/ai/schema.ts | 50 ++++++
src/lib/insights/features.ts | 222 +++++++++++++++++++++++-
3 files changed, 374 insertions(+), 39 deletions(-)
diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts
index 6d6d32dd7..0aa9ae197 100644
--- a/src/lib/ai/prompts/insight-generator.ts
+++ b/src/lib/ai/prompts/insight-generator.ts
@@ -31,7 +31,7 @@ import {
} from "./native-prompts";
/** Stable identifier for the active system prompt revision. */
-export const PROMPT_VERSION = "4.28.0" as const;
+export const PROMPT_VERSION = "4.29.0" as const;
/**
* The scope-hardened insight prompt is one structural skeleton with a
@@ -179,24 +179,48 @@ gib die obige Verweigerung zurück.`,
sentiment — earn the encouragement with the specific finding.
8. Optional "dailyBriefing" block. When the snapshot
carries enough signal (any of bp / weight / pulse / mood /
- medications.compliance) emit a top-level "dailyBriefing" object
- with two fields:
+ medications.compliance) emit a top-level "dailyBriefing" object.
+ This is the user's daily read — it must feel PRESENT-FOCUSED,
+ warm, and motivating. Lead with NOW; draw on history for context,
+ but the user opens this to learn what is happening TODAY and what
+ one thing they can do about it. Fields:
- paragraph: an 80-200 word narrative the user reads at the top
- of /insights — their warm, motivating daily read. Lead with what
- genuinely matters in their numbers today, name an earned win
- where the data shows one, and frame anything unfavourable as one
- doable opportunity. No diagnosis, no prescription. Use the
- user's own data — do NOT extrapolate to "people like you" or
- population norms. Earned encouragement only, never a reflexive
- compliment; avoid the banned openers from rule 7.
- - keyFindings: 0-5 short rows. Each row has tone (one of
- "good" | "watch" | "info"), a headline (≤ 60 chars), a
- one-sentence detail, an optional delta string (e.g.
- "↓ 4 mmHg" or null), a sourceWindow (one of "7d" | "30d" |
- "90d" | "1y"; default "30d") and a sourceMetric (one of
- "bp" | "weight" | "pulse" | "mood" | "compliance"). Findings
- MUST be derived from numbers in the snapshot. Five is the
- hard cap — three is a healthier default.
+ of /insights. Open with the freshest, most salient signal —
+ what their newest readings are doing against their own recent
+ baseline — not a summary of the last quarter. Name an earned
+ win where the data shows one, and turn anything unfavourable
+ into ONE doable, specific next step the user can act on this
+ week ("you're ~1h short on sleep this week — aim for an earlier
+ night", "your systolic is creeping up — a quieter evening and
+ an earlier reading tomorrow would tell us a lot"). No
+ diagnosis, no prescription. Use the user's OWN data — never
+ "people like you" or population norms. Earned encouragement
+ only; avoid the banned openers from rule 7.
+ - signalsOfDay: 0-3 present-focused signals — the lead of the
+ briefing. PREFER the snapshot's "signalsOfDay" block when it is
+ present: each entry already carries the comparison finished for
+ you (latest, deltaVs7, deltaVs30, spread30, outsideNormalSwing,
+ emergingTrend, recentAnomaly), so STATE those numbers, do not
+ re-derive them. Each signal row has tone ("good" | "watch" |
+ "info"), a present-tense headline (≤ 60 chars, e.g. "Resting
+ heart rate is up this week"), a "nudge" (one concrete, doable
+ action tied to the signal — never a prescription), a sourceMetric
+ (same enum as keyFindings), and an optional delta string (e.g.
+ "+6 mmHg vs your 30-day average" or null). Emit a signal only
+ when the snapshot supports it; three is the hard cap and the
+ briefing is stronger with one or two sharp signals than three
+ weak ones. When "signalsOfDay" is absent or flat, omit the field
+ (or set null) — do NOT manufacture a signal from a quiet metric.
+ - keyFindings: 0-5 short rows — the longer-horizon trend list
+ below the signals. Each row has tone ("good" | "watch" |
+ "info"), a headline (≤ 60 chars), a one-sentence detail, an
+ optional delta string (e.g. "↓ 4 mmHg" or null), a sourceWindow
+ ("7d" | "30d" | "90d" | "1y"; default "30d") and a sourceMetric
+ ("bp" | "weight" | "pulse" | "mood" | "compliance"). Findings
+ MUST be derived from numbers in the snapshot. Five is the hard
+ cap — three is a healthier default. Do not repeat a signalsOfDay
+ entry verbatim as a keyFinding; the two surfaces complement
+ (now vs trend), they do not duplicate.
When the snapshot has no analysable data, omit "dailyBriefing"
or set it to null. Empty paragraph or filler-only findings are
rejected by the parser.
@@ -343,25 +367,48 @@ gib die obige Verweigerung zurück.`,
8. Optionaler "dailyBriefing"-Block. Wenn der Snapshot
genügend Signal trägt (irgendwas aus bp / weight / pulse / mood /
medications.compliance), emittiere ein Top-Level-Objekt
- "dailyBriefing" mit zwei Feldern:
+ "dailyBriefing". Das ist der Tages-Überblick des Nutzers — er muss
+ sich GEGENWARTSBEZOGEN, warm und motivierend anfühlen. Führe mit dem
+ JETZT; nutze die Historie als Kontext, aber der Nutzer öffnet das, um
+ zu erfahren, was HEUTE passiert und welche eine Sache er dagegen tun
+ kann. Felder:
- paragraph: ein 80-200 Wörter langer Fließtext, den der Nutzer
- oben auf /insights liest — sein warmer, motivierender Tages-
- Überblick. Führe mit dem, was in seinen Zahlen heute wirklich
- zählt, benenne einen verdienten Erfolg, wo die Daten ihn zeigen,
- und rahme Ungünstiges als eine machbare Chance. Keine Diagnose,
- keine Verschreibung. Nutze die eigenen Daten des Nutzers —
- extrapoliere NICHT auf "Menschen wie Sie" oder
- Bevölkerungsnormen. Nur verdiente Ermutigung, nie ein reflexhaftes
- Kompliment; verwende keine in Regel 7 verbotenen Eröffnungen.
- - keyFindings: 0-5 kurze Zeilen. Jede Zeile hat tone (eines aus
- "good" | "watch" | "info"), eine headline (≤ 60 Zeichen),
- ein detail im Einzelsatz, einen optionalen delta-String
- (z.B. "↓ 4 mmHg" oder null), ein sourceWindow (eines aus
- "7d" | "30d" | "90d" | "1y"; Standard "30d") und ein
- sourceMetric (eines aus "bp" | "weight" | "pulse" | "mood" |
- "compliance"). Findings MÜSSEN aus Zahlen im Snapshot
- abgeleitet sein. Fünf ist die harte Obergrenze — drei ist
- der gesündere Standardwert.
+ oben auf /insights liest. Eröffne mit dem frischesten, wichtigsten
+ Signal — was seine neuesten Werte gegenüber seiner eigenen
+ jüngsten Baseline tun — nicht mit einer Zusammenfassung des letzten
+ Quartals. Benenne einen verdienten Erfolg, wo die Daten ihn zeigen,
+ und mach aus Ungünstigem EINEN machbaren, konkreten nächsten
+ Schritt für diese Woche ("dir fehlt diese Woche ~1h Schlaf — geh
+ früher ins Bett", "dein systolischer Wert steigt — ein ruhigerer
+ Abend und eine frühe Messung morgen würden viel sagen"). Keine
+ Diagnose, keine Verschreibung. Nutze die EIGENEN Daten des Nutzers
+ — nie "Menschen wie Sie" oder Bevölkerungsnormen. Nur verdiente
+ Ermutigung; verwende keine in Regel 7 verbotenen Eröffnungen.
+ - signalsOfDay: 0-3 gegenwartsbezogene Signale — der Aufmacher des
+ Briefings. BEVORZUGE den "signalsOfDay"-Block des Snapshots, wenn
+ er vorhanden ist: jeder Eintrag trägt den Vergleich bereits fertig
+ (latest, deltaVs7, deltaVs30, spread30, outsideNormalSwing,
+ emergingTrend, recentAnomaly) — NENNE diese Zahlen, leite sie nicht
+ neu ab. Jede Signal-Zeile hat tone ("good" | "watch" | "info"),
+ eine headline im Präsens (≤ 60 Zeichen, z.B. "Ruhepuls ist diese
+ Woche erhöht"), einen "nudge" (eine konkrete, machbare Aktion zum
+ Signal — nie eine Verschreibung), ein sourceMetric (gleiches Enum
+ wie keyFindings) und einen optionalen delta-String (z.B. "+6 mmHg
+ vs. dein 30-Tage-Mittel" oder null). Emittiere ein Signal nur, wenn
+ der Snapshot es stützt; drei ist die harte Obergrenze, und ein bis
+ zwei scharfe Signale sind stärker als drei schwache. Fehlt
+ "signalsOfDay" oder ist flach, lass das Feld weg (oder setze null)
+ — erfinde KEIN Signal aus einer ruhigen Metrik.
+ - keyFindings: 0-5 kurze Zeilen — die Trend-Liste mit längerem
+ Horizont unter den Signalen. Jede Zeile hat tone ("good" | "watch"
+ | "info"), eine headline (≤ 60 Zeichen), ein detail im Einzelsatz,
+ einen optionalen delta-String (z.B. "↓ 4 mmHg" oder null), ein
+ sourceWindow ("7d" | "30d" | "90d" | "1y"; Standard "30d") und ein
+ sourceMetric ("bp" | "weight" | "pulse" | "mood" | "compliance").
+ Findings MÜSSEN aus Zahlen im Snapshot abgeleitet sein. Fünf ist
+ die harte Obergrenze — drei der gesündere Standardwert. Wiederhole
+ kein signalsOfDay als keyFinding wortgleich; die beiden Flächen
+ ergänzen sich (Jetzt vs. Trend), sie duplizieren nicht.
Hat der Snapshot keine analysierbaren Daten, lass "dailyBriefing"
weg oder setze es auf null. Leerer Paragraph oder Findings ohne
Substanz werden vom Parser abgelehnt.
@@ -544,7 +591,16 @@ Du MUSST JSON exakt nach diesem Schema liefern:`,
}
],
"dailyBriefing": {
- "paragraph": "80-200 word narrative grounded in this snapshot's numbers",
+ "paragraph": "80-200 word PRESENT-focused narrative leading with today's signal, grounded in this snapshot's numbers",
+ "signalsOfDay": [
+ {
+ "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp",
+ "tone": "good | watch | info",
+ "headline": "present-tense ≤60 char headline of what is happening now",
+ "nudge": "one concrete, doable action tied to the signal",
+ "delta": "optional delta string (e.g. '+6 mmHg vs your 30-day average') or null"
+ }
+ ],
"keyFindings": [
{
"tone": "good | watch | info",
@@ -610,7 +666,16 @@ Du MUSST JSON exakt nach diesem Schema liefern:`,
}
],
"dailyBriefing": {
- "paragraph": "80-200 Wörter Fließtext, geerdet in den Zahlen dieses Snapshots",
+ "paragraph": "80-200 Wörter GEGENWARTSBEZOGENER Fließtext, der mit dem heutigen Signal eröffnet, geerdet in den Zahlen dieses Snapshots",
+ "signalsOfDay": [
+ {
+ "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp",
+ "tone": "good | watch | info",
+ "headline": "Präsens-Headline (≤60 Zeichen), was gerade jetzt passiert",
+ "nudge": "eine konkrete, machbare Aktion zum Signal",
+ "delta": "optionaler Delta-String (z.B. '+6 mmHg vs. dein 30-Tage-Mittel') oder null"
+ }
+ ],
"keyFindings": [
{
"tone": "good | watch | info",
diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts
index 6a6e3f157..7a107c407 100644
--- a/src/lib/ai/schema.ts
+++ b/src/lib/ai/schema.ts
@@ -291,6 +291,45 @@ export type DailyBriefingKeyFinding = z.infer<
typeof dailyBriefingKeyFindingSchema
>;
+/**
+ * v1.18.7 — "Signals of the day".
+ *
+ * The present-focused lead of the rebuilt daily briefing. Each signal is a
+ * short, NOW-anchored read of one metric — what the freshest reading is doing
+ * against the user's own recent baseline — paired with a single concrete,
+ * doable nudge ("you're ~1h short on sleep this week — aim for an earlier
+ * night"). The deterministic `features.signalsOfDay` block hands the model
+ * the comparison already finished (delta, normal-swing verdict, emerging
+ * trend, recent anomaly), so the prose states it rather than re-deriving it.
+ *
+ * Hard cap of 3 so the briefing leads with what actually moved today and the
+ * card stays a glance, not a wall. The block is optional + nullable so cached
+ * payloads from before v1.18.7 round-trip without forcing a regenerate.
+ */
+export const dailyBriefingSignalSchema = z.object({
+ /** Metric the signal is drawn from — same discriminator as keyFindings. */
+ sourceMetric: dailyBriefingKeyFindingSchema.shape.sourceMetric,
+ /**
+ * Tone drives the accent colour, mirroring the keyFinding ladder:
+ * - "good" — moving the favourable way / target held
+ * - "watch" — a deviation worth a gentle nudge
+ * - "info" — a neutral present-tense observation
+ */
+ tone: z.enum(["good", "watch", "info"]),
+ /** Present-tense headline — what is happening NOW (the user reads first). */
+ headline: z.string().min(1, "signal.headline required"),
+ /**
+ * One concrete, doable nudge tied to the signal. Empty rejected so a
+ * signal always carries an action the user can take, not just an
+ * observation. Stays inside the data boundary — never a prescription.
+ */
+ nudge: z.string().min(1, "signal.nudge required"),
+ /** Optional delta string — e.g. "+6 mmHg vs your 30-day average". */
+ delta: z.string().nullable(),
+});
+
+export type DailyBriefingSignal = z.infer;
+
export const dailyBriefingSchema = z.object({
/**
* Narrative paragraph — ~80-200 words. Synthesised from the day's
@@ -298,6 +337,17 @@ export const dailyBriefingSchema = z.object({
* strings are rejected so the briefing card never paints a void.
*/
paragraph: z.string().min(1, "dailyBriefing.paragraph required"),
+ /**
+ * v1.18.7 — 0-3 present-focused "Signals of the day". The rebuilt
+ * briefing leads with these; `keyFindings` stays the longer-horizon
+ * trend list below. Optional + nullable so pre-v1.18.7 caches round-trip.
+ */
+ signalsOfDay: z
+ .array(dailyBriefingSignalSchema)
+ .min(0)
+ .max(3)
+ .nullable()
+ .optional(),
/**
* 0-5 key findings. Empty array is acceptable when the data is
* truly flat; the hero strip simply hides the row in that case.
diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts
index b24dc91c1..d9b4626fc 100644
--- a/src/lib/insights/features.ts
+++ b/src/lib/insights/features.ts
@@ -4,7 +4,7 @@
* No raw timestamps or exact values are sent in aggregated mode.
*/
import { prisma } from "@/lib/db";
-import { summarize } from "@/lib/analytics/trends";
+import { summarize, trendSlope } from "@/lib/analytics/trends";
import type { DataPoint } from "@/lib/analytics/trends";
import {
buildComplianceMedicationContext,
@@ -184,6 +184,64 @@ export interface AggregatedFeatures {
ageYears: number | null;
gender: string | null;
};
+ /**
+ * v1.18.7 — "Signals of the day": the present-focused read the daily
+ * briefing leads with. Each entry compares the freshest reading against
+ * the user's own trailing-7d / 30d windows, flags an emerging slope, and
+ * surfaces a recent anomaly — ranked by clinical priority, ≤5 entries.
+ *
+ * Recomputed every day: the freshest reading, the 7/30d windows, and the
+ * recency move daily, so a flat 30/90d mean no longer pins the briefing.
+ * The block lands inside the compacted features payload, so it also seeds
+ * the content-hash gate (`hashInsightSnapshot`) — a fresh daily signal
+ * forces the briefing to regenerate. See `computeSignalsOfDay`.
+ */
+ signalsOfDay?: SignalOfDay[];
+}
+
+/**
+ * v1.18.7 — one present-focused signal feeding the daily briefing. Every
+ * numeric field is pre-computed so the model states it rather than
+ * re-deriving the comparison (which small LLMs do unreliably).
+ */
+export interface SignalOfDay {
+ /** Briefing `sourceMetric` discriminator the UI pins an icon + route on. */
+ metric:
+ | "bp"
+ | "weight"
+ | "pulse"
+ | "mood"
+ | "sleep"
+ | "resting_hr"
+ | "steps";
+ /** Natural-language label (no enum leak into prose). */
+ label: string;
+ /** Unit string when the metric carries one. */
+ unit?: string;
+ /** Freshest reading (the "now" value the briefing leads with). */
+ latest: number;
+ /** Days since the freshest reading. */
+ latestDaysAgo: number;
+ /** Trailing-7d mean. */
+ avg7: number | null;
+ /** Trailing-30d mean. */
+ avg30: number | null;
+ /** Signed `latest − avg7`, pre-computed. */
+ deltaVs7: number | null;
+ /** Signed `latest − avg30`, pre-computed. */
+ deltaVs30: number | null;
+ /** Normal-swing SD over the trailing-30d window. */
+ spread30: number | null;
+ /** `|latest − avg30| > spread30` — the significance verdict as a boolean. */
+ outsideNormalSwing: boolean;
+ /** Emerging direction over the trailing 30 days (slope sign). */
+ emergingTrend: "rising" | "falling" | "flat" | null;
+ /** A peak / trough inside the last 14 days, when one stands out. */
+ recentAnomaly: {
+ kind: "peak" | "trough";
+ value: number;
+ daysAgo: number;
+ } | null;
}
/**
@@ -359,6 +417,159 @@ function computeHistoricalComparison(
return { current7dAvg, previous30dAvg, change };
}
+/**
+ * v1.18.7 — clinical priority order for the signals block. BP / glucose
+ * lead, then resting HR / pulse, then weight, then mood / sleep / activity.
+ * Lower index = higher priority; the briefing surfaces the top ≤5.
+ */
+const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [
+ "bp",
+ "resting_hr",
+ "pulse",
+ "weight",
+ "sleep",
+ "mood",
+ "steps",
+];
+
+/** Round helper local to the signals builder. */
+function r2(v: number): number {
+ return Math.round(v * 100) / 100;
+}
+
+/**
+ * Build one signal from a single metric's measurement records. Returns null
+ * when there is no fresh reading or fewer than three points in 30 days — a
+ * sparse metric cannot carry an honest "today vs your normal" read.
+ */
+function buildSignal(
+ metric: SignalOfDay["metric"],
+ label: string,
+ records: Array<{ value: number; measuredAt: Date }>,
+ now: number,
+ unit?: string,
+): SignalOfDay | null {
+ if (records.length === 0) return null;
+ const newest = records[records.length - 1];
+ const latestDaysAgo = Math.round(
+ (now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
+ );
+ // A reading older than two weeks is not a "signal of the day".
+ if (latestDaysAgo > 14) return null;
+
+ const win30 = records.filter(
+ (rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000,
+ );
+ if (win30.length < 3) return null;
+
+ const avg7 = avgInWindow(records, now, 7);
+ const avg30 = avgInWindow(records, now, 30);
+ const spread30 = stdDev(win30.map((rec) => rec.value));
+ const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null;
+ const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null;
+ const outsideNormalSwing =
+ avg30 !== null && spread30 !== null && spread30 > 0
+ ? Math.abs(newest.value - avg30) > spread30
+ : false;
+
+ const slope = trendSlope(toDataPoints(records), 30);
+ const emergingTrend: SignalOfDay["emergingTrend"] = slope
+ ? slope.direction === "up"
+ ? "rising"
+ : slope.direction === "down"
+ ? "falling"
+ : "flat"
+ : null;
+
+ // Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD.
+ let recentAnomaly: SignalOfDay["recentAnomaly"] = null;
+ if (avg30 !== null && spread30 !== null && spread30 > 0) {
+ const recent = records.filter(
+ (rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000,
+ );
+ for (const rec of recent) {
+ const sd = (rec.value - avg30) / spread30;
+ if (Math.abs(sd) >= 2) {
+ const daysAgo = Math.round(
+ (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
+ );
+ const candidate = {
+ kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough",
+ value: r2(rec.value),
+ daysAgo,
+ };
+ // Keep the single most extreme anomaly in the window.
+ if (
+ recentAnomaly === null ||
+ Math.abs(rec.value - avg30) >
+ Math.abs(recentAnomaly.value - avg30)
+ ) {
+ recentAnomaly = candidate;
+ }
+ }
+ }
+ }
+
+ return {
+ metric,
+ label,
+ ...(unit ? { unit } : {}),
+ latest: r2(newest.value),
+ latestDaysAgo,
+ avg7,
+ avg30,
+ deltaVs7,
+ deltaVs30,
+ spread30,
+ outsideNormalSwing,
+ emergingTrend,
+ recentAnomaly,
+ };
+}
+
+/**
+ * v1.18.7 — assemble the present-focused "Signals of the day" block from the
+ * in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d
+ * deltas, an emerging slope, and a recent anomaly per salient metric, then
+ * returns the top ≤5 ranked by clinical priority. Salient signals
+ * (outside-normal-swing or a recent anomaly) bubble above quiet ones inside
+ * each priority tier so the briefing leads with what actually moved.
+ */
+function computeSignalsOfDay(
+ byType: (type: string) => Array<{ value: number; measuredAt: Date }>,
+ now: number,
+): SignalOfDay[] {
+ const candidates: SignalOfDay[] = [];
+ const push = (s: SignalOfDay | null) => {
+ if (s) candidates.push(s);
+ };
+
+ // Systolic carries the BP signal (the headline number clinicians read first).
+ push(buildSignal("bp", "blood pressure (systolic)", byType("BLOOD_PRESSURE_SYS"), now, "mmHg"));
+ push(buildSignal("resting_hr", "resting heart rate", byType("RESTING_HEART_RATE"), now, "bpm"));
+ push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
+ push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg"));
+ // Sleep is stored one row per stage per night, so a raw "latest" point
+ // would mis-sum; the sleep aggregates carry that signal already. Steps are
+ // daily totals — a clean per-day point.
+ push(buildSignal("steps", "daily steps", byType("ACTIVITY_STEPS"), now));
+
+ const priorityIndex = (m: SignalOfDay["metric"]) => {
+ const idx = SIGNAL_PRIORITY.indexOf(m);
+ return idx === -1 ? SIGNAL_PRIORITY.length : idx;
+ };
+ const salience = (s: SignalOfDay) =>
+ (s.outsideNormalSwing ? 2 : 0) + (s.recentAnomaly ? 1 : 0);
+
+ return candidates
+ .sort((a, b) => {
+ const sal = salience(b) - salience(a);
+ if (sal !== 0) return sal;
+ return priorityIndex(a.metric) - priorityIndex(b.metric);
+ })
+ .slice(0, 5);
+}
+
export async function extractFeatures(
userId: string,
includeRaw: boolean,
@@ -999,6 +1210,15 @@ export async function extractFeatures(
});
}
+ // v1.18.7 — present-focused "Signals of the day". Computed from the
+ // in-memory measurement set (no extra DB round-trip). Lands inside the
+ // compacted features payload, so it feeds both the briefing prompt AND the
+ // content-hash gate — a fresh daily signal forces the briefing to refresh.
+ const signalsOfDay = computeSignalsOfDay(byType, now);
+ if (signalsOfDay.length > 0) {
+ features.signalsOfDay = signalsOfDay;
+ }
+
// v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row
// into the prompt. Instead we attach DAY / WEEK / MONTH bucket means
// from `measurement_rollups` so the model gets the same temporal
From f931f2f5c2b0df0fb1f8e6e41d82b9c00563abba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:37:01 +0200
Subject: [PATCH 03/71] feat(insights): render signals of the day and remove
the overview ask-coach block
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Render the new signalsOfDay list at the top of the daily-briefing card — a
present-focused row (headline + concrete nudge) sharing the tone bar, metric
icon, and sub-page routing of the key-finding row, with matching typography
(text-sm headline, text-xs muted body) so the card reads consistently with the
rest of the insight surfaces. Add the signalsTitle string across all six
locales.
Remove the "Ask the coach" action button and the guided-questions chip strip
from the overview hero band: the Coach stays the bottom-right drawer mounted by
the insights layout shell. The hero is now greeting + subtitle + baseline meta
+ the optional Health Score panel, and no longer reads the coach flags or the
launch context. Update the coach-cascade / disable-cascade fixtures and the
hero-strip slot tests to pin the affordances absent.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
src/app/insights/page.tsx | 19 +--
.../insights/__tests__/hero-strip.test.tsx | 62 +++------
src/components/insights/daily-briefing.tsx | 96 ++++++++++++-
src/components/insights/hero-strip.tsx | 129 ++----------------
.../__tests__/coach-cascade.test.tsx | 34 ++---
.../__tests__/coach-user-disable.test.tsx | 19 +--
12 files changed, 156 insertions(+), 209 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index da00a7739..a39f15431 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Tagesbriefing",
"keyFindingsTitle": "Wichtige Befunde",
+ "signalsTitle": "Signale des Tages",
"emptyTitle": "Noch kein Briefing",
"emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.",
"emptyAction": "Briefing erstellen",
diff --git a/messages/en.json b/messages/en.json
index 4a9917e07..6c8e829fb 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Daily Briefing",
"keyFindingsTitle": "Key findings",
+ "signalsTitle": "Signals of the day",
"emptyTitle": "No briefing yet",
"emptyDescription": "Generate today's briefing to get a narrative read of your trends.",
"emptyAction": "Generate briefing",
diff --git a/messages/es.json b/messages/es.json
index d922014dd..31455b452 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Resumen del día",
"keyFindingsTitle": "Hallazgos clave",
+ "signalsTitle": "Señales del día",
"emptyTitle": "Aún no hay resumen",
"emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.",
"emptyAction": "Generar resumen",
diff --git a/messages/fr.json b/messages/fr.json
index 4837f53e2..0a88a045f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Briefing du jour",
"keyFindingsTitle": "Constats clés",
+ "signalsTitle": "Signaux du jour",
"emptyTitle": "Pas encore de briefing",
"emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.",
"emptyAction": "Générer le briefing",
diff --git a/messages/it.json b/messages/it.json
index a285b0228..dfd76eb32 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Briefing del giorno",
"keyFindingsTitle": "Riscontri principali",
+ "signalsTitle": "Segnali del giorno",
"emptyTitle": "Ancora nessun briefing",
"emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.",
"emptyAction": "Genera briefing",
diff --git a/messages/pl.json b/messages/pl.json
index 8d176eccc..b6d2457e5 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2003,6 +2003,7 @@
"dailyBriefing": {
"title": "Briefing dnia",
"keyFindingsTitle": "Kluczowe wnioski",
+ "signalsTitle": "Sygnały dnia",
"emptyTitle": "Jeszcze brak briefingu",
"emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.",
"emptyAction": "Wygeneruj briefing",
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 2b13d97e3..36ec5ed8e 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -24,7 +24,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
-import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived";
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
@@ -234,11 +233,11 @@ export default function InsightsPage() {
useScrollResetOnRoute();
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
- // `` so every routed sub-page can reach it.
- // The hero strip + suggested-prompt chips call `askCoach(prefill)`
- // on the same context, and the drawer itself is mounted next to
- // the provider in `src/app/insights/layout.tsx`.
- const coachLaunch = useCoachLaunch();
+ // ``; the drawer is mounted next to the provider in
+ // `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach
+ // entry points (the action button + suggested-prompt chips), so the
+ // overview page no longer reads the launch context — the drawer + its
+ // own launcher stay untouched.
const flags = useFeatureFlags();
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
@@ -471,14 +470,6 @@ export default function InsightsPage() {
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
userName={heroGreetingName}
- onAskCoach={
- coachLaunch
- ? (prefill?: string) => coachLaunch.askCoach(prefill ?? null)
- : undefined
- }
- onPickPrompt={
- coachLaunch ? (prompt) => coachLaunch.askCoach(prompt) : undefined
- }
healthScore={analytics?.healthScore ?? undefined}
// v1.16.8 — reserve the score card's column while the thick
// analytics payload is in flight. Every overview query
diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx
index 797e136b2..588d57901 100644
--- a/src/components/insights/__tests__/hero-strip.test.tsx
+++ b/src/components/insights/__tests__/hero-strip.test.tsx
@@ -6,8 +6,9 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/**
* Insights hero strip — pins the slots so future polish can't
- * silently drop the greeting / subtitle / action row / suggested-
- * prompt strip.
+ * silently drop the greeting / subtitle / baseline meta. v1.18.7
+ * removed the coach action row + suggested-prompt strip; the negative
+ * tests below pin their absence.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -121,26 +122,14 @@ describe("", () => {
);
});
- it("renders the ask-the-coach action button as disabled with a 'Coming soon' title when no handler is supplied", () => {
+ // v1.18.7 — the overview "Ask the coach" action button was removed from
+ // the hero band. The Coach lives in the bottom-right drawer (mounted by
+ // the insights layout shell), not as a hero affordance.
+ it("does NOT render an ask-the-coach action button", () => {
const html = render( );
- const coachTag = html.match(
- /]*data-slot="insights-hero-strip-action-coach"[^>]*>/,
+ expect(html).not.toContain(
+ 'data-slot="insights-hero-strip-action-coach"',
);
- expect(coachTag).not.toBeNull();
- expect(coachTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
- expect(coachTag?.[0]).toContain('title="Coming soon"');
- });
-
- it("enables the ask-the-coach button when onAskCoach is supplied (B2b)", () => {
- const html = render(
- {}} />,
- );
- const coachTag = html.match(
- /]*data-slot="insights-hero-strip-action-coach"[^>]*>/,
- );
- expect(coachTag).not.toBeNull();
- expect(coachTag?.[0]).not.toMatch(/\sdisabled(=""|\s|>)/);
- expect(coachTag?.[0]).not.toContain('title="Coming soon"');
});
// v1.4.25 W3 — the regenerate button moved from the hero action row
@@ -152,20 +141,13 @@ describe("", () => {
});
// ── Suggested prompts strip ────────────────────────────────────────
- it("mounts the SuggestedPrompts strip below the action band", () => {
+ // v1.18.7 — the guided-questions chip strip ("Try asking …") was removed
+ // from the hero band along with the coach action row.
+ it("does NOT mount the SuggestedPrompts strip in the hero band", () => {
const html = render( );
- expect(html).toMatch(/data-slot="insights-hero-strip-prompts"/);
- expect(html).toMatch(/data-slot="insights-suggested-prompts"/);
- expect(html).toContain("Try asking");
- expect(html).toContain("What should I tell my doctor?");
- });
-
- it("forwards onPickPrompt down to the prompt strip without throwing", () => {
- const html = render(
- {}} />,
- );
- // The chips render verbatim; SSR doesn't run click handlers.
- expect(html).toMatch(/data-slot="insights-suggested-prompts-chip"/);
+ expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
+ expect(html).not.toContain('data-slot="insights-suggested-prompts"');
+ expect(html).not.toContain("What should I tell my doctor?");
});
// ── Misc ───────────────────────────────────────────────────────────
@@ -229,12 +211,11 @@ describe("", () => {
expect(html).toContain(">86<");
});
- it("does not render an inline Ask-the-Coach button inside the Health Score card", () => {
- // v1.4.27 F8 — the inline HSC Ask-the-Coach button retired in
- // favour of the hero strip's existing action-row button. Even
- // when `onAskCoach` is supplied, the HSC panel must not surface
- // its own button. The action-row button at
- // `insights-hero-strip-action-coach` carries the affordance.
+ it("does not render any Ask-the-Coach affordance in the hero band", () => {
+ // v1.4.27 F8 — the inline HSC Ask-the-Coach button was retired.
+ // v1.18.7 — the hero action-row coach button was removed too; the
+ // Coach is the bottom-right drawer, not a hero affordance. Neither
+ // the score card nor the band surfaces a coach button.
const html = render(
", () => {
},
delta: 5,
}}
- onAskCoach={() => {}}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
- expect(html).toContain('data-slot="insights-hero-strip-action-coach"');
+ expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"');
});
it("uses the lg row layout when healthScore is supplied (smoke test on container class)", () => {
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index ab156cc2e..f62aa1cfc 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -31,6 +31,7 @@ import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
DailyBriefingKeyFinding,
+ DailyBriefingSignal,
} from "@/lib/ai/schema";
/**
@@ -243,6 +244,73 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
);
}
+/**
+ * v1.18.7 — "Signals of the day" row. The present-focused lead of the
+ * briefing: a NOW-anchored headline + a concrete nudge the user can act on.
+ * Shares the tone bar + metric icon visual language of the key-finding row,
+ * and pins the same type/route mapping so a signal is tappable when its
+ * metric owns a sub-page. Typography matches the key-finding row (the rest of
+ * the insight cards): `text-sm` headline, `text-xs` muted body.
+ */
+function SignalRow({ signal }: { signal: DailyBriefingSignal }) {
+ const Icon = METRIC_ICON[signal.sourceMetric];
+ const href = METRIC_HREF[signal.sourceMetric];
+ const rowContent = (
+ <>
+
+
+
+
+
+ {stripChartTokens(signal.headline)}
+
+
+
+
+ {stripChartTokens(signal.nudge)}
+
+
+ >
+ );
+ if (href) {
+ return (
+
+ {rowContent}
+
+ );
+ }
+ return (
+
+ {rowContent}
+
+ );
+}
+
function BriefingSkeleton() {
return (
0 && (
+
+
+ {t("insights.dailyBriefing.signalsTitle")}
+
+
+ {briefing.signalsOfDay.map((signal, index) => (
+
+ ))}
+
+
+ )}
{briefing.keyFindings.length > 0 && (
" meta row
- * - action row: single "Ask the coach" affordance
- * (v1.4.25 W3 dropped the "Re-run analysis" button — the
- * regenerate affordance moved to ``. v1.4.28
- * retired the weekly-report path, leaving Coach as the sole
- * hero-row action.)
- * - chip strip below the action band
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
* + `.glow-purple` utilities in `globals.css`
*
- * Mobile-first: the action row wraps + the suggested-prompt chips
- * wrap. Right-side Health Score panel is *not* part of B1 — that
- * lands in B5; for B1 the right side just keeps its meta band.
+ * v1.18.7 — the overview "Ask the coach" action row + guided-questions
+ * chip strip were removed from the band. The Coach lives in the
+ * bottom-right drawer (mounted by the insights layout shell); the
+ * overview now leads with the present-focused daily briefing, not a
+ * coach prompt. The hero is greeting + subtitle + baseline meta + the
+ * optional right-side Health Score panel.
*
* Pure presentational — the page owns the briefing data.
*/
@@ -53,25 +46,6 @@ interface HeroStripProps {
* morning, undefined".
*/
userName?: string | null;
- /**
- * Click handler for the suggested prompts. The Coach drawer (B2b)
- * routes a chip click into the drawer's input; the parent owns the
- * drawer state. When omitted the chip click is a no-op.
- */
- onPickPrompt?: (prompt: string) => void;
- /**
- * Click handler for the "Ask the coach" action button. When supplied,
- * the button is enabled and the coming-soon tooltip drops; clicking
- * opens the drawer (B2b). When omitted the button stays disabled —
- * see the v1.4.20 phase B1 dispatch where the drawer was deferred.
- *
- * v1.4.20 phase B5 — the same handler powers the Health Score
- * panel's "Ask the Coach" button, with an optional `prefill` string
- * that opens the drawer with a score-aware question. The action-row
- * button calls it without an argument (drawer opens blank); the HSC
- * panel calls it with "Why is my health score X out of 100?".
- */
- onAskCoach?: (prefill?: string) => void;
/**
* Now() override for tests so the greeting bucket is deterministic.
* Defaults to `new Date()`. Production callers omit this.
@@ -79,11 +53,8 @@ interface HeroStripProps {
now?: Date;
/**
* v1.4.20 phase B5 — Personal Health Score panel data. When supplied
- * the right side of the hero band paints the score card. The
- * `onAskCoach` handler is intentionally re-used from the action-row
- * "Ask the coach" button — same drawer state, different prefill
- * string. When the parent omits the field the right side stays empty
- * (current B1 behaviour).
+ * the right side of the hero band paints the score card. When the
+ * parent omits the field the right side stays empty.
*/
healthScore?: {
score: HealthScoreCardProps["score"];
@@ -134,28 +105,11 @@ export function HeroStrip({
briefing,
updatedAt,
userName,
- onPickPrompt,
- onAskCoach,
now,
healthScore,
healthScorePending = false,
}: HeroStripProps) {
const { t } = useTranslations();
- // v1.4.37 W5 — operator-level Coach gate. When the global Coach flag
- // is off every Coach affordance must vanish from this band (the
- // action-row button, the suggested-prompts chip strip, and the
- // HealthScoreCard's `onAskCoach` prop). The button and chips both
- // open the Coach drawer, so leaving them visible while the drawer
- // mount is suppressed would surface dead controls.
- const flags = useFeatureFlags();
- // v1.4.47 W3 — per-user "Hide Coach" toggle joins the operator's
- // master flag. Either-off resolves to `coachEnabled === false` so
- // the hero action button + suggested-prompts strip + the
- // ` ` prop all vanish together. The
- // hook is SSR-safe — returns `false` when the legacy snapshot
- // tests render the strip without a QueryClient.
- const disableCoach = useDisableCoach();
- const coachEnabled = flags.coach && !disableCoach;
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
// when the account turned the module off (the server already drops the
// pillar from the number itself). SSR-safe + default-on, matching the
@@ -168,7 +122,6 @@ export function HeroStrip({
const generatedLine = updatedAt
? t("insights.heroGenerated", { time: formatRelativeTime(updatedAt, t) })
: null;
- const comingSoon = t("insights.heroComingSoonTooltip");
return (
{/*
- * v1.4.37 W5 — the action row only carries the Coach button
- * today (v1.4.28 retired the weekly-report affordance). When
- * the operator turns the global Coach flag off the whole row
- * disappears rather than collapsing to an empty flex shell.
+ * v1.18.7 — the overview "Ask the coach" action row + the
+ * guided-questions chip strip were removed from the hero band.
+ * The Coach is the bottom-right drawer (mounted in the insights
+ * layout shell); the overview leads with the present-focused
+ * daily briefing, not a coach prompt. The drawer itself is
+ * untouched — only the hero entry points are gone, so the band
+ * is now greeting + subtitle + baseline meta.
*/}
- {coachEnabled && (
-
- {/* B2b wires this into the Coach drawer. The button is
- enabled whenever the parent supplies an `onAskCoach`
- handler; older parents that haven't adopted B2b yet
- still get the disabled "Coming soon" affordance so the
- hero doesn't break. v1.4.28 retired the weekly-report
- button, leaving Coach as the only hero-row action. */}
- onAskCoach() : undefined}
- disabled={!onAskCoach}
- title={onAskCoach ? undefined : comingSoon}
- data-slot="insights-hero-strip-action-coach"
- className="gap-1.5"
- >
-
- {t("insights.heroActionAskCoach")}
-
- {/*
- * v1.4.25 W3 — the regenerate button moved to the new
- * `` (icon-only RefreshCw, sticky next
- * to the pill nav) so the user can re-run the analysis
- * without scrolling back to the hero band.
- */}
-
- )}
-
- {coachEnabled && (
-
- {/*
- * v1.4.20 phase B2b — chip clicks open the Coach drawer
- * with the localised prompt pre-filled in the composer.
- * The parent owns drawer state so the chip strip stays
- * presentational.
- *
- * v1.4.37 W5 — the strip is a Coach-only affordance
- * (every chip seeds a Coach turn), so it is gated on the
- * same flag as the action-row button above. Without the
- * gate the chips would still paint while the drawer is
- * suppressed, leaving inert controls in the band.
- */}
- undefined)} />
-
- )}
{!healthScore && healthScorePending && (
diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx
index b0463690d..9b400dcfd 100644
--- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx
+++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx
@@ -13,7 +13,6 @@ import {
type AssistantFlagSet,
} from "@/hooks/use-feature-flags";
-import { HeroStrip } from "@/components/insights/hero-strip";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
@@ -139,29 +138,11 @@ interface CoachSurface {
proofWhenOn: string;
}
+// v1.18.7 — the HeroStrip "Ask the coach" action button + the in-band
+// SuggestedPrompts chip strip were removed from the overview hero. The
+// Coach is the bottom-right drawer; its launcher + the standalone
+// SuggestedPrompts surface still carry the flag cascade below.
const COACH_SURFACES: CoachSurface[] = [
- {
- name: "HeroStrip action-row 'Ask the coach' button",
- mount: () => (
- undefined}
- />
- ),
- proofWhenOn: 'data-slot="insights-hero-strip-action-coach"',
- },
- {
- name: "HeroStrip SuggestedPrompts chip strip",
- mount: () => (
- undefined}
- />
- ),
- proofWhenOn: 'data-slot="insights-hero-strip-prompts"',
- },
{
name: "SuggestedPrompts standalone",
mount: () => undefined} />,
@@ -290,7 +271,8 @@ describe("Coach disable cascade invariant", () => {
const KNOWN_COACH_GATE_SITES: ReadonlyArray = [
// Coach-bearing surfaces mounted directly by the cascade fixture.
"src/components/insights/coach-launch-button.tsx",
- "src/components/insights/hero-strip.tsx",
+ // v1.18.7 — hero-strip.tsx no longer reads `flags.coach`: its coach
+ // action button + suggested-prompt strip were removed from the band.
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
@@ -404,6 +386,8 @@ describe("Coach disable cascade invariant", () => {
// forces them to revisit the fixture. The number tracks the
// surfaces the fixture mounts directly; the grep-based discovery
// test above pins every other `flags.coach` call site.
- expect(COACH_SURFACES.length).toBe(6);
+ // v1.18.7 — dropped from 6 to 4: the two HeroStrip coach surfaces
+ // (action button + suggested-prompt strip) were removed from the band.
+ expect(COACH_SURFACES.length).toBe(4);
});
});
diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
index 9120db140..71b725ea7 100644
--- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
+++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
@@ -18,7 +18,6 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
-import { HeroStrip } from "@/components/insights/hero-strip";
/**
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
@@ -182,17 +181,9 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
mount: () => undefined} />,
proofWhenVisible: 'data-slot="insights-suggested-prompts"',
},
- {
- name: "HeroStrip action-row Ask the coach",
- mount: () => (
- undefined}
- />
- ),
- proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
- },
+ // v1.18.7 — the HeroStrip "Ask the coach" action button was removed from
+ // the overview hero. The Coach is the bottom-right drawer; its launcher
+ // surfaces below carry the per-user disable gate.
];
describe("Coach per-user disableCoach invariant", () => {
@@ -290,7 +281,9 @@ describe("Coach per-user disableCoach invariant", () => {
// fixture above; each one calls `useDisableCoach()` and short-
// circuits to `null` when the per-user flag is on.
"src/components/insights/coach-launch-button.tsx",
- "src/components/insights/hero-strip.tsx",
+ // v1.18.7 — hero-strip.tsx no longer gates on the Coach flag: its
+ // action button + suggested-prompt strip were removed, so it dropped
+ // the `useDisableCoach` call entirely.
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
From c7425ca426df0a56817d8efe8f63610b450ede55 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:45:14 +0200
Subject: [PATCH 04/71] feat(vorsorge): add 7-day trend strip + measurements
deep link to reminder cards
Enrich the Vorsorge cards with a discreet 7-day trend strip and a path
into the underlying readings.
- A quiet seven-bar strip of the reminder metric's last seven readings
sits under the metric context on both the dashboard summary card and
the full-grid cards, mirroring how a dashboard tile carries a small
trend. Neutral muted bars, no status colour. A free-text reminder or a
metric with fewer than two readings shows nothing, so card height stays
stable. The strip reads a bounded last-7 window per type through the
existing measurements endpoint, cached by type under the measurements
invalidation prefix.
- The card kebab gains a 'Show measurements' item for measurement-linked
reminders that opens /measurements pre-filtered to that type.
- The measurements list accepts a ?type= deep link that
seeds the existing client-side type filter while leaving the rail
interactive; unknown tokens are dropped. The ?add= behaviour is
unchanged.
- New labels added across all six locales.
---
messages/de.json | 4 +
messages/en.json | 4 +
messages/es.json | 4 +
messages/fr.json | 4 +
messages/it.json | 4 +
messages/pl.json | 4 +
src/app/measurements/page.tsx | 29 +++++-
.../__tests__/vorsorge-section.test.tsx | 11 ++-
.../vorsorge-dashboard-card.tsx | 9 +-
.../vorsorge-section.tsx | 24 ++++-
.../vorsorge-trend-strip.tsx | 93 +++++++++++++++++++
.../measurements/measurement-list.tsx | 14 ++-
src/lib/query-keys/measurements.ts | 10 ++
13 files changed, 205 insertions(+), 9 deletions(-)
create mode 100644 src/components/measurement-reminders/vorsorge-trend-strip.tsx
diff --git a/messages/de.json b/messages/de.json
index a39f15431..aaa5c0714 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Keine anstehenden Vorsorge-Erinnerungen.",
"openLink": "Vorsorge öffnen"
+ },
+ "showMeasurements": "Messergebnisse anzeigen",
+ "trendStrip": {
+ "label": "Verlauf der letzten 7 Tage"
}
},
"moduleList": {
diff --git a/messages/en.json b/messages/en.json
index 6c8e829fb..7eb1d9c5b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "No upcoming preventive-care reminders.",
"openLink": "Open preventive care"
+ },
+ "showMeasurements": "Show measurements",
+ "trendStrip": {
+ "label": "Last 7 days"
}
},
"moduleList": {
diff --git a/messages/es.json b/messages/es.json
index 31455b452..63c332c30 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "No hay recordatorios de prevención próximos.",
"openLink": "Abrir prevención"
+ },
+ "showMeasurements": "Ver mediciones",
+ "trendStrip": {
+ "label": "Últimos 7 días"
}
},
"moduleList": {
diff --git a/messages/fr.json b/messages/fr.json
index 0a88a045f..5758907bf 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Aucun rappel de prévention à venir.",
"openLink": "Ouvrir la prévention"
+ },
+ "showMeasurements": "Afficher les mesures",
+ "trendStrip": {
+ "label": "7 derniers jours"
}
},
"moduleList": {
diff --git a/messages/it.json b/messages/it.json
index dfd76eb32..688f8c7f9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Nessun promemoria di prevenzione in arrivo.",
"openLink": "Apri prevenzione"
+ },
+ "showMeasurements": "Mostra le misurazioni",
+ "trendStrip": {
+ "label": "Ultimi 7 giorni"
}
},
"moduleList": {
diff --git a/messages/pl.json b/messages/pl.json
index b6d2457e5..05addbcd7 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6422,6 +6422,10 @@
"dashboard": {
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
"openLink": "Otwórz profilaktykę"
+ },
+ "showMeasurements": "Pokaż pomiary",
+ "trendStrip": {
+ "label": "Ostatnie 7 dni"
}
},
"moduleList": {
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index 57fc95791..8411579ba 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -10,6 +10,7 @@ import {
resolveAddToken,
} from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -41,6 +42,18 @@ export default function MeasurementsPage() {
// effect, so that path stays render-driven.
const addParam = searchParams.get("add");
const initialAdd = addParam ? resolveAddToken(addParam) : null;
+ // v1.18.7 (Wave E) — a `?type=` deep link (e.g. the
+ // Vorsorge card's "Show measurements") seeds the list's type filter on
+ // first paint. Captured once via a lazy initializer so the value is
+ // stable even after the effect below strips the query string; an unknown
+ // token is dropped (validated against the canonical type-label map), so a
+ // stale link never traps the user on a broken filter.
+ const typeParam = searchParams.get("type");
+ const [initialType] = useState(() =>
+ typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS
+ ? typeParam
+ : undefined,
+ );
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
const [defaultType, setDefaultType] = useState(
() => initialAdd ?? undefined,
@@ -82,11 +95,14 @@ export default function MeasurementsPage() {
}
// Drop the query string so the back-button leaves the user on
- // `/measurements` rather than re-opening the dialog. Effect-driven
- // (client-only) because `router.replace` is not callable during SSR.
+ // `/measurements` rather than re-opening the dialog (or re-seeding the
+ // type filter). The type filter is already seeded into the list's own
+ // state via `initialType`, so stripping the param doesn't lose it.
+ // Effect-driven (client-only) because `router.replace` is not callable
+ // during SSR.
useEffect(() => {
- if (addParam) router.replace("/measurements");
- }, [addParam, router]);
+ if (addParam || typeParam) router.replace("/measurements");
+ }, [addParam, typeParam, router]);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
@@ -157,7 +173,10 @@ export default function MeasurementsPage() {
/>
- setDialogOpen(true)} />
+ setDialogOpen(true)}
+ initialType={initialType}
+ />
);
}
diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
index 748b7999e..d9ad228cd 100644
--- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
+++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
@@ -27,8 +28,16 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
import { VorsorgeSection } from "../vorsorge-section";
function render(node: React.ReactNode) {
+ // v1.18.7 (Wave E) — a measurement-linked card now mounts the 7-day
+ // trend strip, which reads via TanStack Query; wrap in a client so the
+ // static-markup render resolves (the query stays idle on the server).
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
return renderToStaticMarkup(
-
{node} ,
+
+ {node}
+ ,
);
}
diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
index 817439aab..0c03f88ef 100644
--- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
+++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
@@ -25,6 +25,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
+import { VorsorgeTrendStrip } from "@/components/measurement-reminders/vorsorge-trend-strip";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -122,13 +123,19 @@ export function VorsorgeDashboardCard() {
key={reminder.id}
className="flex items-center justify-between gap-3 rounded-md border p-2.5"
>
-
+
{resolveLabel(reminder, t)}
{t(`measurementReminders.${due.key}`, { days: due.days })}
+ {/* v1.18.7 (Wave E) — discreet 7-day strip under the
+ metric context; renders nothing for a free-text
+ reminder or a too-thin window. */}
+
{t("measurementReminders.edit")}
+ {/* v1.18.7 (Wave E) — jump to the measurements list pre-filtered to
+ this reminder's type. Only for measurement-linked reminders; a
+ free-text reminder has no readings to show. */}
+ {isLinked && (
+
+
+
+ {t("measurementReminders.showMeasurements")}
+
+
+ )}
{nextLastSlot}
+ {/* v1.18.7 (Wave E) — discreet 7-day strip of the metric's last
+ readings, sitting under the metric context like the dashboard
+ tiles. Renders nothing for a free-text reminder or a too-thin
+ window, so the card height stays stable when absent. */}
+
+
{/* v1.18.6 (MOD-06) — light gamification: progress through the
current interval toward next-due, reusing the medication card's
Progress building block. Honest "on track / due" signal without
diff --git a/src/components/measurement-reminders/vorsorge-trend-strip.tsx b/src/components/measurement-reminders/vorsorge-trend-strip.tsx
new file mode 100644
index 000000000..5d476c702
--- /dev/null
+++ b/src/components/measurement-reminders/vorsorge-trend-strip.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+/**
+ * v1.18.7 (Wave E) — a small, DISCREET 7-day trend strip for a Vorsorge
+ * card. Renders the reminder metric's last seven readings as seven quiet
+ * bars (newest → rightmost), heights normalised across the window. The
+ * colour is intentionally muted (`bg-muted-foreground/30`) so the strip
+ * reads as calm context sitting under the metric — never an alarming
+ * status signal, in keeping with the no-loud-colour card ethos.
+ *
+ * Pulls the bounded last-7 readings for one `MeasurementType` through the
+ * existing measurements list endpoint (a single `limit=7` read per card,
+ * cached by type) — no new heavy aggregate query. A free-text reminder
+ * (no `measurementType`) renders nothing; a metric with fewer than two
+ * readings in the window renders nothing (a single dot is no trend).
+ */
+import { useQuery } from "@tanstack/react-query";
+
+import { apiGet } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+interface RecentMeasurement {
+ value: number;
+ measuredAt: string;
+}
+
+/**
+ * Bounded last-7 read for one measurement type, sorted newest-first by the
+ * server. Re-sorted oldest → newest here so the bars read left (older) to
+ * right (newest). Shares the `measurements` invalidation prefix so a fresh
+ * reading (e.g. completing this very reminder) repaints the strip.
+ */
+function useRecentValues(measurementType: string | null, enabled: boolean) {
+ return useQuery({
+ queryKey: queryKeys.measurementRecentValues(measurementType ?? ""),
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ params.set("type", measurementType as string);
+ params.set("limit", "7");
+ params.set("sortBy", "measuredAt");
+ params.set("sortDir", "desc");
+ const res = await apiGet<{ measurements: RecentMeasurement[] }>(
+ `/api/measurements?${params}`,
+ );
+ return res.measurements
+ .slice()
+ .reverse()
+ .map((m) => m.value);
+ },
+ enabled: enabled && measurementType != null,
+ });
+}
+
+export function VorsorgeTrendStrip({
+ measurementType,
+ enabled = true,
+}: {
+ measurementType: string | null;
+ enabled?: boolean;
+}) {
+ const { t } = useTranslations();
+ const { data } = useRecentValues(measurementType, enabled);
+
+ // Nothing to show for a free-text reminder or a too-thin window.
+ if (measurementType == null) return null;
+ const values = data ?? [];
+ if (values.length < 2) return null;
+
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = max - min || 1;
+
+ return (
+
+ {values.map((v, i) => {
+ // Floor at ~18% so a flat series still reads as bars, not a line.
+ const pct = 0.18 + ((v - min) / span) * 0.82;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx
index f8d40ddb2..899baa6ba 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -179,6 +179,13 @@ interface MeasurementListProps {
* scoped to this type; the count caption still shows.
*/
lockedType?: string;
+ /**
+ * v1.18.7 (Wave E) — seed the (still user-editable) type filter from a
+ * `?type=` deep link, e.g. the Vorsorge card's "Show
+ * measurements" action. Unlike `lockedType` the full filter rail stays
+ * visible so the user can widen or change the filter afterwards.
+ */
+ initialType?: string;
}
const PAGE_SIZE = 25;
@@ -263,6 +270,7 @@ export function MeasurementList({
onEdit,
onAddFirst,
lockedType,
+ initialType,
}: MeasurementListProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
@@ -270,7 +278,11 @@ export function MeasurementList({
const queryClient = useQueryClient();
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
// and the type selector is hidden; the filter state seeds from it.
- const [typeFilter, setTypeFilterRaw] = useState(lockedType ?? "ALL");
+ // v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
+ // same filter but keeps the rail interactive so the user can change it.
+ const [typeFilter, setTypeFilterRaw] = useState(
+ lockedType ?? initialType ?? "ALL",
+ );
// v1.15.13 — management-list source filter + optional date range.
// `ALL` clears the source filter; empty date strings clear the bound.
const [sourceFilter, setSourceFilterRaw] = useState("ALL");
diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts
index 4a3d12cb5..3fa99852e 100644
--- a/src/lib/query-keys/measurements.ts
+++ b/src/lib/query-keys/measurements.ts
@@ -126,6 +126,16 @@ export const measurementKeys = {
measurementDiversity: (type: string) =>
["measurement-diversity", type] as const,
+ /**
+ * v1.18.7 (Wave E) — bounded last-7 readings for one `MeasurementType`,
+ * powering the discreet 7-day trend strip on a Vorsorge card. Rides under
+ * the `["measurements"]` prefix so it lands in `measurementDependentKeys`
+ * and a fresh reading (e.g. satisfying the very reminder) repaints the
+ * strip. Keyed by type so each card caches its own thin window.
+ */
+ measurementRecentValues: (type: string) =>
+ ["measurements", "recent-values", type] as const,
+
/**
* v1.18.6 — batched dashboard daily series
* (`GET /api/measurements/series-batch`). Shares the `chart-data`
From d92b3b9efd5b3057685a122cdccff1afb93a7e00 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:45:23 +0200
Subject: [PATCH 05/71] style(insights): calm and flatten the health-score
rings
The score dials wore a token-tinted drop-shadow bloom, a conic specular
sheen, a breathing pulse on the green ring, and a dark-to-neon two-stop
arc gradient. Against the flat 2-D Recharts charts that read as a glowing
3-D gauge and made the overview restless.
Make the calm treatment the default everywhere, not only behind the `flat`
prop:
- Drop the bloom, the sheen, and the pulse for every ring. `.wellness-ring-arc`
is now a plain flat stroke; the sheen overlay paints nothing. The shared
cycle ring inherits the same quiet.
- Mute the arc colours toward the chart palette: the two gradient stops sit a
hair apart for a whisper of depth instead of a neon sweep, pulled down and
desaturated per hue in both the Dracula and Alucard sets. Band semantics
(green/yellow/red thresholds) are unchanged.
- `flat` now only drops the entrance sweep + count-up, so the dashboard hero
ring paints its value at once. The /insights strip keeps the subtle spring
fill; prefers-reduced-motion still zeroes it.
Score and band logic, thresholds, and the data-band/aria contract are
untouched.
---
src/app/globals.css | 223 ++++++------------
src/components/insights/derived/ring-hues.ts | 48 ++--
.../insights/derived/score-ring.tsx | 67 +++---
3 files changed, 116 insertions(+), 222 deletions(-)
diff --git a/src/app/globals.css b/src/app/globals.css
index 80d94a203..4696df20e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -235,38 +235,38 @@
--dose-accent: oklch(0.714 0.203 305.504);
/*
- * v1.14.0 — wellness-ring per-metric gradients (Dracula dark).
- * Five DISTINCT single-hue two-stop arcs (from = darker, to = lighter):
- * readiness green · recovery cyan · sleep purple · stress orange ·
- * strain pink. Every `to` stop IS the matching Dracula palette colour
- * and the `from` stop is a hand-darkened tone of the same hue, so the
- * arcs read as Dracula rather than generic chart colours (the v1.14.0
- * set leaned indigo/teal/violet, which sat next to the palette instead
- * of on it). Each `to` doubles as the ring's bloom colour. The Alucard
- * light set lives on `:root.light`.
+ * v1.18.7 — wellness-ring per-metric arc colours (Dracula dark), CALM pass.
+ * Five DISTINCT but MUTED single-hue arcs: readiness green · recovery cyan ·
+ * sleep purple · stress orange · strain pink. The earlier set ran a
+ * dark→neon two-stop sweep (`to` == the raw Dracula palette colour) plus a
+ * bloom + sheen, which read as glowing/3-D against the flat Recharts charts.
+ * The arc is now a near-flat single tone per hue: `from`/`to` sit a hair
+ * apart for a whisper of depth, both pulled down from the neon palette and
+ * desaturated toward the chart colours, so the ring reads as quiet as a
+ * chart series. The Alucard light set lives on `:root.light`.
*/
- --ring-readiness-from: #1fa854;
- --ring-readiness-to: #50fa7b;
+ --ring-readiness-from: #3fb86b;
+ --ring-readiness-to: #46c476;
--tile-readiness: #50fa7b;
- --ring-recovery-from: #2fa8c4;
- --ring-recovery-to: #8be9fd;
+ --ring-recovery-from: #4fb6cc;
+ --ring-recovery-to: #58c0d6;
--tile-recovery: #8be9fd;
- --ring-sleep-from: #8655d6;
- --ring-sleep-to: #bd93f9;
+ --ring-sleep-from: #9a73da;
+ --ring-sleep-to: #a37fe0;
--tile-sleep: #bd93f9;
- --ring-stress-from: #d9842e;
- --ring-stress-to: #ffb86c;
+ --ring-stress-from: #e09a52;
+ --ring-stress-to: #e8a55e;
--tile-stress: #ffb86c;
- --ring-strain-from: #d6429b;
- --ring-strain-to: #ff79c6;
+ --ring-strain-from: #e06aa8;
+ --ring-strain-to: #ea74b1;
--tile-strain: #ff79c6;
/* Band fallback (anatomy detail view, no per-metric hue). */
- --ring-green-from: #1f9d57;
- --ring-green-to: #5cf08f;
- --ring-yellow-from: #e0922f;
- --ring-yellow-to: #ffce6b;
- --ring-red-from: #c93a2a;
- --ring-red-to: #ff7a6b;
+ --ring-green-from: #3fb868;
+ --ring-green-to: #48c473;
+ --ring-yellow-from: #e0a050;
+ --ring-yellow-to: #e8ab5c;
+ --ring-red-from: #e0594a;
+ --ring-red-to: #e96456;
/*
* v1.15.0 — cycle-phase ring hues (Dracula dark). Four calm, distinct
@@ -332,33 +332,35 @@
--sidebar-ring: #644ac9;
/*
- * v1.14.0 — wellness-ring per-metric gradients (Alucard light). DEEPER +
- * more saturated than the dark set so each hue holds AA-grade presence on
- * the white `--card` (raw Dracula cyan/green wash out on white). Same five
- * Dracula hue families as the dark set: readiness green · recovery cyan ·
- * sleep purple · stress orange · strain pink.
+ * v1.18.7 — wellness-ring per-metric arc colours (Alucard light), CALM pass.
+ * Deep + saturated enough to hold AA presence on the white `--card` (raw
+ * Dracula cyan/green wash out on white), but the dark→light two-stop SWEEP
+ * is collapsed to a near-flat single tone (`from`/`to` a hair apart) so the
+ * arc reads as quiet as a chart series rather than a glowing gradient. Same
+ * five hue families as the dark set: readiness green · recovery cyan · sleep
+ * purple · stress orange · strain pink.
*/
- --ring-readiness-from: #0f7a3a;
- --ring-readiness-to: #18a34e;
+ --ring-readiness-from: #138043;
+ --ring-readiness-to: #168a48;
--tile-readiness: #14720a;
- --ring-recovery-from: #067f96;
- --ring-recovery-to: #0d95b0;
+ --ring-recovery-from: #07849c;
+ --ring-recovery-to: #098ba3;
--tile-recovery: #08869e;
- --ring-sleep-from: #5b2db8;
- --ring-sleep-to: #7a45d6;
+ --ring-sleep-from: #6638c2;
+ --ring-sleep-to: #6c3fc8;
--tile-sleep: #6233c0;
- --ring-stress-from: #b06a16;
- --ring-stress-to: #c47a1e;
+ --ring-stress-from: #b27018;
+ --ring-stress-to: #b8771c;
--tile-stress: #a34d14;
- --ring-strain-from: #ad1e6f;
- --ring-strain-to: #cc2e88;
+ --ring-strain-from: #b62478;
+ --ring-strain-to: #bd2a7e;
--tile-strain: #b0277d;
- --ring-green-from: #0f7a3a;
- --ring-green-to: #18a34e;
- --ring-yellow-from: #b06a16;
- --ring-yellow-to: #d68a2a;
- --ring-red-from: #a3271a;
- --ring-red-to: #cb3a2a;
+ --ring-green-from: #138043;
+ --ring-green-to: #168a48;
+ --ring-yellow-from: #b27018;
+ --ring-yellow-to: #bd7a1e;
+ --ring-red-from: #b22e20;
+ --ring-red-to: #ba3526;
/*
* v1.15.0 — cycle-phase ring hues (Alucard light). Deeper + more
@@ -606,22 +608,13 @@
}
/*
- * v1.14.0 — the wellness-strip "signature reveal" motion system.
- *
- * The whole moment plays ONCE per session (the strip toggles
- * `data-revealed="true"` once, gated on a sessionStorage flag) so a
- * background Apple-Health-sync refetch never re-triggers it. Three layers:
- * 1. tile rise+fade (staggered left-to-right by an inline animation-delay),
- * 2. the ring's spring-fill arc sweep (in score-ring.tsx, --ring-spring),
- * 3. a conic SHEEN that sweeps the arc once (the "expensive" specular touch).
- * A single prefers-reduced-motion block at the bottom zeroes all of it.
+ * v1.18.7 — the wellness-strip reveal, calmed. What remains is a quiet
+ * left-to-right tile rise+fade (gated once per session via `data-revealed`)
+ * and the arc's spring-fill sweep (in score-ring.tsx, `--ring-spring`). The
+ * conic specular sheen + the bloom + the breathing pulse were removed so the
+ * strip settles as flat as the surrounding charts. A single
+ * prefers-reduced-motion block at the bottom zeroes the tile rise.
*/
-@property --shimmer-angle {
- syntax: "";
- inherits: false;
- initial-value: 0deg;
-}
-
:root {
/* Sampled spring (stiffness ~120, damping ~14): overshoots ~6% then settles
— the Oura/WHOOP fill feel. Falls back to the browser default on very old
@@ -658,93 +651,26 @@
}
}
-/* The conic sheen overlay sits over the ring; opacity animates from 0 so a
- browser without @property support (Safari <16.4 / FF <128) shows nothing
- rather than a frozen highlight band. */
+/* v1.18.7 — the conic specular SHEEN is retired. It swept a white highlight
+ across the arc once on reveal, which was the most overtly "premium / 3-D"
+ touch and clashed hardest with the flat charts. The class is kept as a
+ no-op so the cycle-ring + score-ring call sites that still render the
+ overlay div simply paint nothing. */
.wellness-ring-sheen {
- position: absolute;
- inset: 6%;
- border-radius: 50%;
- opacity: 0;
- /* A specular highlight must always LIGHTEN. Under mix-blend overlay a
- near-black light-mode `--card-foreground` would darken the tile into a
- grey band, so the sheen rides a fixed white in both themes. */
- background: radial-gradient(
- circle at 50% 50%,
- rgba(255, 255, 255, 0.72) 0%,
- transparent 70%
- );
- mix-blend-mode: overlay;
- pointer-events: none;
- -webkit-mask: conic-gradient(
- from var(--shimmer-angle),
- transparent 0deg,
- transparent 38deg,
- #000 58deg,
- transparent 80deg,
- transparent 360deg
- );
- mask: conic-gradient(
- from var(--shimmer-angle),
- transparent 0deg,
- transparent 38deg,
- #000 58deg,
- transparent 80deg,
- transparent 360deg
- );
-}
-[data-revealed="true"] .wellness-ring-sheen {
- animation: ringSheen 1500ms var(--ring-ease) forwards;
- animation-delay: calc(var(--reveal-delay, 0ms) + 550ms);
-}
-@keyframes ringSheen {
- 0% {
- --shimmer-angle: 0deg;
- opacity: 0;
- }
- 15% {
- opacity: 0.5;
- }
- 85% {
- opacity: 0.5;
- }
- 100% {
- --shimmer-angle: 360deg;
- opacity: 0;
- }
+ display: none;
}
-/* Token-tinted bloom behind the progress arc. The breathing pulse runs only
- on the strongest (green-band) ring so the strip celebrates a good score
- rather than jittering all five. `--ring-glow` is set inline per ring. */
-.wellness-ring-arc {
- filter: drop-shadow(
- 0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)
- );
-}
-/* Flat 2-D arc — no bloom, no pulse. The dashboard hero ring uses this so it
- reads as calmly as the chart + tile surfaces around it. */
-.wellness-ring-arc--flat {
- filter: none;
-}
+/* v1.18.7 — the arc is FLAT by default. The earlier treatment painted a
+ token-tinted drop-shadow bloom behind every arc plus a breathing pulse on
+ the green-band ring; against the flat 2-D Recharts charts that read as a
+ glowing 3-D dial. Both are gone — the arc is a plain stroke, the same
+ weight a chart series carries. `.wellness-ring-arc--flat` is kept as an
+ alias so the dashboard-hero call site (and any other) stays valid. */
+.wellness-ring-arc,
+.wellness-ring-arc--flat,
.wellness-ring-arc--pulse {
- /* Breathe a few times to celebrate a strong score, then settle — an
- infinite loop would repaint the (non-compositable) drop-shadow filter
- forever. Motion happens, then the surface goes calm. */
- animation: ringGlow 3.4s ease-in-out 2s 3;
-}
-@keyframes ringGlow {
- 0%,
- 100% {
- filter: drop-shadow(
- 0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent)
- );
- }
- 50% {
- filter: drop-shadow(
- 0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent)
- );
- }
+ filter: none;
+ animation: none;
}
@media (prefers-reduced-motion: reduce) {
@@ -762,15 +688,6 @@
transform: none;
animation: none;
}
- [data-revealed="true"] .wellness-ring-sheen {
- animation: none;
- opacity: 0;
- }
- .wellness-ring-arc,
- .wellness-ring-arc--pulse {
- filter: none;
- animation: none;
- }
}
/*
diff --git a/src/components/insights/derived/ring-hues.ts b/src/components/insights/derived/ring-hues.ts
index cbbbd48e2..0bba33290 100644
--- a/src/components/insights/derived/ring-hues.ts
+++ b/src/components/insights/derived/ring-hues.ts
@@ -1,22 +1,19 @@
/**
* v1.14.0 — per-metric ring hues for the wellness-score strip.
*
- * The premium signature is a SINGLE-hue, TWO-stop gradient per ring
- * (dark → light tones of the *same* colour), never a multi-hue rainbow.
- * Each metric leans toward one intentional, distinct hue (Oura's per-metric
- * move): readiness green, recovery cyan, sleep purple, stress orange,
- * strain pink — five clearly different hues so the strip never reads as
- * "a wash of green/cyan". In the dark theme every `to` stop sits exactly
- * on the matching Dracula palette colour.
+ * Each metric leans toward one intentional, distinct hue: readiness green,
+ * recovery cyan, sleep purple, stress orange, strain pink — five clearly
+ * different hues so the strip never reads as "a wash of green/cyan".
*
- * The actual colours live as CSS custom properties in `globals.css`
+ * v1.18.7 — CALM. The arc was a dark→neon two-stop gradient (the `to` stop
+ * sat on the raw Dracula palette colour) plus a bloom + sheen, which glowed
+ * against the flat charts. The two stops are now MUTED and sit a hair apart,
+ * so each arc reads as a near-flat single tone — quiet as a chart series. The
+ * actual colours live as CSS custom properties in `globals.css`
* (`--ring--from` / `--ring--to` / `--tile-`) with a Dracula
* dark set on `:root` and an Alucard light set on `:root.light`, so each hue
- * is HAND-TUNED per theme — deep + saturated enough to hold contrast on the
- * white Alucard card, vivid on the dark Dracula card. (The v1.13.x version
- * mixed through ambiguous semantic tokens — `--info` == `--dracula-cyan` —
- * which collapsed three of the five rings to cyan in the dark theme and
- * washed recovery/strain out on white. Dedicated per-theme vars fix that.)
+ * is HAND-TUNED per theme — deep enough to hold contrast on the white Alucard
+ * card without going neon on the dark Dracula card.
*
* The band semantic is never carried by the hue: it still rides `data-band`
* + the band word + the aria-label, so a red-band readiness still reads "low"
@@ -29,11 +26,11 @@ import type { ScoreBand } from "./band-tokens";
export type RingHue = "readiness" | "sleep" | "recovery" | "stress" | "strain";
/**
- * Single-hue two-stop gradient `[from, to]` (dark → light) per metric, plus a
- * band fallback for the anatomy detail view (which passes no `hue`, so the
- * ring keeps its green/yellow/red band semantics there). Values are CSS var
- * references resolved per theme — consumed straight by the SVG
- * `` stops.
+ * Near-flat single-hue arc `[from, to]` per metric, plus a band fallback for
+ * the anatomy detail view (which passes no `hue`, so the ring keeps its
+ * green/yellow/red band semantics there). The two stops are a hair apart for a
+ * whisper of depth, not a neon sweep. Values are CSS var references resolved
+ * per theme — consumed straight by the SVG `` stops.
*/
export const RING_GRADIENT: Record = {
readiness: ["var(--ring-readiness-from)", "var(--ring-readiness-to)"],
@@ -47,21 +44,6 @@ export const RING_GRADIENT: Record = {
red: ["var(--ring-red-from)", "var(--ring-red-to)"],
};
-/**
- * The `to` (light) stop per key — used as the ring's glow colour
- * (`--ring-glow`) so the bloom matches the brighter end of the arc.
- */
-export const RING_GLOW: Record = {
- readiness: "var(--ring-readiness-to)",
- recovery: "var(--ring-recovery-to)",
- sleep: "var(--ring-sleep-to)",
- stress: "var(--ring-stress-to)",
- strain: "var(--ring-strain-to)",
- green: "var(--ring-green-to)",
- yellow: "var(--ring-yellow-to)",
- red: "var(--ring-red-to)",
-};
-
/**
* The surface tint per metric — the `--tile-hue` the `.wellness-tile` CSS
* mixes (gently) over the theme `--card`. Set inline by each `RingTile`.
diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx
index b7bbfe337..50d281e8f 100644
--- a/src/components/insights/derived/score-ring.tsx
+++ b/src/components/insights/derived/score-ring.tsx
@@ -6,25 +6,31 @@ import { cn } from "@/lib/utils";
import { useCountUp } from "@/hooks/use-count-up";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { bandForScore, clampScore, type ScoreBand } from "./band-tokens";
-import { RING_GRADIENT, RING_GLOW, type RingHue } from "./ring-hues";
+import { RING_GRADIENT, type RingHue } from "./ring-hues";
/**
- * v1.14.0 — the composite-score dial: a hand-rolled SVG arc gauge with a
- * premium "signature reveal" (Oura/WHOOP-tier), 0 KB runtime, no animation
- * library. A score dial is a fixed-geometry gauge, not a data chart, so SVG
- * gives exact control over a thin round-cap arc, a segmented same-hue track,
- * a single-hue two-stop gradient, a faint "vs your baseline" ghost arc, and a
- * token-tinted bloom — all CSS-driven.
+ * v1.14.0 — the composite-score dial: a hand-rolled SVG arc gauge, 0 KB
+ * runtime, no animation library. A score dial is a fixed-geometry gauge, not a
+ * data chart, so SVG gives exact control over a thin round-cap arc, a
+ * segmented same-hue track, a near-flat single-hue arc, and a faint "vs your
+ * baseline" ghost arc.
*
- * Motion (all CSS-first, all gated on `prefers-reduced-motion`):
+ * v1.18.7 — CALM by default. The earlier dial wore a token-tinted bloom, a
+ * conic specular sheen, and a breathing pulse on the green ring; against the
+ * flat 2-D Recharts charts that read as a glowing 3-D gauge. All three are
+ * gone everywhere — the ring now paints a plain stroke in a muted single hue,
+ * as quiet as a chart series. The only motion left is a subtle spring fill on
+ * first reveal + a count-up, both gated on `prefers-reduced-motion`.
+ *
+ * Motion (CSS-first, gated on `prefers-reduced-motion`):
* • the arc sweeps from empty on mount via a `linear()` SPRING that
* overshoots ~6% then settles (`--ring-spring`); the spring is swapped for
* a plain ease-out above score 94 so a high arc never wraps past full;
* • `delayMs` staggers the sweep + the count-up so the strip reveals
* left-to-right and each number trails its arc;
- * • a conic SHEEN + the tile rise + glow live in `globals.css`, gated on the
- * strip's `data-revealed` flag (set once per session) so a background
- * refetch never re-triggers the moment.
+ * • the quiet tile rise lives in `globals.css`, gated on the strip's
+ * `data-revealed` flag (set once per session) so a background refetch
+ * never re-triggers it.
*
* The arc/track/ghost are real SVG JSX (`` is NOT
* `dangerouslySetInnerHTML`). The `` is rotated -90° so the arc starts at
@@ -78,10 +84,10 @@ export interface ScoreRingProps {
*/
delayMs?: number;
/**
- * Flat 2-D treatment: drops the drop-shadow bloom, the breathing pulse, the
- * conic sheen, and the sweep/count-up entrance. Used on the dashboard hero so
- * the ring sits as calmly as the surrounding chart + tile surfaces. The
- * /insights wellness strip + anatomy view keep the full premium reveal.
+ * v1.18.7 — the bloom / sheen / pulse are gone for every ring, so the only
+ * thing `flat` still does is drop the spring-fill sweep + the count-up: the
+ * dashboard-hero ring paints its final value at once, with no entrance
+ * motion. The /insights strip + anatomy view keep the subtle sweep.
*/
flat?: boolean;
/**
@@ -121,10 +127,10 @@ export function ScoreRing({
const resolvedBand: ScoreBand = band ?? bandForScore(clamped);
const hueKey = hue ?? resolvedBand;
- // Single-hue two-stop gradient. The per-metric `hue` leans the colour
- // (Oura's move); with no `hue` the anatomy view falls back to the band token.
+ // Near-flat single-hue arc. The two stops sit a hair apart for a whisper of
+ // depth; the per-metric `hue` leans the colour, with no `hue` the anatomy
+ // view falls back to the muted band token.
const [from, to] = RING_GRADIENT[hueKey];
- const glow = RING_GLOW[hueKey];
const reduced = prefersReducedMotion();
// `flat` forces the calm static treatment regardless of the `animate` prop:
@@ -175,18 +181,15 @@ export function ScoreRing({
{
width: dims.px,
height: dims.px,
- "--ring-glow": glow,
} as React.CSSProperties
}
>
- {/* `overflow-visible`: the arc's drop-shadow bloom (`.wellness-ring-arc`)
- paints outside the viewBox — the arc fills the 0 0 100 100 box, so the
- default `overflow: hidden` on SVG clipped the glow flat at the circle's
- edge. The -90° rotation is unaffected (the bloom is radially symmetric)
- and the parent div never clips, so the glow now feathers out fully. */}
+ {/* The -90° rotation starts the arc at 12 o'clock so it sweeps clockwise
+ without per-point trig. The arc now carries no bloom, so the SVG needs
+ no `overflow-visible`. */}
@@ -227,15 +230,10 @@ export function ScoreRing({
}}
/>
) : null}
- {/* The progress arc — single-hue gradient, round cap, spring sweep, bloom. */}
+ {/* The progress arc — muted single hue, round cap, subtle spring sweep.
+ No bloom / pulse: `.wellness-ring-arc` is a plain flat stroke. */}
- {/* Conic specular sheen — sweeps the arc once on the strip reveal.
- Omitted entirely in the flat treatment (dashboard hero). */}
- {flat ? null :
}
{/* Centred number as real DOM text — design tokens + tabular-nums. */}
Date: Fri, 19 Jun 2026 19:48:03 +0200
Subject: [PATCH 06/71] =?UTF-8?q?feat(coach):=20premium=20chat=20surface?=
=?UTF-8?q?=20=E2=80=94=20narrower=20column,=20collapsible=20rail,=20voice?=
=?UTF-8?q?=20dictation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Redesign the Coach frontend toward a calm, prompt-first reading
experience:
- Narrow the reading column to a centred max-w-2xl with a roomier
vertical rhythm so the conversation reads like a document rather than
a packed log.
- Make the page-surface conversation rail collapsible and collapsed by
default. A single labelled toggle in the rail strip opens it (lg+),
the rail heading carries a matching close control, and the column
folds to zero width with a smooth grid transition. Below lg the
bottom tray is unchanged. The fullscreen header keeps only the
minimize control.
- Replace the boxy default scrollbar on the thread, history list, and
composer with a thin, rounded, subtle bar across WebKit and Firefox,
scoped to the components.
- Polish the pre-stream beat: the Thinking indicator now pairs bouncing
dots with the label, and the assistant turn fades in so the hand-off
to streamed tokens reads as one motion.
- Return voice dictation to the composer via the Web Speech API. The
mic mounts only where the browser supports recognition, dictates into
the textarea, and is keyboard- and screen-reader-accessible.
New labels land in all six locale bundles.
---
messages/de.json | 6 +-
messages/en.json | 6 +-
messages/es.json | 6 +-
messages/fr.json | 6 +-
messages/it.json | 6 +-
messages/pl.json | 6 +-
.../coach-drawer-mobile-trays.test.tsx | 21 +-
.../__tests__/coach-input.test.tsx | 28 ++-
.../coach-panel/coach-conversation.tsx | 10 +
.../coach-panel/coach-drawer-body.tsx | 206 ++++++++++++------
.../insights/coach-panel/coach-input.tsx | 205 +++++++++++++++--
.../insights/coach-panel/history-rail.tsx | 7 +-
.../insights/coach-panel/message-thread.tsx | 84 +++++--
13 files changed, 470 insertions(+), 127 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index a39f15431..fb588de12 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Daten, die der Coach sieht",
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten",
- "sourcesMemberCount": "{count} Werte"
+ "sourcesMemberCount": "{count} Werte",
+ "dictate": "Diktieren",
+ "dictateStop": "Diktat beenden",
+ "showConversations": "Unterhaltungen anzeigen",
+ "hideConversations": "Unterhaltungen ausblenden"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
diff --git a/messages/en.json b/messages/en.json
index 6c8e829fb..79e9e02e4 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Data the Coach sees",
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet",
- "sourcesMemberCount": "{count} metrics"
+ "sourcesMemberCount": "{count} metrics",
+ "dictate": "Dictate",
+ "dictateStop": "Stop dictation",
+ "showConversations": "Show conversations",
+ "hideConversations": "Hide conversations"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
diff --git a/messages/es.json b/messages/es.json
index 31455b452..f5adaa171 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Datos que ve el Coach",
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos",
- "sourcesMemberCount": "{count} métricas"
+ "sourcesMemberCount": "{count} métricas",
+ "dictate": "Dictar",
+ "dictateStop": "Detener el dictado",
+ "showConversations": "Mostrar conversaciones",
+ "hideConversations": "Ocultar conversaciones"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
diff --git a/messages/fr.json b/messages/fr.json
index 0a88a045f..8ef30c248 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Données vues par le Coach",
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données",
- "sourcesMemberCount": "{count} mesures"
+ "sourcesMemberCount": "{count} mesures",
+ "dictate": "Dicter",
+ "dictateStop": "Arrêter la dictée",
+ "showConversations": "Afficher les conversations",
+ "hideConversations": "Masquer les conversations"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
diff --git a/messages/it.json b/messages/it.json
index dfd76eb32..03c56c20b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Dati che il Coach vede",
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato",
- "sourcesMemberCount": "{count} metriche"
+ "sourcesMemberCount": "{count} metriche",
+ "dictate": "Detta",
+ "dictateStop": "Interrompi dettatura",
+ "showConversations": "Mostra conversazioni",
+ "hideConversations": "Nascondi conversazioni"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
diff --git a/messages/pl.json b/messages/pl.json
index b6d2457e5..779c5098a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Dane widziane przez Coacha",
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych",
- "sourcesMemberCount": "{count} wskaźników"
+ "sourcesMemberCount": "{count} wskaźników",
+ "dictate": "Dyktuj",
+ "dictateStop": "Zatrzymaj dyktowanie",
+ "showConversations": "Pokaż rozmowy",
+ "hideConversations": "Ukryj rozmowy"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
diff --git a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx
index d46c0e857..da352e3ac 100644
--- a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx
@@ -58,19 +58,34 @@ describe(" — rail-tray strip", () => {
expect(html).not.toContain('data-slot="coach-drawer-sources"');
});
- it("mounts an inline history column on lg+ when historyRail is passed (page surface)", () => {
+ it("mounts a collapsible inline history column on lg+ when historyRail is passed (page surface)", () => {
+ // v1.18.7 W-coach C-UI — the inline rail is now collapsible and
+ // collapsed by default; the page surface passes `onToggleHistory`.
+ // The sub-lg tray trigger keeps `lg:hidden` (the bottom tray covers
+ // small viewports); a separate lg+ toggle (`coach-history-toggle`)
+ // shows the rail on desktop.
const html = render(
- history } />,
+
history}
+ onToggleHistory={() => undefined}
+ />,
);
expect(html).toMatch(
/]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).toContain("history");
- // The strip button collapses on lg+ where the inline column shows.
+ // Sub-lg tray trigger collapses on lg+ (the inline rail covers it).
const historyTrigger = html.match(
/]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger?.[0]).toContain("lg:hidden");
+ // lg+ inline-rail toggle is present and labelled as a show control.
+ const railToggle = html.match(
+ /]*data-slot="coach-history-toggle"[^>]*>/,
+ );
+ expect(railToggle).not.toBeNull();
+ expect(railToggle?.[0]).toContain('aria-expanded="false"');
});
it("renders the message thread + composer slots without a disclaimer line", () => {
diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
index 5fb65b000..b59b383c0 100644
--- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
@@ -11,21 +11,31 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("", () => {
- it("mounts the textarea + send button slots (mic dropped in W5)", () => {
+ it("mounts the textarea + send button slots", () => {
// v1.4.22 B4 — the disclaimer moved to the sources rail; the
// composer no longer renders its own paragraph below the input.
- // v1.4.25 W5 — the non-functional mic icon was removed.
const html = render(
{}} onSubmit={() => {}} />,
);
expect(html).toContain('data-slot="coach-input"');
expect(html).toContain('data-slot="coach-input-textarea"');
expect(html).toContain('data-slot="coach-input-send"');
- expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).not.toContain('data-slot="coach-input-disclaimer"');
expect(html).not.toContain("Coach replies are generated");
});
+ it("does not render the mic in SSR markup (Web Speech API is client-only)", () => {
+ // v1.18.7 W-coach C-UI — voice dictation returns, but the mic only
+ // mounts client-side once `SpeechRecognition` is confirmed present
+ // (a post-hydration effect flips support on). The SSR / unsupported
+ // path renders no button so the affordance is never a dead control.
+ const html = render(
+ {}} onSubmit={() => {}} />,
+ );
+ expect(html).not.toContain('data-slot="coach-input-mic"');
+ expect(html).not.toContain("Voice input arrives with the iOS app");
+ });
+
it("renders the localised placeholder without the retired shortcut hint", () => {
// v1.16.1 — the Enter/Shift+Enter hint (prose footer, later an
// Info-popover) is gone entirely; the behaviour itself is
@@ -48,18 +58,6 @@ describe("", () => {
expect(html).toContain("Frag mich etwas zu deinen Daten");
});
- it("no longer renders a mic button (W5 removed the placeholder)", () => {
- // v1.4.25 W5 — the mic icon used to be rendered + disabled with a
- // "voice arrives with iOS" tooltip. The maintainer flagged it as a click-
- // trap: nothing happened on tap. The composer now drops the icon
- // entirely; voice input remains a v1.5 iOS feature.
- const html = render(
- {}} onSubmit={() => {}} />,
- );
- expect(html).not.toMatch(/data-slot="coach-input-mic"/);
- expect(html).not.toContain("Voice input arrives with the iOS app");
- });
-
it("disables the send button when the value is empty", () => {
const html = render(
{}} onSubmit={() => {}} />,
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index 5b229441f..bcda26d32 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -126,6 +126,10 @@ export function CoachConversation({
>(null);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
+ // v1.18.7 (W-coach C-UI) — page surface only: the inline conversation
+ // rail is collapsed by default for a calm, prompt-first surface. The
+ // rail-tray-strip toggle (lg+) opens it; the rail heading closes it.
+ const [historyRailOpen, setHistoryRailOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
// v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet
// offer to fold a clarifying-question answer back into the
@@ -359,6 +363,12 @@ export function CoachConversation({
setHistoryRailOpen((open) => !open)
+ : undefined
+ }
historyRail={
surface === "page" ? (
void;
onHistoryClick: () => void;
onOpenSourcesTray: () => void;
}
@@ -46,49 +68,75 @@ export function CoachDrawerBody({
thread,
composer,
historyRail,
+ historyOpen = false,
+ onToggleHistory,
onHistoryClick,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
const { t } = useTranslations();
+ // The page surface (inline `historyRail`) carries the collapsible
+ // grid; without a rail the body is a single column (drawer surface).
+ const railExpanded = !!historyRail && historyOpen;
return (
- {/* Inline history column — page surface, lg+ only. */}
+ {/* Inline history column — page surface, lg+ only, collapsible. */}
{historyRail && (
- {/* v1.18.1 (W-COACH-UI C4) — the rail heading and the centre
- column's rail-tray strip share one fixed `h-14` header band
- with identical `border-b`, so the two dividers land on the
- same horizontal line across the columns (the asymmetry the
- maintainer flagged: a `p-3` text heading vs a `py-2` button
- strip put the lines at different heights). */}
-
- {t("insights.coach.historyTitle")}
-
- {/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here
- was the broken left scrollbar: `` is itself a
- `flex h-full flex-col` with its OWN `overflow-y-auto` list,
- so this nested it inside a second scroll container. The
- outer container scrolled the whole rail (search field
- included) against an `h-full` child whose height resolved to
- its content, leaving the inner list's `flex-1 min-h-0`
- without a bounded track — the scrollbar rendered against an
- indeterminate height and read as half-broken. Drop the outer
- scroll: the rail keeps a fixed search header and its single
- internal list-scroll is the only scroll region. */}
+ {/* v1.18.7 — the rail heading shares the `h-14` band + `border-b`
+ with the centre column's rail-tray strip so the two dividers
+ land on one horizontal line, and carries the collapse
+ control on its trailing edge. */}
+
+
+ {t("insights.coach.historyTitle")}
+
+ {onToggleHistory && (
+
+
+
+ )}
+
+ {/* v1.18.6 (CCH-01) — `` is itself a `flex h-full
+ flex-col` with its OWN `overflow-y-auto` list, so a single
+ bounded flex track here is all it needs; do not nest a
+ second scroll container. */}
{historyRail}
)}
@@ -96,41 +144,72 @@ export function CoachDrawerBody({
{/* Centre — message thread. */}
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the buttons
sit at a 44 px tap target and never overlay the first
message bubble.
- v1.18.1 (W-COACH-UI C4) — fixed `h-14` so the strip's
- bottom border aligns with the history rail heading divider
- in the adjacent column (one continuous line, no restless
- two-height split). */}
+ v1.18.7 — fixed `h-14` so the strip's bottom border aligns
+ with the history rail heading divider in the adjacent column.
+ On the page surface the left control is the rail TOGGLE
+ (clean show/hide) on lg+, falling back to the tray trigger
+ below lg; the drawer keeps the plain conversations button. */}
` (px-4) all share the same horizontal divider
- // line at sm+ rather than stepping in/out by a pixel.
className="border-border/70 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-3 sm:px-4"
>
-
-
- {t("insights.coach.historyTitle")}
-
+ {/* Left control(s). On the page surface (inline rail) the
+ affordance forks by breakpoint: below lg a tray trigger
+ opens the bottom sheet; on lg+ a clean toggle shows/hides
+ the inline rail (and the lg+ toggle hides once the rail is
+ expanded, since the rail heading then owns the close
+ control). Without an inline rail (drawer surface) the
+ single button hands off via `onHistoryClick`. */}
+ {historyRail && onToggleHistory ? (
+ <>
+ {/* Sub-lg: open the bottom tray. */}
+
+
+ {t("insights.coach.historyTitle")}
+
+ {/* lg+: toggle the inline rail (hidden while expanded). */}
+
+
+ {t("insights.coach.historyTitle")}
+
+ >
+ ) : (
+
+
+ {t("insights.coach.historyTitle")}
+
+ )}
{/* Sources toggle — the rail is hidden by default on every
viewport and opens as the right-edge tray. */}
{/* v1.18.6.1 — the thread wrapper must be a flex column itself so the
- `` scroll region resolves a bounded height. The
- wrapper used to be a plain block, so the thread's `h-full` resolved
- against an auto-height parent and the scroll container grew with its
- content instead of scrolling — pushing the composer off-screen and
- leaving a stray scrollbar on the page. `flex min-h-0 flex-1 flex-col`
- gives the thread a definite track to scroll within. */}
+ `` scroll region resolves a bounded height. */}
{thread}
{/* Composer pinned to the bottom. */}
void;
+ stop: () => void;
+ abort: () => void;
+ onresult: ((event: SpeechRecognitionResultEventLike) => void) | null;
+ onerror: (() => void) | null;
+ onend: (() => void) | null;
+};
+type SpeechRecognitionResultEventLike = {
+ resultIndex: number;
+ results: ArrayLike<
+ ArrayLike<{ transcript: string }> & { isFinal: boolean }
+ >;
+};
+type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
+
+function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null {
+ if (typeof window === "undefined") return null;
+ const w = window as unknown as {
+ SpeechRecognition?: SpeechRecognitionConstructor;
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
+ };
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
+}
+
+// v1.18.7 — `useSyncExternalStore` resolves Web Speech API support
+// without a setState-in-effect: the server snapshot is `false` (no
+// mic at SSR) and the client snapshot reflects whether the constructor
+// exists. The store never changes after hydration, so the subscribe
+// callback is a no-op.
+const noopSubscribe = () => () => {};
+function useVoiceSupported(): boolean {
+ return useSyncExternalStore(
+ noopSubscribe,
+ () => getSpeechRecognitionCtor() !== null,
+ () => false,
+ );
+}
+
export function CoachInput({
value,
onChange,
@@ -113,7 +170,7 @@ export function CoachInput({
autoFocusOnOpen = false,
placeholder,
}: CoachInputProps) {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
const textareaRef = useRef
(null);
// v1.4.27 MB3 / CF-30 — focus the textarea once on mount when the
@@ -162,6 +219,80 @@ export function CoachInput({
el.style.height = `${height}px`;
}, [value]);
+ // v1.18.7 — voice dictation. The mic control only mounts once the
+ // client confirms the Web Speech API exists (resolved through
+ // `useSyncExternalStore`, server snapshot `false`), so SSR +
+ // unsupported browsers render no button at all — never a dead
+ // affordance. `onChange` / `value` are mirrored into refs (in an
+ // effect, never during render) so the long-lived recognition handlers
+ // always read the freshest composer state without re-subscribing.
+ const voiceSupported = useVoiceSupported();
+ const [listening, setListening] = useState(false);
+ const recognitionRef = useRef(null);
+ const onChangeRef = useRef(onChange);
+ const valueRef = useRef(value);
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ valueRef.current = value;
+ });
+
+ // Tear the recogniser down on unmount so a live mic never outlives
+ // the composer (e.g. the drawer closing mid-dictation).
+ useEffect(() => {
+ return () => {
+ recognitionRef.current?.abort();
+ recognitionRef.current = null;
+ };
+ }, []);
+
+ const stopDictation = useCallback(() => {
+ recognitionRef.current?.stop();
+ }, []);
+
+ const startDictation = useCallback(() => {
+ const Ctor = getSpeechRecognitionCtor();
+ if (!Ctor) return;
+ const recognition = new Ctor();
+ recognition.lang = locale === "en" ? "en-US" : locale;
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ // Anchor every transcript onto the text present when dictation
+ // began so interim results replace cleanly rather than stacking.
+ const base = valueRef.current;
+ recognition.onresult = (event) => {
+ let finalText = "";
+ let interimText = "";
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
+ const result = event.results[i];
+ const transcript = result[0]?.transcript ?? "";
+ if (result.isFinal) finalText += transcript;
+ else interimText += transcript;
+ }
+ const spoken = (finalText + interimText).trim();
+ if (!spoken) return;
+ const joiner = base && !base.endsWith(" ") ? " " : "";
+ onChangeRef.current(`${base}${joiner}${spoken}`);
+ };
+ recognition.onerror = () => setListening(false);
+ recognition.onend = () => {
+ setListening(false);
+ recognitionRef.current = null;
+ };
+ recognitionRef.current = recognition;
+ try {
+ recognition.start();
+ setListening(true);
+ } catch {
+ // start() throws if already running; treat as a no-op.
+ setListening(false);
+ }
+ }, [locale]);
+
+ const toggleDictation = useCallback(() => {
+ if (listening) stopDictation();
+ else startDictation();
+ }, [listening, startDictation, stopDictation]);
+
const canSubmit = !disabled && value.trim().length > 0;
const handleKeyDown = useCallback(
@@ -188,20 +319,54 @@ export function CoachInput({
onSubmit={handleFormSubmit}
className="flex flex-col"
>
- {/* v1.16.1 — modern chat-app composer: a single row with the
- textarea and the send control on the same baseline. The
- textarea starts at one line and auto-grows; `items-end` keeps
- the button pinned to the input's last line as it grows. The
- old footer row (Info-popover hint + right-aligned button) is
- gone — Enter still sends and Shift+Enter still inserts a
- newline, the affordance just no longer needs explaining. */}
+ {/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded
+ field with the textarea flanked by a dictation mic (left) and
+ the send / stop control (right) on the same baseline.
+ `items-end` keeps the controls pinned to the input's last line
+ as it grows. Enter sends, Shift+Enter inserts a newline. */}
+ {voiceSupported && (
+
+
+
+ )}
@@ -251,7 +420,7 @@ export function CoachInput({
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
- className="size-9 shrink-0"
+ className="size-9 shrink-0 rounded-xl"
>
@@ -263,7 +432,7 @@ export function CoachInput({
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
- className="size-9 shrink-0"
+ className="size-9 shrink-0 rounded-xl"
>
{isStreaming ? (
{isLoading && conversations.length === 0 ? (
*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl",
+ // v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
+ // bottom breathing room so the conversation reads like a document,
+ // not a chat log packed against the chrome.
+ "flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
+ // v1.18.6 (CCH-01) / v1.18.7 — on the wide page surface an edge-to-
+ // edge thread sprawled the prose past a readable measure. Centre the
+ // content on a narrower, calmer Claude/ChatGPT-like column
+ // (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
+ // rides the surface edge. The drawer surface is already narrow, so
+ // the cap only bites on the wide page surface.
+ "[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
+ // v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
+ // component-scoped via Tailwind arbitrary variants so globals.css
+ // stays untouched. Replaces the default boxy/angular track.
+ COACH_SCROLLBAR,
)}
>
{messages.map((m) => {
@@ -418,7 +447,15 @@ export function MessageThread({
// assistant prose announce as tokens land. aria-relevant=text
// limits announcements to the streamed content; additions
// covers the bubble-mount edge case.
-
+
{label}
- {[0, 1, 2].map((i) => (
-
- ))}
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ {label}
+
);
}
From 4bda26f47c3eafaf7c8947447265c630b49cf31f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:48:10 +0200
Subject: [PATCH 07/71] =?UTF-8?q?fix(insights):=20rework=20the=20Sleep=20v?=
=?UTF-8?q?iew=20=E2=80=94=20drop=20the=20redundant=20last-night=20card,?=
=?UTF-8?q?=20promote=20chronotype,=20fix=20the=20assessment=20slot?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove the single-night "Last night" hypnogram from the sleep overview: its
per-stage breakdown (time + %) duplicated the phase-distribution stacked bar
directly below it. The /api/sleep/night route stays (the dashboard reads it);
only the redundant surface is gone.
Promote the chronotype to the prominent bottom block of the view. Split it out
of the rhythm section (now the sleep-debt headline alone) into its own section:
a large band label with the natural sleep-midpoint clock beneath it and an
expandable disclosure for social jetlag + MSFsc. It reads the same
["sleep-rhythm"] cache, so it adds no round-trip, and keeps the calm
"still learning your rhythm — N of M nights" state.
Fix the sleep-quality assessment slot. It disabled the status fetch whenever the
AI narrative was absent, so the card could not read hasProvider and fell back to
its no-provider default — surfacing the "connect an AI provider" CTA even for
accounts that have a provider connected, stacked under the grounded note. The
card now stays enabled and reports the truthful state: no provider shows its
clean AI-settings CTA alone; a connected provider with no text yet shows the
grounded note; warm or preparing AI text wins. Exactly one block fills the slot.
---
messages/de.json | 3 +-
messages/en.json | 3 +-
messages/es.json | 3 +-
messages/fr.json | 3 +-
messages/it.json | 3 +-
messages/pl.json | 3 +-
src/app/insights/sleep/page.tsx | 18 ++-
src/components/insights/sleep-overview.tsx | 30 ++---
.../__tests__/chronotype-section.test.tsx | 106 ++++++++++++++++++
.../__tests__/sleep-rhythm-cards.test.tsx | 14 +--
.../__tests__/sleep-rhythm-section.test.tsx | 50 ++-------
.../insights/sleep/chronotype-card.tsx | 63 +++++------
.../insights/sleep/chronotype-section.tsx | 57 ++++++++++
.../insights/sleep/sleep-quality-section.tsx | 75 ++++++++-----
.../insights/sleep/sleep-rhythm-section.tsx | 40 ++-----
15 files changed, 292 insertions(+), 179 deletions(-)
create mode 100644 src/components/insights/sleep/__tests__/chronotype-section.test.tsx
create mode 100644 src/components/insights/sleep/chronotype-section.tsx
diff --git a/messages/de.json b/messages/de.json
index a39f15431..b02655945 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2569,7 +2569,6 @@
"chronotype": {
"title": "Chronotyp",
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
- "midSleepCaption": "Deine natürliche Schlafmitte an freien Tagen liegt etwa um {clock}.",
"advancedToggle": "Mehr Details",
"socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag",
"socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.",
@@ -2583,7 +2582,7 @@
"late": "Abendtyp",
"extreme_late": "Ausgeprägter Abendtyp"
},
- "cornerLabel": "Chronotyp"
+ "midpointHeadline": "Deine natürliche Schlafmitte liegt etwa um {clock}."
},
"rhythm": {
"loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht."
diff --git a/messages/en.json b/messages/en.json
index 6c8e829fb..ddb660adf 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2569,7 +2569,6 @@
"chronotype": {
"title": "Chronotype",
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
- "midSleepCaption": "Your natural sleep midpoint on free days lands around {clock}.",
"advancedToggle": "Advanced",
"socialJetlagValue": "{hours}h {minutes}m social jetlag",
"socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.",
@@ -2583,7 +2582,7 @@
"late": "Evening type",
"extreme_late": "Strong evening type"
},
- "cornerLabel": "Chronotype"
+ "midpointHeadline": "Your natural sleep midpoint is around {clock}."
},
"rhythm": {
"loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles."
diff --git a/messages/es.json b/messages/es.json
index 31455b452..8eff756e3 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2569,7 +2569,6 @@
"chronotype": {
"title": "Cronotipo",
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
- "midSleepCaption": "El punto medio natural de tu sueño en días libres ronda las {clock}.",
"advancedToggle": "Avanzado",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.",
@@ -2583,7 +2582,7 @@
"late": "Vespertino",
"extreme_late": "Vespertino marcado"
},
- "cornerLabel": "Cronotipo"
+ "midpointHeadline": "El punto medio natural de tu sueño ronda las {clock}."
},
"rhythm": {
"loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión."
diff --git a/messages/fr.json b/messages/fr.json
index 0a88a045f..6a8e8797e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2569,7 +2569,6 @@
"chronotype": {
"title": "Chronotype",
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
- "midSleepCaption": "Le milieu naturel de votre sommeil les jours de repos se situe vers {clock}.",
"advancedToggle": "Avancé",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.",
@@ -2583,7 +2582,7 @@
"late": "Type du soir",
"extreme_late": "Type du soir marqué"
},
- "cornerLabel": "Chronotype"
+ "midpointHeadline": "Le milieu naturel de votre sommeil se situe vers {clock}."
},
"rhythm": {
"loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie."
diff --git a/messages/it.json b/messages/it.json
index dfd76eb32..c6bf530a7 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2569,7 +2569,6 @@
"chronotype": {
"title": "Cronotipo",
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
- "midSleepCaption": "Il punto medio naturale del tuo sonno nei giorni liberi è intorno alle {clock}.",
"advancedToggle": "Avanzate",
"socialJetlagValue": "{hours} h {minutes} min di jet lag sociale",
"socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.",
@@ -2583,7 +2582,7 @@
"late": "Tipo serale",
"extreme_late": "Tipo serale marcato"
},
- "cornerLabel": "Cronotipo"
+ "midpointHeadline": "Il punto medio naturale del tuo sonno è intorno alle {clock}."
},
"rhythm": {
"loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile."
diff --git a/messages/pl.json b/messages/pl.json
index b6d2457e5..de8081d2b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2569,7 +2569,6 @@
"chronotype": {
"title": "Chronotyp",
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
- "midSleepCaption": "Naturalny środek Twojego snu w dni wolne przypada około {clock}.",
"advancedToggle": "Zaawansowane",
"socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego",
"socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.",
@@ -2583,7 +2582,7 @@
"late": "Typ wieczorny",
"extreme_late": "Wyraźny typ wieczorny"
},
- "cornerLabel": "Chronotyp"
+ "midpointHeadline": "Naturalny środek Twojego snu przypada około {clock}."
},
"rhythm": {
"loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje."
diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx
index d6f4271de..f8e779af6 100644
--- a/src/app/insights/sleep/page.tsx
+++ b/src/app/insights/sleep/page.tsx
@@ -14,6 +14,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section";
+import { ChronotypeSection } from "@/components/insights/sleep/chronotype-section";
import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -116,9 +117,11 @@ export default function InsightsSchlafPage() {
/>
{/*
- v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative
- (the same DTO iOS renders), gated on the data-bearing branch so a
- source-less account never fetches.
+ v1.17.0 — sleep-debt headline. Server-authoritative (the same DTO iOS
+ renders), gated on the data-bearing branch so a source-less account
+ never fetches. v1.18.7 W-D — the chronotype moved out to the prominent
+ bottom block (`` below); this section is the
+ sleep-debt headline alone.
*/}
@@ -134,6 +137,15 @@ export default function InsightsSchlafPage() {
+
+ {/*
+ v1.18.7 W-D — chronotype as the PROMINENT bottom treatment of the
+ Sleep view (maintainer directive): a large band label with the natural
+ sleep-midpoint clock beneath it and an expandable disclosure for social
+ jetlag + MSFsc. Reads the same `["sleep-rhythm"]` cache the debt section
+ warmed, so it adds no round-trip; gated on the data-bearing branch.
+ */}
+
);
}
diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx
index 8a4a9a824..6ff200c1c 100644
--- a/src/components/insights/sleep-overview.tsx
+++ b/src/components/insights/sleep-overview.tsx
@@ -2,13 +2,11 @@
import Link from "next/link";
import { Moon } from "lucide-react";
-import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
-import { queryKeys } from "@/lib/query-keys";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
@@ -16,9 +14,7 @@ import {
SleepStageStackedBar,
type SleepStageBreakdown,
} from "./sleep-stage-stacked-bar";
-import { SleepHypnogram, type SleepHypnogramSession } from "./sleep-hypnogram";
import { SleepDurationChart } from "./sleep-duration-chart";
-import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.4.25 W4c — Sleep insights composition.
@@ -30,9 +26,15 @@ import { apiGet } from "@/lib/api/api-fetch";
* 1. Headline card with the average nightly total (h / min) and the
* number of nights covered.
* 2. Stage composition stacked bar (REM / Deep / Core / Awake / In
- * bed / Asleep).
+ * bed / Asleep) — the canonical sleep-phase distribution.
* 3. Duration trend chart with chart-cog parity (`chartKey="sleep"`).
*
+ * v1.18.7 W-D — the single-night "Last night" hypnogram card was removed:
+ * its per-stage breakdown (time + %) duplicated the phase distribution the
+ * stage stacked bar already shows directly below it. The `/api/sleep/night`
+ * route stays (the dashboard still reads it); only this redundant surface is
+ * gone.
+ *
* Empty state: no SLEEP_DURATION rows yet → render the "Apple Health
* sync is coming in v1.5" message with a deep-link to Settings →
* Devices so the user can prepare. We do NOT render any of the three
@@ -84,22 +86,6 @@ export function SleepOverview() {
const sleepStages = data?.sleepStages ?? null;
const totalCount = sleepSummary?.count ?? 0;
- // v1.11.5 — last-night hypnogram. Read-only over existing per-stage rows;
- // gated on the same data-bearing branch so a source-less account never
- // fetches. Shares the measurement-write invalidation prefix.
- const nightQuery = useQuery({
- queryKey: queryKeys.sleepNight(),
- queryFn: async () => {
- return apiGet<{
- night: string | null;
- main: SleepHypnogramSession | null;
- }>("/api/sleep/night");
- },
- enabled: isAuthenticated && totalCount > 0,
- staleTime: 60 * 1000,
- });
- const lastNight = nightQuery.data?.main ?? null;
-
if (!isLoading && totalCount === 0) {
return (
- {lastNight && }
-
{sleepStages ? (
) : (
diff --git a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx
new file mode 100644
index 000000000..7d09a89d0
--- /dev/null
+++ b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { SleepRhythmDto } from "../use-sleep-rhythm";
+
+/**
+ * v1.18.7 W-D — chronotype SECTION states (the prominent bottom block).
+ *
+ * Mirrors the sleep-rhythm section guard: error → quiet inline notice,
+ * loading → skeleton, settled → the large band treatment. `useAuth` +
+ * `useSleepRhythm` are mocked so each branch is deterministic.
+ */
+
+const authMock = vi.fn();
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => authMock(),
+}));
+
+const rhythmMock = vi.fn();
+vi.mock("../use-sleep-rhythm", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useSleepRhythm: () => rhythmMock() };
+});
+
+const { ChronotypeSection } = await import("../chronotype-section");
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+const READY_DTO: SleepRhythmDto = {
+ sleepDebt: {
+ state: "ready",
+ debtMinutes: 600,
+ needMinutes: 420,
+ nightsCounted: 10,
+ windowNights: 14,
+ nightsUntilReady: 0,
+ },
+ chronotype: {
+ state: "ready",
+ msfMinutes: 5 * 60 + 41, // 05:41
+ msfScMinutes: 5 * 60,
+ band: "late",
+ socialJetlagMinutes: 75,
+ freeNightsCounted: 6,
+ workNightsCounted: 14,
+ freeNightsUntilReady: 0,
+ },
+};
+
+beforeEach(() => {
+ authMock.mockReturnValue({ isAuthenticated: true });
+ rhythmMock.mockReset();
+});
+
+describe("", () => {
+ it("renders the quiet inline notice on a query error", () => {
+ rhythmMock.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ });
+ const html = render( );
+ expect(html).toContain('data-slot="chronotype-error"');
+ expect(html).not.toContain('data-slot="chronotype-loading"');
+ });
+
+ it("shows the skeleton while loading", () => {
+ rhythmMock.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ });
+ const html = render( );
+ expect(html).toContain('data-slot="chronotype-loading"');
+ expect(html).not.toContain('data-slot="chronotype-error"');
+ });
+
+ it("renders the large band treatment + midpoint clock on a settled DTO", () => {
+ rhythmMock.mockReturnValue({
+ data: READY_DTO,
+ isLoading: false,
+ isError: false,
+ });
+ const html = render( );
+ expect(html).toContain('data-slot="chronotype-band"');
+ expect(html).toContain("Evening type");
+ expect(html).toContain("05:41");
+ // The section heading carries the chronotype title.
+ expect(html).toContain('data-slot="section-heading"');
+ });
+
+ it("renders nothing when disabled", () => {
+ rhythmMock.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ });
+ const html = render( );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
index ffa8e2572..c2c0bc53f 100644
--- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
+++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
@@ -110,20 +110,20 @@ describe("", () => {
expect(html).toContain("04:30");
});
- it("renders the band as a labelled top-right corner readout, not a badge", () => {
+ it("v1.18.7 W-D — renders the band as the large hero label, not a corner readout", () => {
const html = render( );
- // The labelled corner carries both the "Chronotype" label and the value.
- expect(html).toContain('data-slot="chronotype-corner"');
- expect(html).toContain("Chronotype");
+ // The reworked bottom treatment leads with a large band label + the
+ // natural-midpoint headline. The retired top-right corner readout is gone.
+ expect(html).not.toContain('data-slot="chronotype-corner"');
+ expect(html).toContain('data-slot="chronotype-band"');
expect(html).toContain("Intermediate type");
+ expect(html).toContain("text-2xl");
});
- it("inherits the standard Card rhythm (no compact override)", () => {
+ it("uses the semantic info token, never the raw Dracula cyan", () => {
const learning = render( );
const ready = render( );
for (const html of [learning, ready]) {
- expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
- expect(html).toContain("md:py-6");
expect(html).toContain("text-info");
expect(html).not.toContain("text-dracula-cyan");
}
diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
index 639992829..2d254ed3e 100644
--- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
+++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
@@ -12,8 +12,11 @@ import type { SleepRhythmDto } from "../use-sleep-rhythm";
* clinical panel's treatment:
* - a query error renders a quiet inline notice, NOT an endless skeleton
* - loading (and settled-but-undefined) shows the skeletons
- * - a settled DTO renders both cards
+ * - a settled DTO renders the sleep-debt card
* `useAuth` + `useSleepRhythm` are mocked so each branch is deterministic.
+ *
+ * v1.18.7 W-D — the chronotype card moved out to `` (the
+ * prominent bottom block); this section now owns the sleep-debt headline alone.
*/
const authMock = vi.fn();
@@ -87,54 +90,17 @@ describe("", () => {
expect(html).not.toContain('data-slot="sleep-rhythm-error"');
});
- it("renders both cards on a settled DTO with the standard Card rhythm", () => {
+ it("renders the sleep-debt card on a settled DTO and NOT the chronotype", () => {
rhythmMock.mockReturnValue({
data: READY_DTO,
isLoading: false,
isError: false,
});
const html = render( );
- expect(html).toContain("Intermediate type");
expect(html).toContain("10h 0m short");
- // The cards inherit the house Card rhythm (gap-4 py-4 md:gap-6 md:py-6) —
- // the old compact override must NOT reappear.
- expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
- expect(html).toContain("md:py-6");
- });
-
- it("places the two cards side by side when both carry settled data", () => {
- rhythmMock.mockReturnValue({
- data: READY_DTO,
- isLoading: false,
- isError: false,
- });
- const html = render( );
- // Two-up on large screens, single column below.
- expect(html).toContain("lg:grid-cols-2");
- });
-
- it("keeps a lone data-bearing card full width when the other is still learning", () => {
- rhythmMock.mockReturnValue({
- data: {
- ...READY_DTO,
- // Sleep-debt settled, chronotype still learning → no two-up grid.
- chronotype: {
- state: "learning",
- msfMinutes: null,
- msfScMinutes: null,
- band: null,
- socialJetlagMinutes: null,
- freeNightsCounted: 2,
- workNightsCounted: 5,
- freeNightsUntilReady: 4,
- },
- } satisfies SleepRhythmDto,
- isLoading: false,
- isError: false,
- });
- const html = render( );
- expect(html).toContain("grid-cols-1");
- expect(html).not.toContain("lg:grid-cols-2");
+ // The chronotype band lives in the separate bottom section now, never here.
+ expect(html).not.toContain("Intermediate type");
+ expect(html).not.toContain('data-slot="chronotype-band"');
});
it("renders nothing when disabled", () => {
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx
index e7ddfa94e..7d616d8a9 100644
--- a/src/components/insights/sleep/chronotype-card.tsx
+++ b/src/components/insights/sleep/chronotype-card.tsx
@@ -25,10 +25,12 @@ function hoursMinutes(totalMinutes: number): { hours: number; minutes: number }
/**
* v1.17.0 — chronotype card (MCTQ MSF / MSFsc band + social jetlag).
*
- * Progressive disclosure: the band + mid-sleep clock time show by default; the
- * social-jetlag detail + the sleep-debt-corrected MSFsc sit behind an
- * "advanced" toggle. While `learning` it shows "still learning your rhythm —
- * N of M nights" and asserts NO type. Warm, grounded copy; mobile-first.
+ * v1.18.7 W-D — reworked from a small corner-label card into the PROMINENT
+ * bottom treatment of the Sleep view: a LARGE band label is the hero, with the
+ * natural sleep-midpoint clock beneath it, and an "advanced" disclosure that
+ * reveals social jetlag + the sleep-debt-corrected MSFsc. While `learning` it
+ * keeps the calm "still learning your rhythm — N of M nights" copy and asserts
+ * NO band. Warm, grounded copy; mobile-first.
*/
export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const { t } = useTranslations();
@@ -38,7 +40,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const need =
chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
return (
-
+
@@ -64,40 +66,31 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null;
return (
-
+
- {/* The resolved chronotype rides the top-right corner as a labelled
- readout — a small "Chronotyp" label over the band value — rather
- than a badge buried in the body. The title stays left; the value
- is the thing the eye should land on first. */}
-
-
-
-
- {t("insights.sleep.chronotype.title")}
-
-
-
-
- {t("insights.sleep.chronotype.cornerLabel")}
-
-
- {bandLabel}
-
-
+
+
+
+ {t("insights.sleep.chronotype.title")}
+
-
- {msfClock && (
-
- {t("insights.sleep.chronotype.midSleepCaption", {
- clock: msfClock,
- })}
+
+ {/* The band is the hero of the bottom treatment — a large label the
+ eye lands on first, with the natural sleep-midpoint clock as the
+ grounding caption beneath it. */}
+
+
+ {bandLabel}
- )}
+ {msfClock && (
+
+ {t("insights.sleep.chronotype.midpointHeadline", {
+ clock: msfClock,
+ })}
+
+ )}
+
` (which now owns only the sleep-debt
+ * headline) so the chronotype gets a full-width treatment at the end of the
+ * page: a large band label, the natural sleep-midpoint clock beneath it, and an
+ * expandable disclosure for social jetlag + MSFsc. Reads the SAME
+ * server-authoritative DTO via `useSleepRhythm`, so it shares the
+ * `["sleep-rhythm"]` cache the debt section already warmed — no extra
+ * round-trip. Gated on `enabled` so a source-less account never fetches.
+ *
+ * States mirror the debt section: loading → skeleton, error → quiet inline
+ * notice, settled → the card (which carries its own calm "still learning"
+ * copy for the no-nights-yet case, since the route always returns a full DTO).
+ */
+export function ChronotypeSection({ enabled }: { enabled: boolean }) {
+ const { isAuthenticated } = useAuth();
+ const { t } = useTranslations();
+ const { data, isLoading, isError } = useSleepRhythm(
+ isAuthenticated && enabled,
+ );
+
+ if (!enabled) return null;
+
+ return (
+
+
+ {isError ? (
+
+ {t("insights.sleep.rhythm.loadError")}
+
+ ) : isLoading || !data ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx
index 51363ba9a..c7dde248b 100644
--- a/src/components/insights/sleep/sleep-quality-section.tsx
+++ b/src/components/insights/sleep/sleep-quality-section.tsx
@@ -134,16 +134,28 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
}
/**
- * The quality block's "Einschätzung". The richer AI narrative wins when it
- * exists; until then (or forever, on an account with no AI provider) a
- * deterministic note grounded in the user's own quality averages fills the
- * slot, so the assessment is never the bare "no analysis yet" line.
+ * The quality block's "Einschätzung".
*
- * Both reads are gated on the headline sleep score having data — the same gate
- * the AI route used — so the assessment only surfaces with a quality series to
- * describe. The grounded note reads the SLEEP_SCORE status query (already
- * warmed by ``) to know whether the AI text is absent, so it
- * adds no second round-trip and the two never stack.
+ * v1.18.7 W-D — the assessment slot was broken: the previous wiring disabled
+ * the status fetch whenever the AI narrative was absent, so the card could not
+ * read `hasProvider` and fell back to its `hasProvider: false` default — it
+ * showed the "connect an AI provider" CTA even for accounts that HAVE a
+ * provider connected (the text just hadn't been generated yet), AND it stacked
+ * that CTA under the grounded note. Two assessments, one of them wrong.
+ *
+ * The card now stays enabled (gated only on the metric having data), so it
+ * reports the truthful state from the route:
+ * - no provider → the route's `hasProvider: false` → the card's own
+ * clean "connect an AI provider → AI settings" CTA,
+ * ALONE (the grounded note stands down).
+ * - provider, no text yet → the grounded note fills the slot with a read
+ * built from the user's own quality averages, so the
+ * block is never the bare "no assessment yet" line.
+ * - provider, text/prep → the AI card owns the slot; the grounded note
+ * stands down so the two never stack.
+ *
+ * The grounded note reads the SAME SLEEP_SCORE status query the card warmed, so
+ * it adds no second round-trip.
*/
function SleepQualityAssessmentBlock({
summaries,
@@ -156,30 +168,37 @@ function SleepQualityAssessmentBlock({
hasSleepScore,
);
- // The AI narrative is "present" when there is text OR the worker is actively
- // assembling one (preparing / revalidating). In those cases the AI card owns
- // the slot. The grounded note only steps in once the status query has SETTLED
- // with no narrative — so it never flashes ahead of a warm AI assessment.
- const aiAbsent =
+ // The grounded note only steps in when a provider IS connected but the AI
+ // narrative has SETTLED with no text (and none is being assembled). When no
+ // provider is configured the AI card owns the slot with its own CTA, so the
+ // note never stacks under it; when the AI text exists or is preparing the AI
+ // card wins. Until the status query settles we show nothing extra, so the
+ // note never flashes ahead of a warm AI assessment.
+ const showGroundedNote =
+ hasSleepScore &&
+ status?.hasProvider === true &&
!statusLoading &&
!status?.text &&
status?.preparing !== true &&
status?.revalidating !== true;
+ // Exactly one block fills the assessment slot, so the two never stack: the
+ // grounded note when a provider is connected but the AI text has settled
+ // empty, otherwise the status card (which owns the no-provider CTA, the
+ // preparing state, and the real text). The card's hook fetched regardless of
+ // which branch renders — it runs at the top of this component — so swapping
+ // its render for the note loses no `hasProvider` knowledge.
+ if (showGroundedNote) {
+ return (
+
+ );
+ }
+
return (
- <>
- }
- // Suppress the AI card's static "no analysis yet" / "no provider"
- // empty states — the grounded note covers that ground with real
- // content. The AI card still renders its text / preparing states.
- enabled={hasSleepScore && !aiAbsent}
- />
-
- >
+ }
+ enabled={hasSleepScore}
+ />
);
}
diff --git a/src/components/insights/sleep/sleep-rhythm-section.tsx b/src/components/insights/sleep/sleep-rhythm-section.tsx
index a88466965..05402d62a 100644
--- a/src/components/insights/sleep/sleep-rhythm-section.tsx
+++ b/src/components/insights/sleep/sleep-rhythm-section.tsx
@@ -2,26 +2,28 @@
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
-import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { SleepDebtCard } from "./sleep-debt-card";
-import { ChronotypeCard } from "./chronotype-card";
/**
* v1.17.0 — sleep-rhythm section on the Sleep page.
*
* Reads the server-authoritative sleep-debt + chronotype DTO and renders the
- * sleep-debt headline + the chronotype card. Gated on `enabled` so a
- * source-less account never fetches. The values are identical to what the iOS
- * client renders (one server computation).
+ * sleep-debt headline. Gated on `enabled` so a source-less account never
+ * fetches. The values are identical to what the iOS client renders (one server
+ * computation).
+ *
+ * v1.18.7 W-D — the chronotype card moved out to ``, the
+ * prominent bottom treatment of the Sleep view; this section now owns the
+ * sleep-debt headline alone. Both read the same `["sleep-rhythm"]` cache.
*
* States, mirroring the glucose clinical panel's treatment:
- * - loading → skeletons (distinct from a settled-but-empty read)
+ * - loading → skeleton (distinct from a settled-but-empty read)
* - error → a quiet inline notice, never an endless skeleton
- * - settled → the two cards, which carry their OWN calm "still learning" /
+ * - settled → the debt card, which carries its OWN calm "still learning" /
* source-cue copy for the no-nights-yet case (the route always
- * returns a full DTO, so emptiness surfaces inside the cards).
+ * returns a full DTO, so emptiness surfaces inside the card).
*/
export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
@@ -48,29 +50,9 @@ export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
return (
-
);
}
- // Both cards are compact; when each carries a settled (non-learning) readout
- // they sit two-up. A lone card with data spans full width so it never leaves
- // a half-width orphan with dead space beside it (mirrors the mood Einordnung
- // tiles). The cards' own learning/partial states stay stacked full-width.
- const debtHasData = data.sleepDebt.state === "ready";
- const chronotypeHasData =
- data.chronotype.state === "ready" && data.chronotype.band != null;
- const bothHaveData = debtHasData && chronotypeHasData;
-
- return (
-
-
-
-
- );
+ return ;
}
From d35c7660ea4c7002c07df75af10b137d798eec0d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:52:21 +0200
Subject: [PATCH 08/71] test(insights): assert the overview no longer wires the
inline ask-Coach affordance
Wave A removed the inline ask-the-Coach / suggested-questions block from
the overview hero (Coach is reached via the FAB + drawer). Update the
stale assertion that still expected onAskCoach/onPickPrompt + the launch
context on the page.
---
src/app/__tests__/insights-polish.test.ts | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts
index ab59ec3a6..1029c31af 100644
--- a/src/app/__tests__/insights-polish.test.ts
+++ b/src/app/__tests__/insights-polish.test.ts
@@ -86,15 +86,15 @@ describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
expect(layout).toContain("LayoutCoachFab");
});
- it("hero strip's onAskCoach handler calls the launch context", () => {
+ it("the overview no longer wires the inline ask-the-Coach affordance", () => {
const src = load(INSIGHTS_PATH);
- // The hero supplies onAskCoach which now delegates to
- // `coachLaunch.askCoach(prefill)` from the layout-level provider
- // instead of toggling local state.
- expect(src).toMatch(/ {
From aa092df9f046ddd2c9e39070d2d323c6f498d11b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 19:58:48 +0200
Subject: [PATCH 09/71] refactor(settings): unify the integration card skeleton
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every Settings → Integrations block now paints one consistent shape:
heading (icon + name + status pill) → a white primary description → a
grey secondary description that ends with the setup-guide link rendered
inline, instead of a standalone bottom-of-card element.
OAuth-only (WHOOP/Fitbit), BYO-credential (Withings/Oura/Polar) and
manual-key (Nightscout) cards share the same skeleton via the new
IntegrationCardDescription helper; the credential form stays as an extra
block where it applies. The "where to find these credentials" hint moves
above the Save button instead of sitting below it.
Rewrite the per-provider description strings across all six locales into a
parallel two-part shape with even length, and drop the now-orphaned
overlap/experimental sub-notes and the standalone setup-guide link.
---
messages/de.json | 21 ++++++----
messages/en.json | 19 ++++++---
messages/es.json | 19 ++++++---
messages/fr.json | 21 ++++++----
messages/it.json | 19 ++++++---
messages/pl.json | 21 ++++++----
.../settings/integrations/fitbit-card.tsx | 26 ++++---------
.../settings/integrations/nightscout-card.tsx | 15 ++++---
.../integrations/oauth-provider-card.tsx | 24 ++++++------
.../integrations/setup-guide-link.tsx | 39 +++++++++++++------
.../settings/integrations/whoop-card.tsx | 20 ++++------
.../settings/integrations/withings-card.tsx | 14 +++++--
12 files changed, 154 insertions(+), 104 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index bea52bb4c..b244ec522 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4328,7 +4328,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
+ "withingsDescription": "Verbinde deine Withings-Waage und dein Blutdruckmessgerät, um Gewicht, Körperzusammensetzung und Blutdruck automatisch zu synchronisieren.",
"withingsCredentials": "API-Zugangsdaten",
"withingsClientId": "Client-ID",
"withingsClientSecret": "Client-Secret",
@@ -4366,7 +4366,7 @@
"withingsConnect": "Mit Withings verbinden",
"withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.",
"whoop": "WHOOP",
- "whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts zu synchronisieren.",
+ "whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts direkt in HealthLog zu synchronisieren.",
"whoopOverlapNote": "Wenn WHOOP und eine weitere Quelle denselben Vitalwert liefern – Ruhepuls, Blutsauerstoff, Körpertemperatur, Atemfrequenz oder Schlafphasen –, können beide Werte angezeigt werden, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
"whoopCredentials": "API-Zugangsdaten",
"whoopCredentialsHelp": "Registriere deine eigene WHOOP-Entwickler-App und füge hier Client ID und Client Secret ein.",
@@ -4403,7 +4403,7 @@
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
- "fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.",
+ "fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
"fitbitOverlapNote": "Wenn Google Health und eine andere Quelle denselben Vitalwert liefern — Ruhepuls, Sauerstoffsättigung, Körpertemperatur oder Atemfrequenz —, siehst du möglicherweise beide Werte, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
"fitbitExperimentalBadge": "Experimentell",
"fitbitExperimentalNote": "Diese Integration befindet sich in der Beta — die Datenabdeckung und die Feldzuordnung werden noch gegen echte Google-Health-Konten geprüft, daher können einzelne Werte fehlen oder sich in einem künftigen Update ändern.",
@@ -4678,7 +4678,7 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.",
+ "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.",
"nightscoutViewData": "Glukose ansehen",
"nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.",
"nightscoutUrl": "Nightscout-URL",
@@ -4697,7 +4697,7 @@
"nightscoutDisconnectTitle": "Nightscout trennen?",
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
"polar": "Polar",
- "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
+ "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und tägliche Aktivität zu synchronisieren.",
"polarViewData": "Schlaf & Erholung ansehen",
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"polarCredentials": "API-Zugangsdaten",
@@ -4725,7 +4725,7 @@
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"oura": "Oura",
- "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
+ "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.",
"ouraViewData": "Schlaf & Erholung ansehen",
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"ouraCredentials": "API-Zugangsdaten",
@@ -4762,7 +4762,14 @@
"savedOff": "Coach ist ausgeblendet.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
}
- }
+ },
+ "withingsDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "whoopDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "fitbitDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
+ "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben."
},
"admin": {
"title": "Administration",
diff --git a/messages/en.json b/messages/en.json
index 650a8ddac..e4ebe1147 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4328,7 +4328,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Connect your Withings scale and blood pressure monitors.",
+ "withingsDescription": "Connect your Withings scale and blood-pressure monitor to sync weight, body composition, and blood pressure automatically.",
"withingsCredentials": "API Credentials",
"withingsClientId": "Client ID",
"withingsClientSecret": "Client Secret",
@@ -4366,7 +4366,7 @@
"withingsConnect": "Connect with Withings",
"withingsNoCredentials": "Please enter your API credentials above to connect Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain and workouts.",
+ "whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain, and workouts straight into HealthLog.",
"whoopOverlapNote": "If WHOOP and another source both supply a vital — resting heart rate, blood oxygen, body temperature, respiratory rate or sleep stages — you may see both values until a future update settles on a single preferred source.",
"whoopCredentials": "API Credentials",
"whoopCredentialsHelp": "Register your own WHOOP developer app and paste its Client ID and Client Secret here.",
@@ -4403,7 +4403,7 @@
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
- "fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.",
+ "fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
"fitbitOverlapNote": "If Google Health and another source both supply a vital — resting heart rate, blood oxygen, body temperature or respiratory rate — you may see both values until a future update settles on a single preferred source.",
"fitbitExperimentalBadge": "Experimental",
"fitbitExperimentalNote": "This integration is in beta — data coverage and field mapping are still being verified against live Google Health accounts, so some values may be missing or change in a future update.",
@@ -4678,7 +4678,7 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.",
+ "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.",
"nightscoutViewData": "View your glucose",
"nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.",
"nightscoutUrl": "Nightscout URL",
@@ -4725,7 +4725,7 @@
"generic": "Unknown error while connecting. Please start the connection again."
},
"oura": "Oura",
- "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
+ "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, heart-rate variability, and daily activity.",
"ouraViewData": "View your sleep & recovery",
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
"ouraCredentials": "API Credentials",
@@ -4762,7 +4762,14 @@
"savedOff": "Coach hidden.",
"saveError": "Could not save the change. Please try again."
}
- }
+ },
+ "withingsDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "whoopDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "fitbitDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
+ "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above."
},
"admin": {
"title": "Administration",
diff --git a/messages/es.json b/messages/es.json
index ab0edfa93..80d6ea029 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4328,7 +4328,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Conecta tu báscula y tensiómetros Withings.",
+ "withingsDescription": "Conecta tu báscula y tensiómetro Withings para sincronizar peso, composición corporal y tensión arterial automáticamente.",
"withingsCredentials": "Credenciales de API",
"withingsClientId": "ID de cliente",
"withingsClientSecret": "Secreto de cliente",
@@ -4366,7 +4366,7 @@
"withingsConnect": "Conectar con Withings",
"withingsNoCredentials": "Introduce primero tus credenciales de API arriba para conectar Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos.",
+ "whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos directamente en HealthLog.",
"whoopOverlapNote": "Si WHOOP y otra fuente proporcionan el mismo valor vital —frecuencia cardíaca en reposo, oxígeno en sangre, temperatura corporal, frecuencia respiratoria o fases del sueño—, es posible que veas ambos valores hasta que una futura actualización elija una única fuente preferida.",
"whoopCredentials": "Credenciales de API",
"whoopCredentialsHelp": "Registra tu propia app de desarrollador de WHOOP y pega aquí su Client ID y Client Secret.",
@@ -4403,7 +4403,7 @@
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit y Pixel",
- "fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.",
+ "fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
"fitbitOverlapNote": "Si Google Health y otra fuente proporcionan la misma constante vital —frecuencia cardíaca en reposo, saturación de oxígeno, temperatura corporal o frecuencia respiratoria—, es posible que veas ambos valores hasta que una actualización futura defina una única fuente preferida.",
"fitbitExperimentalBadge": "Experimental",
"fitbitExperimentalNote": "Esta integración está en fase beta: la cobertura de datos y la asignación de campos aún se están verificando con cuentas reales de Google Health, por lo que algunos valores pueden faltar o cambiar en una actualización futura.",
@@ -4697,7 +4697,7 @@
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
"polar": "Polar",
- "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
+ "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño y actividad diaria.",
"polarViewData": "Ver tu sueño y recuperación",
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"polarCredentials": "Credenciales de API",
@@ -4725,7 +4725,7 @@
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"oura": "Oura",
- "ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
+ "ouraDescription": "Conecta tu cuenta de Oura para sincronizar disposición, fases de sueño, variabilidad cardíaca y actividad diaria.",
"ouraViewData": "Ver tu sueño y recuperación",
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"ouraCredentials": "Credenciales de API",
@@ -4762,7 +4762,14 @@
"savedOff": "Coach oculto.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
}
- }
+ },
+ "withingsDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "whoopDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "fitbitDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
+ "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba."
},
"admin": {
"title": "Administration",
diff --git a/messages/fr.json b/messages/fr.json
index 5026783b9..c52b796c7 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4328,7 +4328,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Connectez votre balance et vos tensiomètres Withings.",
+ "withingsDescription": "Connecte ta balance et ton tensiomètre Withings pour synchroniser automatiquement poids, composition corporelle et tension.",
"withingsCredentials": "Identifiants API",
"withingsClientId": "ID client",
"withingsClientSecret": "Secret client",
@@ -4366,7 +4366,7 @@
"withingsConnect": "Se connecter avec Withings",
"withingsNoCredentials": "Saisissez d’abord vos identifiants API ci-dessus pour connecter Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Connectez votre bracelet WHOOP pour synchroniser récupération, sommeil, effort et entraînements.",
+ "whoopDescription": "Connecte ton bracelet WHOOP pour synchroniser récupération, sommeil, charge et entraînements directement dans HealthLog.",
"whoopOverlapNote": "Si WHOOP et une autre source fournissent la même donnée vitale — fréquence cardiaque au repos, oxygène sanguin, température corporelle, fréquence respiratoire ou phases de sommeil —, les deux valeurs peuvent apparaître jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
"whoopCredentials": "Identifiants API",
"whoopCredentialsHelp": "Enregistrez votre propre application développeur WHOOP et collez ici son Client ID et son Client Secret.",
@@ -4403,7 +4403,7 @@
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit et Pixel",
- "fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.",
+ "fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
"fitbitOverlapNote": "Si Google Health et une autre source fournissent la même constante vitale — fréquence cardiaque au repos, saturation en oxygène, température corporelle ou fréquence respiratoire —, vous pourriez voir les deux valeurs jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
"fitbitExperimentalBadge": "Expérimental",
"fitbitExperimentalNote": "Cette intégration est en bêta : la couverture des données et la correspondance des champs sont encore en cours de vérification avec de vrais comptes Google Health, donc certaines valeurs peuvent manquer ou changer lors d'une future mise à jour.",
@@ -4678,7 +4678,7 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.",
+ "nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.",
"nightscoutViewData": "Voir votre glycémie",
"nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.",
"nightscoutUrl": "URL Nightscout",
@@ -4697,7 +4697,7 @@
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
"polar": "Polar",
- "polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
+ "polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité.",
"polarViewData": "Voir votre sommeil et récupération",
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"polarCredentials": "Identifiants API",
@@ -4725,7 +4725,7 @@
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"oura": "Oura",
- "ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
+ "ouraDescription": "Connecte ton compte Oura pour synchroniser readiness, phases de sommeil, variabilité cardiaque et activité quotidienne.",
"ouraViewData": "Voir votre sommeil et récupération",
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"ouraCredentials": "Identifiants API",
@@ -4762,7 +4762,14 @@
"savedOff": "Coach masqué.",
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
}
- }
+ },
+ "withingsDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "whoopDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "fitbitDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
+ "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus."
},
"admin": {
"title": "Administration",
diff --git a/messages/it.json b/messages/it.json
index 7ff1785b9..b668a943e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4328,7 +4328,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Collega la tua bilancia e i misuratori di pressione Withings.",
+ "withingsDescription": "Collega la bilancia e il misuratore di pressione Withings per sincronizzare peso, composizione corporea e pressione automaticamente.",
"withingsCredentials": "Credenziali API",
"withingsClientId": "ID client",
"withingsClientSecret": "Secret client",
@@ -4366,7 +4366,7 @@
"withingsConnect": "Connetti con Withings",
"withingsNoCredentials": "Inserisci prima le tue credenziali API qui sopra per collegare Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Collega la tua fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti.",
+ "whoopDescription": "Collega la fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti direttamente in HealthLog.",
"whoopOverlapNote": "Se WHOOP e un'altra fonte forniscono lo stesso valore vitale — frequenza cardiaca a riposo, ossigeno nel sangue, temperatura corporea, frequenza respiratoria o fasi del sonno —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
"whoopCredentials": "Credenziali API",
"whoopCredentialsHelp": "Registra la tua app sviluppatore WHOOP e incolla qui il suo Client ID e Client Secret.",
@@ -4403,7 +4403,7 @@
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit e Pixel",
- "fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.",
+ "fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
"fitbitOverlapNote": "Se Google Health e un'altra fonte forniscono lo stesso parametro vitale — frequenza cardiaca a riposo, saturazione di ossigeno, temperatura corporea o frequenza respiratoria —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
"fitbitExperimentalBadge": "Sperimentale",
"fitbitExperimentalNote": "Questa integrazione è in versione beta: la copertura dei dati e la mappatura dei campi sono ancora in fase di verifica con account Google Health reali, quindi alcuni valori potrebbero mancare o cambiare in un futuro aggiornamento.",
@@ -4678,7 +4678,7 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.",
+ "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.",
"nightscoutViewData": "Vedi la tua glicemia",
"nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.",
"nightscoutUrl": "URL Nightscout",
@@ -4725,7 +4725,7 @@
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"oura": "Oura",
- "ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
+ "ouraDescription": "Collega il tuo account Oura per sincronizzare prontezza, fasi del sonno, variabilità cardiaca e attività giornaliera.",
"ouraViewData": "Vedi sonno e recupero",
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"ouraCredentials": "Credenziali API",
@@ -4762,7 +4762,14 @@
"savedOff": "Coach nascosto.",
"saveError": "Impossibile salvare la modifica. Riprova."
}
- }
+ },
+ "withingsDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "whoopDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "fitbitDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
+ "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra."
},
"admin": {
"title": "Administration",
diff --git a/messages/pl.json b/messages/pl.json
index f09dacc22..d5a1a412e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4328,7 +4328,7 @@
}
},
"withings": "Withings",
- "withingsDescription": "Połącz swoją wagę i ciśnieniomierze Withings.",
+ "withingsDescription": "Połącz wagę i ciśnieniomierz Withings, aby automatycznie synchronizować wagę, skład ciała i ciśnienie krwi.",
"withingsCredentials": "Dane logowania API",
"withingsClientId": "ID klienta",
"withingsClientSecret": "Sekret klienta",
@@ -4366,7 +4366,7 @@
"withingsConnect": "Połącz z Withings",
"withingsNoCredentials": "Najpierw wprowadź powyżej dane logowania API, aby połączyć Withings.",
"whoop": "WHOOP",
- "whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi.",
+ "whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi prosto do HealthLog.",
"whoopOverlapNote": "Jeśli WHOOP i inne źródło dostarczają tę samą wartość życiową — tętno spoczynkowe, saturację, temperaturę ciała, częstość oddechów lub fazy snu — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
"whoopCredentials": "Dane API",
"whoopCredentialsHelp": "Zarejestruj własną aplikację deweloperską WHOOP i wklej tutaj jej Client ID oraz Client Secret.",
@@ -4403,7 +4403,7 @@
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit i Pixel",
- "fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.",
+ "fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
"fitbitOverlapNote": "Jeśli Google Health i inne źródło dostarczają ten sam parametr życiowy — tętno spoczynkowe, saturację, temperaturę ciała lub częstość oddechów — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
"fitbitExperimentalBadge": "Eksperymentalne",
"fitbitExperimentalNote": "Ta integracja jest w wersji beta — zakres danych i mapowanie pól są wciąż weryfikowane na rzeczywistych kontach Google Health, więc niektóre wartości mogą być niedostępne lub zmienić się w przyszłej aktualizacji.",
@@ -4678,7 +4678,7 @@
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
- "nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
+ "nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
"nightscoutViewData": "Zobacz swoją glukozę",
"nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.",
"nightscoutUrl": "Adres URL Nightscout",
@@ -4697,7 +4697,7 @@
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
"polar": "Polar",
- "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
+ "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i dzienną aktywność.",
"polarViewData": "Zobacz sen i regenerację",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"polarCredentials": "Dane API",
@@ -4725,7 +4725,7 @@
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"oura": "Oura",
- "ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
+ "ouraDescription": "Połącz konto Oura, aby synchronizować gotowość, fazy snu, zmienność rytmu serca i dzienną aktywność.",
"ouraViewData": "Zobacz sen i regenerację",
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"ouraCredentials": "Dane API",
@@ -4762,7 +4762,14 @@
"savedOff": "Coach ukryty.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
}
- }
+ },
+ "withingsDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "whoopDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "fitbitDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
+ "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej."
},
"admin": {
"title": "Administracja",
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx
index f2b1c3b04..1b280ad7d 100644
--- a/src/components/settings/integrations/fitbit-card.tsx
+++ b/src/components/settings/integrations/fitbit-card.tsx
@@ -51,7 +51,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
-import { IntegrationSetupGuideLink } from "./setup-guide-link";
+import { IntegrationCardDescription } from "./setup-guide-link";
export function FitbitCard({
viewModel,
@@ -210,18 +210,10 @@ export function FitbitCard({
>
}
description={
- <>
- {t("settings.fitbitDescription")}
-
- {t("settings.fitbitExperimentalNote")}
-
-
- {t("settings.fitbitOverlapNote")}
-
- >
+
}
status={
{t("settings.fitbitCredentials")}
-
- {t("settings.fitbitCredentialsHelp")}
-
);
diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx
index 6dc028bb9..c0ad7785a 100644
--- a/src/components/settings/integrations/nightscout-card.tsx
+++ b/src/components/settings/integrations/nightscout-card.tsx
@@ -39,7 +39,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys";
import { IntegrationErrorMessage } from "./shared";
-import { IntegrationSetupGuideLink } from "./setup-guide-link";
+import { IntegrationCardDescription } from "./setup-guide-link";
interface NightscoutStatus {
connected: boolean;
@@ -170,7 +170,12 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
{t("settings.nightscoutTag")}
}
- description={{t("settings.nightscoutDescription")}
}
+ description={
+
+ }
status={
)}
-
- {t("settings.nightscoutHelp")}
-
-
);
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index 1b0e5aa41..f107a1772 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -41,7 +41,10 @@ import {
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
-import { IntegrationSetupGuideLink } from "./setup-guide-link";
+import {
+ IntegrationCardDescription,
+ type IntegrationDocsProvider,
+} from "./setup-guide-link";
export interface OAuthProviderStatus {
connected: boolean;
@@ -205,7 +208,12 @@ export function OAuthProviderCard({
{t(`${i18nPrefix}Description`)}}
+ description={
+
+ }
status={
)}
-
- {t(`${i18nPrefix}Help`)}
-
-
{credentials && (
{t(`${i18nPrefix}Credentials`)}
-
- {t(`${i18nPrefix}CredentialsHelp`)}
-
);
diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx
index a6cf6a652..d4b4c2530 100644
--- a/src/components/settings/integrations/setup-guide-link.tsx
+++ b/src/components/settings/integrations/setup-guide-link.tsx
@@ -35,23 +35,38 @@ export function integrationDocsHref(provider: IntegrationDocsProvider): string {
return `${INTEGRATION_DOCS_BASE}/${provider}`;
}
-export function IntegrationSetupGuideLink({
+/**
+ * v1.18.7 (Wave F) — the one description skeleton every integration card paints:
+ * a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary
+ * sentence (`${i18nPrefix}DescriptionSecondary`) that ends with the setup-guide
+ * link rendered INLINE at the very end of the grey copy — not a standalone
+ * bottom-of-card element. Single-sourced so all six cards read as one family.
+ */
+export function IntegrationCardDescription({
+ i18nPrefix,
provider,
}: {
+ i18nPrefix: string;
provider: IntegrationDocsProvider;
}) {
const { t } = useTranslations();
return (
-
- {t("settings.integrationSetupGuide")}
-
-
+ <>
+ {t(`${i18nPrefix}Description`)}
+
+ {t(`${i18nPrefix}DescriptionSecondary`)}{" "}
+
+ {t("settings.integrationSetupGuide")}
+
+
+
+ >
);
}
diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx
index 23b875f4e..4d7b4797a 100644
--- a/src/components/settings/integrations/whoop-card.tsx
+++ b/src/components/settings/integrations/whoop-card.tsx
@@ -43,7 +43,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
-import { IntegrationSetupGuideLink } from "./setup-guide-link";
+import { IntegrationCardDescription } from "./setup-guide-link";
export function WhoopCard({
viewModel,
@@ -187,12 +187,10 @@ export function WhoopCard({
icon={Activity}
title={t("settings.whoop")}
description={
- <>
- {t("settings.whoopDescription")}
-
- {t("settings.whoopOverlapNote")}
-
- >
+
}
status={
{t("settings.whoopCredentials")}
-
- {t("settings.whoopCredentialsHelp")}
-
);
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx
index 009566d7d..2560322c3 100644
--- a/src/components/settings/integrations/withings-card.tsx
+++ b/src/components/settings/integrations/withings-card.tsx
@@ -36,7 +36,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
-import { IntegrationSetupGuideLink } from "./setup-guide-link";
+import { IntegrationCardDescription } from "./setup-guide-link";
export function WithingsCard({
viewModel,
@@ -194,7 +194,12 @@ export function WithingsCard({
+ }
status={
+
+ {t("settings.integrationCredentialsHint")}
+
)}
-
-
);
From c334be22e91ccb47e1f120688da16c72acea677a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:00:04 +0200
Subject: [PATCH 10/71] feat(coach): tier history context + illness and
learn-guide awareness
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wire the Coach snapshot's core-metric history onto the shared tiered
series so blood pressure, weight, and pulse carry a coarse tail the
in-window weekly fold could not produce — 90d-1y monthly and >1y yearly
buckets plus the preserved peak/trough envelope, so a spike from months
ago survives a coarse bucket. The tiered read skips the raw 0-14d band
the snapshot already holds, so no duplicate round-trip; the coarse tail
is bounded per band and sheds first under the prompt-budget degrader, so
token size holds.
Surface the active illness episodes and Rest Mode state to the Coach
prompt: the snapshot already carries the illness block, and the system
prompt now instructs the Coach to read an off-band vital as plausibly
illness-explained and avoid pushing a heavier cadence while the user is
in Rest Mode.
Add a static catalog of the public /learn guides and inject a compact
list into the system prompt. The Coach may reference at most one guide
by topic and URL when genuinely helpful, and only a slug the catalog
lists — a fabricated /learn URL is impossible by construction.
---
src/lib/ai/coach/learn-catalog.ts | 110 +++++++++++++++++++++++++++
src/lib/ai/coach/snapshot.ts | 119 ++++++++++++++++++++++++++++++
src/lib/ai/coach/system-prompt.ts | 34 ++++++++-
src/lib/rollups/tiered-context.ts | 11 ++-
4 files changed, 272 insertions(+), 2 deletions(-)
create mode 100644 src/lib/ai/coach/learn-catalog.ts
diff --git a/src/lib/ai/coach/learn-catalog.ts b/src/lib/ai/coach/learn-catalog.ts
new file mode 100644
index 000000000..b1c3f38cf
--- /dev/null
+++ b/src/lib/ai/coach/learn-catalog.ts
@@ -0,0 +1,110 @@
+/**
+ * v1.18.7 — static catalog of the public /learn guides for the Coach.
+ *
+ * The Coach is article-aware: when a reply would genuinely benefit from a
+ * deeper read, it MAY point the user at the matching guide by topic + URL.
+ * The article wording itself lives on the public site (docs.healthlog.dev /
+ * healthlog.dev/learn) — the Coach never invents article content and may only
+ * reference a slug that appears in this catalog, so a fabricated /learn URL is
+ * impossible by construction.
+ *
+ * Keep this list in lock-step with the published guides. The compact prompt
+ * injection (`learnCatalogPromptBlock`) groups entries by topic so the model
+ * picks the most relevant single link rather than dumping the whole list.
+ */
+
+const LEARN_BASE_URL = "https://healthlog.dev/learn";
+
+export interface LearnGuide {
+ /** URL slug under /learn. */
+ slug: string;
+ /** Concise English title. */
+ title: string;
+ /** 3–6 word topic the guide covers, for matching. */
+ topic: string;
+ /** Absolute public URL. */
+ url: string;
+}
+
+function guide(slug: string, title: string, topic: string): LearnGuide {
+ return { slug, title, topic, url: `${LEARN_BASE_URL}/${slug}` };
+}
+
+/**
+ * The 19 published /learn guides. Order groups roughly by domain (overview →
+ * cardiovascular → sleep/respiratory → metabolic → body → mood/cycle →
+ * device/trends → lifestyle) so the prompt block reads coherently.
+ */
+export const LEARN_GUIDES: readonly LearnGuide[] = [
+ guide(
+ "understanding-your-health-metrics",
+ "Understanding Your Health Metrics",
+ "what each metric means",
+ ),
+ guide("resting-heart-rate", "Resting Heart Rate", "resting heart rate"),
+ guide(
+ "heart-rate-variability",
+ "Heart Rate Variability",
+ "HRV and autonomic balance",
+ ),
+ guide(
+ "reading-your-blood-pressure",
+ "Reading Your Blood Pressure",
+ "blood pressure readings",
+ ),
+ guide("sleep-consistency", "Sleep Consistency", "consistent sleep timing"),
+ guide("respiratory-rate", "Respiratory Rate", "breathing rate at rest"),
+ guide("blood-oxygen-spo2", "Blood Oxygen (SpO2)", "blood oxygen saturation"),
+ guide(
+ "body-temperature-baseline",
+ "Body Temperature Baseline",
+ "body temperature deviation",
+ ),
+ guide(
+ "blood-sugar-beyond-diabetes",
+ "Blood Sugar Beyond Diabetes",
+ "glucose for everyone",
+ ),
+ guide(
+ "vo2max-and-longevity",
+ "VO2 Max and Longevity",
+ "cardiorespiratory fitness",
+ ),
+ guide("beyond-the-scale", "Beyond the Scale", "weight and body composition"),
+ guide("tracking-mood", "Tracking Mood", "mood and wellbeing"),
+ guide(
+ "the-cycle-as-a-vital-sign",
+ "The Cycle as a Vital Sign",
+ "menstrual cycle health",
+ ),
+ guide(
+ "how-wearables-measure-you",
+ "How Wearables Measure You",
+ "how devices read vitals",
+ ),
+ guide("reading-your-trends", "Reading Your Trends", "interpreting trends"),
+ guide("steps-and-movement", "Steps and Movement", "daily activity"),
+ guide(
+ "caffeine-alcohol-and-your-readings",
+ "Caffeine, Alcohol and Your Readings",
+ "caffeine and alcohol effects",
+ ),
+ guide("hydration-and-your-body", "Hydration and Your Body", "hydration"),
+ guide("stress-and-recovery", "Stress and Recovery", "stress and recovery"),
+] as const;
+
+/**
+ * Compact, token-light prompt block listing every guide as `topic — url`.
+ * Injected into the Coach system prompt so the model can offer the relevant
+ * link. One line per guide keeps it ~600 chars; the model picks at most one.
+ */
+export function learnCatalogPromptBlock(): string {
+ const lines = LEARN_GUIDES.map((g) => `- ${g.topic}: ${g.url}`).join("\n");
+ return [
+ "LEARN GUIDES (public articles you MAY link when genuinely helpful):",
+ lines,
+ "Only link a URL from this list — never invent a /learn URL or article",
+ "content. Reference at most one guide, and only when it adds real value,",
+ 'e.g. "more on this: ' + LEARN_GUIDES[1].url + '".',
+ ].join("\n");
+}
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 339482f58..5879d4188 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -53,6 +53,8 @@ import {
buildReferenceGroundingBlock,
type GroundingMetricInput,
} from "./reference-grounding";
+import { buildTieredSeries } from "@/lib/rollups/tiered-context";
+import type { MeasurementType } from "@/generated/prisma/client";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
@@ -368,6 +370,76 @@ function buildDailyValueRows(
.sort((a, b) => a.date.localeCompare(b.date));
}
+/**
+ * v1.18.7 — coarse tail + anomaly envelope for one metric, from the shared
+ * tiered-context builder. The Coach already ships the 0–14d raw + 30–90d
+ * weekly band (above); this adds only the bands the weekly-fold CANNOT
+ * produce — 90d–1y MONTH, >1y YEAR, and the preserved peak/trough envelope —
+ * so a glucose spike or a BP outlier from months ago survives a coarse
+ * bucket. Token-bounded by the helper's per-band row caps (~10 month + ≤5
+ * year + ≤5 anomalies ≈ 240 tokens), and the degrader sheds it first (it is
+ * the lowest-value, oldest detail). Returns `undefined` when the metric has
+ * no coarse history at all, so the block is omitted rather than shipped empty.
+ */
+interface CoarseTimelineTail {
+ /** 90d–1y monthly buckets: `[bucketStart, mean, min, max]`. */
+ monthly: Array<[string, number, number, number]>;
+ /** >1y yearly buckets: `[bucketStart, mean, min, max]`. */
+ yearly: Array<[string, number, number, number]>;
+ /** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */
+ anomalies: Array<{
+ band: string;
+ date: string;
+ kind: string;
+ value: number;
+ deltaSd: number;
+ }>;
+}
+
+async function buildCoarseTimelineTail(
+ userId: string,
+ type: MeasurementType,
+ now: Date,
+): Promise {
+ // `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
+ // bands, which collapse to `undefined` here. The try/catch keeps the coarse
+ // tail a strictly additive enrichment: any unexpected reader failure simply
+ // drops the tail rather than failing the whole snapshot (matching the
+ // "fall back on miss" contract the rest of the snapshot honours).
+ let series: Awaited>;
+ try {
+ // `skipRecentDaily` — the snapshot already holds the raw 0–14d rows from
+ // its own window read, so the tiered builder skips that duplicate raw
+ // round-trip and returns only the coarse MONTH/YEAR bands + anomalies.
+ series = await buildTieredSeries(userId, type, {
+ now: now.getTime(),
+ skipRecentDaily: true,
+ });
+ } catch {
+ return undefined;
+ }
+ const monthly = series.monthBand.map(
+ (b) => [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
+ );
+ const yearly = series.yearBand.map(
+ (b) => [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
+ );
+ if (monthly.length === 0 && yearly.length === 0 && series.anomalies.length === 0) {
+ return undefined;
+ }
+ return {
+ monthly,
+ yearly,
+ anomalies: series.anomalies.map((a) => ({
+ band: a.band,
+ date: a.date,
+ kind: a.kind,
+ value: a.value,
+ deltaSd: a.deltaSd,
+ })),
+ };
+}
+
/**
* Pair systolic + diastolic into one row per day. Days with only one
* side measured are dropped — the Coach is asked about "BP" as a pair,
@@ -772,6 +844,43 @@ async function buildCoachSnapshotImpl(
const wantsMood = sources.has("mood");
const wantsCompliance = sources.has("compliance");
+ // v1.18.7 — coarse tail (90d–1y MONTH + >1y YEAR) + anomaly envelope for the
+ // core clinical metrics, from the shared tiered-context builder. Only the
+ // bands the in-window weekly-fold cannot produce are fetched here; the recent
+ // + weekly bands stay as built below. Bounded per-band, so this holds or
+ // reduces the per-metric token cost while letting the Coach see a spike from
+ // months ago. Reads never throw — a coverage miss yields `undefined`, the
+ // block is then omitted. Run in parallel; awaited at the block site.
+ const coarseTailPromises: Partial<
+ Record<"bp" | "weight" | "pulse", Promise>
+ > = {};
+ if (wantsBp) {
+ coarseTailPromises.bp = buildCoarseTimelineTail(
+ userId,
+ "BLOOD_PRESSURE_SYS" as MeasurementType,
+ now,
+ );
+ }
+ if (wantsWeight) {
+ coarseTailPromises.weight = buildCoarseTimelineTail(
+ userId,
+ "WEIGHT" as MeasurementType,
+ now,
+ );
+ }
+ if (wantsPulse) {
+ coarseTailPromises.pulse = buildCoarseTimelineTail(
+ userId,
+ "PULSE" as MeasurementType,
+ now,
+ );
+ }
+ const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([
+ coarseTailPromises.bp ?? Promise.resolve(undefined),
+ coarseTailPromises.weight ?? Promise.resolve(undefined),
+ coarseTailPromises.pulse ?? Promise.resolve(undefined),
+ ]);
+
// v1.4.23 W6 (S-04) — single source of truth for the
// CoachScopeSource → MeasurementType[] mapping. Drives both the
// SQL `WHERE type IN (…)` build below and the Apple-Health timeline
@@ -1162,6 +1271,7 @@ async function buildCoachSnapshotImpl(
recent: recentDaily,
weeklySys: bucketWeekly(olderSys, userTz),
weeklyDia: bucketWeekly(olderDia, userTz),
+ ...(bpCoarseTail ? { coarse: bpCoarseTail } : {}),
},
};
metrics.add("bp");
@@ -1184,6 +1294,7 @@ async function buildCoachSnapshotImpl(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
),
+ ...(weightCoarseTail ? { coarse: weightCoarseTail } : {}),
},
};
metrics.add("weight");
@@ -1209,6 +1320,7 @@ async function buildCoachSnapshotImpl(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
),
+ ...(pulseCoarseTail ? { coarse: pulseCoarseTail } : {}),
},
};
metrics.add("pulse");
@@ -2086,6 +2198,13 @@ function degradeToBudget(
delete timeline.recent;
changed = true;
}
+ // v1.18.7 — the coarse MONTH/YEAR tail + anomaly envelope is the
+ // lowest-value, oldest detail, so it sheds in the same first pass as the
+ // dense per-day rows.
+ if (timeline && "coarse" in timeline) {
+ delete timeline.coarse;
+ changed = true;
+ }
const byContext = asRecord(block.byContext);
if (byContext) {
for (const ctx of Object.keys(byContext)) {
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 0c94d276b..826b7af66 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -19,6 +19,7 @@
import type { Locale } from "@/lib/i18n/config";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import { buildNativeCoachPrompt } from "@/lib/ai/prompts/native-prompts";
+import { learnCatalogPromptBlock } from "./learn-catalog";
import {
DEFAULT_COACH_PREFS,
type CoachPrefs,
@@ -223,6 +224,13 @@ ISO-week means.
available day from the timeline.
- Older weekday questions (>14 days back) fall back to
"timeline.weekly" or, if neither has the day, the aggregate.
+- A metric MAY also carry "timeline.coarse" — the longer-horizon tail:
+ "monthly" and "yearly" buckets each as [start, mean, min, max], plus
+ an "anomalies" list of preserved peaks/troughs ({ band, date, kind,
+ value, deltaSd }). Use it for "compared with last year" or "have I
+ ever been this high" questions and to acknowledge a months-old spike
+ the weekly view would hide. Cite the bucket's date; never read a single
+ coarse bucket as a present-day reading.
- The "scope" object names the user-selected window + sources. When
the user asks about a metric that isn't listed in scope.sources,
say it isn't part of this conversation's scope.
@@ -294,6 +302,16 @@ ISO-week means.
context, DESCRIPTIVE not diagnostic — never restate a "condition" fact
as a medical finding, and never invent a fact the block does not
carry. If a fact seems outdated, gently check it rather than assume.
+- The SNAPSHOT MAY carry an "illness" block: { restMode, active[],
+ recentResolved[] }. When "restMode" is true the user has one or more
+ ACTIVE conditions right now (each with a label, type, lifecycle, and
+ onset date) — read an off-band vital or a low recovery as plausibly
+ illness-explained rather than alarming, do not push a heavier
+ measurement cadence, and lean toward rest-and-recover framing. Treat
+ the labels as the user's OWN descriptive context, never a diagnosis;
+ "recentResolved" is light history for "how often do I get sick"
+ questions. When no "illness" block is present, assume nothing about
+ the user's health status.
EVIDENCE BLOCK
@@ -659,6 +677,15 @@ ISO-Wochenmittel zusammen.
nie als medizinischen Befund um und erfinde nie einen Fakt, den der
Block nicht enthält. Wirkt ein Fakt veraltet, frage behutsam nach,
statt es anzunehmen.
+- Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[],
+ recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder
+ mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn) —
+ lies einen abweichenden Vitalwert oder eine niedrige Erholung eher als
+ krankheitsbedingt denn als alarmierend, dränge nicht auf eine engere
+ Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Behandle die
+ Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose;
+ "recentResolved" ist leichte Historie für "wie oft bin ich krank".
+ Fehlt der "illness"-Block, nimm nichts über den Gesundheitszustand an.
EVIDENZ-BLOCK
@@ -838,10 +865,15 @@ export function getCoachSystemPrompt(
}
const prefix = buildPrefsPrefix(locale, prefs);
const withPrefix = prefix ? `${prefix}\n\n${base}` : base;
+ // v1.18.7 — article-awareness: a compact catalog of the public /learn
+ // guides so the Coach can point the user at the relevant deeper read. The
+ // block is locale-independent (English topic labels + URLs) and the model
+ // may only link a slug it lists, so a fabricated /learn URL is impossible.
+ const withLearn = `${withPrefix}\n\n${learnCatalogPromptBlock()}`;
// v1.15.20 — the user-authored "about me" self-description rides the
// system prompt as a delimited, user-provided block (Settings → AI).
const suffix = aboutMe ? buildAboutMeBlock(locale, aboutMe) : "";
- return suffix ? `${withPrefix}\n\n${suffix}` : withPrefix;
+ return suffix ? `${withLearn}\n\n${suffix}` : withLearn;
}
/**
diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts
index d05761879..accf0d0c3 100644
--- a/src/lib/rollups/tiered-context.ts
+++ b/src/lib/rollups/tiered-context.ts
@@ -117,6 +117,13 @@ export interface BuildTieredSeriesOptions {
* `|deltaSd|` before the cap.
*/
maxAnomalies?: number;
+ /**
+ * v1.18.7 — skip the raw 0–14d `Measurement` read and return an empty
+ * `recentDaily`. For callers (the Coach snapshot) that already hold the
+ * recent daily rows from their own query and only need the coarser
+ * MONTH/YEAR bands + anomaly envelope; avoids a duplicate raw round-trip.
+ */
+ skipRecentDaily?: boolean;
}
interface RollupBucket {
@@ -275,7 +282,9 @@ export async function buildTieredSeries(
const [recentDaily, dayBand, weekBand, monthBand, yearBand] =
await Promise.all([
- readRecentDaily(userId, type, now),
+ options.skipRecentDaily
+ ? Promise.resolve([])
+ : readRecentDaily(userId, type, now),
readBand(userId, type, "DAY", TIERED_BANDS.dayUntil, TIERED_BANDS.rawDays, now),
readBand(userId, type, "WEEK", TIERED_BANDS.weekUntil, TIERED_BANDS.dayUntil, now),
readBand(userId, type, "MONTH", TIERED_BANDS.monthUntil, TIERED_BANDS.weekUntil, now),
From cdb76f1930cfc952febc175743cf146d92f139d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:03:28 +0200
Subject: [PATCH 11/71] chore(release): v1.18.7
---
CHANGELOG.md | 24 ++++++++++++++++++++++++
docs/api/openapi.yaml | 2 +-
package.json | 2 +-
public/sw.js | 2 +-
4 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c55c05f4..ac715db59 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,30 @@
## [Unreleased]
+## [1.18.7] — 2026-06-19 — dashboard, Coach and insights polish
+
+A UI and insights patch. No migration; the Coach context and daily briefing change internally, but the API contract is unchanged.
+
+### Added
+
+- The daily briefing surfaces "signals of the day" — today's readings against your 7- and 30-day trend, emerging slopes and recent outliers — and leads with one concrete, present-focused nudge.
+- Preventive-care cards show a quiet seven-day strip of the metric, and their menu jumps straight to the measurement history filtered to that type.
+- The Coach reads your active illness state and Rest Mode, can point you to the relevant in-depth guide, and draws on a tiered view of your history (recent days in full, older periods progressively summarised with peaks preserved).
+- Dictate to the Coach by voice.
+
+### Changed
+
+- The daily briefing regenerates every day and reads warmer and more motivating.
+- The Coach interface is rebuilt around a calm, centred reading column with a collapsible conversation list, a thinking indicator that hands off to streaming, and a quieter scrollbar.
+- The health-score rings are flattened to match the chart style — the glow, sheen and pulse are gone.
+- Sleep drops the redundant "last night" card; the chronotype reads as a prominent summary with your natural sleep midpoint and an expandable detail.
+- Every integration card follows one layout — a short description ending in an inline setup-guide link, with the credential hint above the save button.
+
+### Fixed
+
+- The daily briefing no longer goes stale: a fresh daily signal forces regeneration instead of re-stamping an unchanged narrative.
+- The sleep assessment no longer shows the "connect an AI provider" prompt when a provider is already connected.
+
## [1.18.6.1] — 2026-06-18 — settings and Coach UI follow-ups
A small UI and settings patch on top of v1.18.6. No migration, no API contract change.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index f560b3954..456cf79dc 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.18.6.1
+ version: 1.18.7
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/package.json b/package.json
index 1e980ffd2..abe773b83 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.18.6.1",
+ "version": "1.18.7",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/public/sw.js b/public/sw.js
index fb3bad369..4ceb3436e 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.18.6";
+ /* @sw-version-fallback */ "v1.18.6.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
From ebb9d0c607cf7516901522c183ac7dc6fd45708e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:27:36 +0200
Subject: [PATCH 12/71] fix(insights): correct signals-of-day extraction and
determinism
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Drop steps from the signals-of-day set: Apple Health steps ingest as
many intraday stats-prefixed samples, so the newest raw row is a
partial-day fragment, not a daily total — same exclusion already applied
to per-night sleep. Add a glucose signal off the canonical stored value
(the model reads the snapshot, not display units) so the block matches
the priority comment that already claimed it; drop the dead sleep entry
from SIGNAL_PRIORITY. Track the raw extreme magnitude when keeping the
most extreme recent anomaly so a larger anomaly is no longer dropped by
comparing against an already-rounded value. Thread the injected clock
through trendSlope so the emerging-trend cutoff is deterministic under a
test clock. Rename the anomaly day-count to anomalyDaysAgo so the
snapshot hash staleness-tiers it instead of letting the raw count drive
the fingerprint. Cap the returned set at three to match the schema.
---
src/lib/analytics/trends.ts | 3 +-
src/lib/insights/features.ts | 56 ++++++++++++++++++------------------
2 files changed, 30 insertions(+), 29 deletions(-)
diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts
index 7d8b988ac..c87402820 100644
--- a/src/lib/analytics/trends.ts
+++ b/src/lib/analytics/trends.ts
@@ -29,11 +29,12 @@ export interface TrendSlope {
export function trendSlope(
data: DataPoint[],
windowDays: number,
+ now: number = Date.now(),
): TrendSlope | null {
if (data.length < 2) return null;
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
- const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000;
+ const cutoff = now - windowDays * 24 * 60 * 60 * 1000;
const window = sorted.filter((p) => p.date.getTime() >= cutoff);
if (window.length < 2) return null;
diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts
index d9b4626fc..bbea70d4c 100644
--- a/src/lib/insights/features.ts
+++ b/src/lib/insights/features.ts
@@ -188,7 +188,7 @@ export interface AggregatedFeatures {
* v1.18.7 — "Signals of the day": the present-focused read the daily
* briefing leads with. Each entry compares the freshest reading against
* the user's own trailing-7d / 30d windows, flags an emerging slope, and
- * surfaces a recent anomaly — ranked by clinical priority, ≤5 entries.
+ * surfaces a recent anomaly — ranked by clinical priority, ≤3 entries.
*
* Recomputed every day: the freshest reading, the 7/30d windows, and the
* recency move daily, so a flat 30/90d mean no longer pins the briefing.
@@ -213,7 +213,7 @@ export interface SignalOfDay {
| "mood"
| "sleep"
| "resting_hr"
- | "steps";
+ | "glucose";
/** Natural-language label (no enum leak into prose). */
label: string;
/** Unit string when the metric carries one. */
@@ -240,7 +240,7 @@ export interface SignalOfDay {
recentAnomaly: {
kind: "peak" | "trough";
value: number;
- daysAgo: number;
+ anomalyDaysAgo: number;
} | null;
}
@@ -419,17 +419,16 @@ function computeHistoricalComparison(
/**
* v1.18.7 — clinical priority order for the signals block. BP / glucose
- * lead, then resting HR / pulse, then weight, then mood / sleep / activity.
- * Lower index = higher priority; the briefing surfaces the top ≤5.
+ * lead, then resting HR / pulse, then weight, then mood.
+ * Lower index = higher priority; the briefing surfaces the top ≤3.
*/
const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [
"bp",
+ "glucose",
"resting_hr",
"pulse",
"weight",
- "sleep",
"mood",
- "steps",
];
/** Round helper local to the signals builder. */
@@ -472,7 +471,7 @@ function buildSignal(
? Math.abs(newest.value - avg30) > spread30
: false;
- const slope = trendSlope(toDataPoints(records), 30);
+ const slope = trendSlope(toDataPoints(records), 30, now);
const emergingTrend: SignalOfDay["emergingTrend"] = slope
? slope.direction === "up"
? "rising"
@@ -483,6 +482,9 @@ function buildSignal(
// Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD.
let recentAnomaly: SignalOfDay["recentAnomaly"] = null;
+ // Track the RAW extreme magnitude — comparing against the already-r2()
+ // rounded stored value can drop a genuinely larger anomaly.
+ let bestAbs = 0;
if (avg30 !== null && spread30 !== null && spread30 > 0) {
const recent = records.filter(
(rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000,
@@ -490,21 +492,16 @@ function buildSignal(
for (const rec of recent) {
const sd = (rec.value - avg30) / spread30;
if (Math.abs(sd) >= 2) {
- const daysAgo = Math.round(
- (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
- );
- const candidate = {
- kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough",
- value: r2(rec.value),
- daysAgo,
- };
- // Keep the single most extreme anomaly in the window.
- if (
- recentAnomaly === null ||
- Math.abs(rec.value - avg30) >
- Math.abs(recentAnomaly.value - avg30)
- ) {
- recentAnomaly = candidate;
+ const abs = Math.abs(rec.value - avg30);
+ if (recentAnomaly === null || abs > bestAbs) {
+ bestAbs = abs;
+ recentAnomaly = {
+ kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough",
+ value: r2(rec.value),
+ anomalyDaysAgo: Math.round(
+ (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
+ ),
+ };
}
}
}
@@ -531,7 +528,7 @@ function buildSignal(
* v1.18.7 — assemble the present-focused "Signals of the day" block from the
* in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d
* deltas, an emerging slope, and a recent anomaly per salient metric, then
- * returns the top ≤5 ranked by clinical priority. Salient signals
+ * returns the top ≤3 ranked by clinical priority. Salient signals
* (outside-normal-swing or a recent anomaly) bubble above quiet ones inside
* each priority tier so the briefing leads with what actually moved.
*/
@@ -546,13 +543,16 @@ function computeSignalsOfDay(
// Systolic carries the BP signal (the headline number clinicians read first).
push(buildSignal("bp", "blood pressure (systolic)", byType("BLOOD_PRESSURE_SYS"), now, "mmHg"));
+ // Glucose uses the canonical stored value (the model reads the snapshot,
+ // not display units); absent data simply produces no signal.
+ push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now));
push(buildSignal("resting_hr", "resting heart rate", byType("RESTING_HEART_RATE"), now, "bpm"));
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg"));
// Sleep is stored one row per stage per night, so a raw "latest" point
- // would mis-sum; the sleep aggregates carry that signal already. Steps are
- // daily totals — a clean per-day point.
- push(buildSignal("steps", "daily steps", byType("ACTIVITY_STEPS"), now));
+ // would mis-sum; the sleep aggregates carry that signal already. Steps
+ // ingest as many intraday `stats:`-prefixed samples, so the newest raw row
+ // is a partial-day fragment, not a daily total — excluded like sleep.
const priorityIndex = (m: SignalOfDay["metric"]) => {
const idx = SIGNAL_PRIORITY.indexOf(m);
@@ -567,7 +567,7 @@ function computeSignalsOfDay(
if (sal !== 0) return sal;
return priorityIndex(a.metric) - priorityIndex(b.metric);
})
- .slice(0, 5);
+ .slice(0, 3);
}
export async function extractFeatures(
From d2007f2f37cd06db33b517610af07d34b8e922c1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:29:02 +0200
Subject: [PATCH 13/71] perf(coach): read only coarse bands for the
deep-history tail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a coarseOnly option to buildTieredSeries that skips the raw 0–14d
read and the DAY (14–30d) / WEEK (30–90d) band reads, returning only the
MONTH / YEAR bands plus the anomaly envelope. The Coach snapshot's
coarse timeline tail consumes only month/year + anomalies, so the
near-term bands were read and discarded every turn; it now passes
coarseOnly. The briefing path is unchanged. Look the learn-guide prompt
example up by slug rather than by array index so reordering the catalog
cannot silently change the example link.
---
src/lib/ai/coach/learn-catalog.ts | 7 ++++++-
src/lib/ai/coach/snapshot.ts | 9 +++++----
src/lib/rollups/tiered-context.ts | 22 +++++++++++++++++++---
3 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/src/lib/ai/coach/learn-catalog.ts b/src/lib/ai/coach/learn-catalog.ts
index b1c3f38cf..73d3a9e00 100644
--- a/src/lib/ai/coach/learn-catalog.ts
+++ b/src/lib/ai/coach/learn-catalog.ts
@@ -100,11 +100,16 @@ export const LEARN_GUIDES: readonly LearnGuide[] = [
*/
export function learnCatalogPromptBlock(): string {
const lines = LEARN_GUIDES.map((g) => `- ${g.topic}: ${g.url}`).join("\n");
+ // Look the example guide up by slug, not by array index, so reordering the
+ // catalog above cannot silently change the prompt example.
+ const exampleGuide =
+ LEARN_GUIDES.find((g) => g.slug === "resting-heart-rate") ??
+ LEARN_GUIDES[0];
return [
"LEARN GUIDES (public articles you MAY link when genuinely helpful):",
lines,
"Only link a URL from this list — never invent a /learn URL or article",
"content. Reference at most one guide, and only when it adds real value,",
- 'e.g. "more on this: ' + LEARN_GUIDES[1].url + '".',
+ 'e.g. "more on this: ' + exampleGuide.url + '".',
].join("\n");
}
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 5879d4188..4eba2238f 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -408,12 +408,13 @@ async function buildCoarseTimelineTail(
// "fall back on miss" contract the rest of the snapshot honours).
let series: Awaited>;
try {
- // `skipRecentDaily` — the snapshot already holds the raw 0–14d rows from
- // its own window read, so the tiered builder skips that duplicate raw
- // round-trip and returns only the coarse MONTH/YEAR bands + anomalies.
+ // `coarseOnly` — the snapshot already holds the raw 0–14d rows from its
+ // own window read and this tail consumes only the MONTH/YEAR bands +
+ // anomaly envelope, so the builder skips the raw read AND the DAY/WEEK
+ // band reads it would otherwise discard.
series = await buildTieredSeries(userId, type, {
now: now.getTime(),
- skipRecentDaily: true,
+ coarseOnly: true,
});
} catch {
return undefined;
diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts
index accf0d0c3..e8d1abba7 100644
--- a/src/lib/rollups/tiered-context.ts
+++ b/src/lib/rollups/tiered-context.ts
@@ -124,6 +124,16 @@ export interface BuildTieredSeriesOptions {
* MONTH/YEAR bands + anomaly envelope; avoids a duplicate raw round-trip.
*/
skipRecentDaily?: boolean;
+ /**
+ * v1.18.7 — skip the raw 0–14d read AND the DAY (14–30d) / WEEK (30–90d)
+ * band reads, returning only the MONTH / YEAR bands plus the anomaly
+ * envelope. For the Coach's `buildCoarseTimelineTail`, which consumes only
+ * month/year + anomalies; the skipped near-term bands would otherwise be
+ * read and discarded. The anomaly window-stats then span the coarse bands
+ * only, which is the right baseline for the deep-history tail. Implies
+ * `skipRecentDaily`.
+ */
+ coarseOnly?: boolean;
}
interface RollupBucket {
@@ -280,13 +290,19 @@ export async function buildTieredSeries(
await ensureUserRollupsFresh(userId);
}
+ const coarseOnly = options.coarseOnly ?? false;
+ const emptyBand = Promise.resolve([]);
const [recentDaily, dayBand, weekBand, monthBand, yearBand] =
await Promise.all([
- options.skipRecentDaily
+ options.skipRecentDaily || coarseOnly
? Promise.resolve([])
: readRecentDaily(userId, type, now),
- readBand(userId, type, "DAY", TIERED_BANDS.dayUntil, TIERED_BANDS.rawDays, now),
- readBand(userId, type, "WEEK", TIERED_BANDS.weekUntil, TIERED_BANDS.dayUntil, now),
+ coarseOnly
+ ? emptyBand
+ : readBand(userId, type, "DAY", TIERED_BANDS.dayUntil, TIERED_BANDS.rawDays, now),
+ coarseOnly
+ ? emptyBand
+ : readBand(userId, type, "WEEK", TIERED_BANDS.weekUntil, TIERED_BANDS.dayUntil, now),
readBand(userId, type, "MONTH", TIERED_BANDS.monthUntil, TIERED_BANDS.weekUntil, now),
readBand(userId, type, "YEAR", TIERED_BANDS.yearUntil, TIERED_BANDS.monthUntil, now),
]);
From 1f8420a6d0f9b3244981916490ff4fd03633f938 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:31:52 +0200
Subject: [PATCH 14/71] chore(i18n): drop orphaned integration help and overlap
notes
Remove the integration keys left dead after the integrations refactor:
the WHOOP and Fitbit overlap notes, the Fitbit experimental note, the
per-provider credentials-help strings (WHOOP, Fitbit, Polar, Oura) and
the Nightscout help. None carry a t() call site any longer. Add the
Vorsorge trend-strip screen-reader summary strings (latest value plus
seven-day direction) across all locales.
---
messages/de.json | 14 +++++---------
messages/en.json | 14 +++++---------
messages/es.json | 14 +++++---------
messages/fr.json | 14 +++++---------
messages/it.json | 14 +++++---------
messages/pl.json | 14 +++++---------
6 files changed, 30 insertions(+), 54 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index b244ec522..3ce0ec929 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4367,9 +4367,7 @@
"withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.",
"whoop": "WHOOP",
"whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts direkt in HealthLog zu synchronisieren.",
- "whoopOverlapNote": "Wenn WHOOP und eine weitere Quelle denselben Vitalwert liefern – Ruhepuls, Blutsauerstoff, Körpertemperatur, Atemfrequenz oder Schlafphasen –, können beide Werte angezeigt werden, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
"whoopCredentials": "API-Zugangsdaten",
- "whoopCredentialsHelp": "Registriere deine eigene WHOOP-Entwickler-App und füge hier Client ID und Client Secret ein.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Zugangsdaten gespeichert",
@@ -4404,11 +4402,8 @@
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
- "fitbitOverlapNote": "Wenn Google Health und eine andere Quelle denselben Vitalwert liefern — Ruhepuls, Sauerstoffsättigung, Körpertemperatur oder Atemfrequenz —, siehst du möglicherweise beide Werte, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
"fitbitExperimentalBadge": "Experimentell",
- "fitbitExperimentalNote": "Diese Integration befindet sich in der Beta — die Datenabdeckung und die Feldzuordnung werden noch gegen echte Google-Health-Konten geprüft, daher können einzelne Werte fehlen oder sich in einem künftigen Update ändern.",
"fitbitCredentials": "API-Zugangsdaten",
- "fitbitCredentialsHelp": "Registriere deinen eigenen Google-Cloud-OAuth-Client für die Google-Health-API und füge hier dessen Client-ID und Client-Secret ein.",
"fitbitClientId": "Client-ID",
"fitbitClientSecret": "Client-Secret",
"fitbitCredentialsSaved": "Zugangsdaten gespeichert",
@@ -4680,7 +4675,6 @@
"nightscoutTag": "CGM",
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.",
"nightscoutViewData": "Glukose ansehen",
- "nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.",
"nightscoutUrl": "Nightscout-URL",
"nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen",
"nightscoutToken": "API-Token",
@@ -4701,7 +4695,6 @@
"polarViewData": "Schlaf & Erholung ansehen",
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"polarCredentials": "API-Zugangsdaten",
- "polarCredentialsHelp": "Optional: Registriere deine eigene Polar-AccessLink-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Polar-App des Servers zu nutzen.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Zugangsdaten gespeichert",
@@ -4729,7 +4722,6 @@
"ouraViewData": "Schlaf & Erholung ansehen",
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"ouraCredentials": "API-Zugangsdaten",
- "ouraCredentialsHelp": "Optional: Registriere deine eigene Oura-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Oura-App des Servers zu nutzen.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Zugangsdaten gespeichert",
@@ -6435,7 +6427,11 @@
},
"showMeasurements": "Messergebnisse anzeigen",
"trendStrip": {
- "label": "Verlauf der letzten 7 Tage"
+ "label": "Verlauf der letzten 7 Tage",
+ "summary": "Letzter Wert {value}, {direction} über 7 Tage.",
+ "rising": "steigend",
+ "falling": "fallend",
+ "steady": "stabil"
}
},
"moduleList": {
diff --git a/messages/en.json b/messages/en.json
index e4ebe1147..535db02d5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4367,9 +4367,7 @@
"withingsNoCredentials": "Please enter your API credentials above to connect Withings.",
"whoop": "WHOOP",
"whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain, and workouts straight into HealthLog.",
- "whoopOverlapNote": "If WHOOP and another source both supply a vital — resting heart rate, blood oxygen, body temperature, respiratory rate or sleep stages — you may see both values until a future update settles on a single preferred source.",
"whoopCredentials": "API Credentials",
- "whoopCredentialsHelp": "Register your own WHOOP developer app and paste its Client ID and Client Secret here.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Credentials saved",
@@ -4404,11 +4402,8 @@
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
- "fitbitOverlapNote": "If Google Health and another source both supply a vital — resting heart rate, blood oxygen, body temperature or respiratory rate — you may see both values until a future update settles on a single preferred source.",
"fitbitExperimentalBadge": "Experimental",
- "fitbitExperimentalNote": "This integration is in beta — data coverage and field mapping are still being verified against live Google Health accounts, so some values may be missing or change in a future update.",
"fitbitCredentials": "API Credentials",
- "fitbitCredentialsHelp": "Register your own Google Cloud OAuth client for the Google Health API and paste its Client ID and Client Secret here.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credentials saved",
@@ -4680,7 +4675,6 @@
"nightscoutTag": "CGM",
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.",
"nightscoutViewData": "View your glucose",
- "nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.",
"nightscoutUrl": "Nightscout URL",
"nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it",
"nightscoutToken": "API token",
@@ -4701,7 +4695,6 @@
"polarViewData": "View your sleep & recovery",
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
"polarCredentials": "API Credentials",
- "polarCredentialsHelp": "Optional: register your own Polar AccessLink app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Polar app.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credentials saved",
@@ -4729,7 +4722,6 @@
"ouraViewData": "View your sleep & recovery",
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
"ouraCredentials": "API Credentials",
- "ouraCredentialsHelp": "Optional: register your own Oura app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Oura app.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credentials saved",
@@ -6435,7 +6427,11 @@
},
"showMeasurements": "Show measurements",
"trendStrip": {
- "label": "Last 7 days"
+ "label": "Last 7 days",
+ "summary": "Latest value {value}, {direction} over 7 days.",
+ "rising": "rising",
+ "falling": "falling",
+ "steady": "steady"
}
},
"moduleList": {
diff --git a/messages/es.json b/messages/es.json
index 80d6ea029..15ed8347c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4367,9 +4367,7 @@
"withingsNoCredentials": "Introduce primero tus credenciales de API arriba para conectar Withings.",
"whoop": "WHOOP",
"whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos directamente en HealthLog.",
- "whoopOverlapNote": "Si WHOOP y otra fuente proporcionan el mismo valor vital —frecuencia cardíaca en reposo, oxígeno en sangre, temperatura corporal, frecuencia respiratoria o fases del sueño—, es posible que veas ambos valores hasta que una futura actualización elija una única fuente preferida.",
"whoopCredentials": "Credenciales de API",
- "whoopCredentialsHelp": "Registra tu propia app de desarrollador de WHOOP y pega aquí su Client ID y Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Credenciales guardadas",
@@ -4404,11 +4402,8 @@
"fitbit": "Google Health",
"fitbitTag": "Fitbit y Pixel",
"fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
- "fitbitOverlapNote": "Si Google Health y otra fuente proporcionan la misma constante vital —frecuencia cardíaca en reposo, saturación de oxígeno, temperatura corporal o frecuencia respiratoria—, es posible que veas ambos valores hasta que una actualización futura defina una única fuente preferida.",
"fitbitExperimentalBadge": "Experimental",
- "fitbitExperimentalNote": "Esta integración está en fase beta: la cobertura de datos y la asignación de campos aún se están verificando con cuentas reales de Google Health, por lo que algunos valores pueden faltar o cambiar en una actualización futura.",
"fitbitCredentials": "Credenciales de API",
- "fitbitCredentialsHelp": "Registra tu propio cliente OAuth de Google Cloud para la API de Google Health y pega aquí su Client ID y Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credenciales guardadas",
@@ -4680,7 +4675,6 @@
"nightscoutTag": "CGM",
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
"nightscoutViewData": "Ver tu glucosa",
- "nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.",
"nightscoutUrl": "URL de Nightscout",
"nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla",
"nightscoutToken": "Token de API",
@@ -4701,7 +4695,6 @@
"polarViewData": "Ver tu sueño y recuperación",
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"polarCredentials": "Credenciales de API",
- "polarCredentialsHelp": "Opcional: registra tu propia app de Polar AccessLink y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credenciales guardadas",
@@ -4729,7 +4722,6 @@
"ouraViewData": "Ver tu sueño y recuperación",
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"ouraCredentials": "Credenciales de API",
- "ouraCredentialsHelp": "Opcional: registra tu propia app de Oura y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credenciales guardadas",
@@ -6435,7 +6427,11 @@
},
"showMeasurements": "Ver mediciones",
"trendStrip": {
- "label": "Últimos 7 días"
+ "label": "Últimos 7 días",
+ "summary": "Último valor {value}, {direction} en 7 días.",
+ "rising": "en aumento",
+ "falling": "en descenso",
+ "steady": "estable"
}
},
"moduleList": {
diff --git a/messages/fr.json b/messages/fr.json
index c52b796c7..c2ce0dfc7 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4367,9 +4367,7 @@
"withingsNoCredentials": "Saisissez d’abord vos identifiants API ci-dessus pour connecter Withings.",
"whoop": "WHOOP",
"whoopDescription": "Connecte ton bracelet WHOOP pour synchroniser récupération, sommeil, charge et entraînements directement dans HealthLog.",
- "whoopOverlapNote": "Si WHOOP et une autre source fournissent la même donnée vitale — fréquence cardiaque au repos, oxygène sanguin, température corporelle, fréquence respiratoire ou phases de sommeil —, les deux valeurs peuvent apparaître jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
"whoopCredentials": "Identifiants API",
- "whoopCredentialsHelp": "Enregistrez votre propre application développeur WHOOP et collez ici son Client ID et son Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Identifiants enregistrés",
@@ -4404,11 +4402,8 @@
"fitbit": "Google Health",
"fitbitTag": "Fitbit et Pixel",
"fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
- "fitbitOverlapNote": "Si Google Health et une autre source fournissent la même constante vitale — fréquence cardiaque au repos, saturation en oxygène, température corporelle ou fréquence respiratoire —, vous pourriez voir les deux valeurs jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
"fitbitExperimentalBadge": "Expérimental",
- "fitbitExperimentalNote": "Cette intégration est en bêta : la couverture des données et la correspondance des champs sont encore en cours de vérification avec de vrais comptes Google Health, donc certaines valeurs peuvent manquer ou changer lors d'une future mise à jour.",
"fitbitCredentials": "Identifiants d'API",
- "fitbitCredentialsHelp": "Enregistrez votre propre client OAuth Google Cloud pour l'API Google Health et collez ici son Client ID et son Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Identifiants enregistrés",
@@ -4680,7 +4675,6 @@
"nightscoutTag": "CGM",
"nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.",
"nightscoutViewData": "Voir votre glycémie",
- "nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.",
"nightscoutUrl": "URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer",
"nightscoutToken": "Jeton d'API",
@@ -4701,7 +4695,6 @@
"polarViewData": "Voir votre sommeil et récupération",
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"polarCredentials": "Identifiants API",
- "polarCredentialsHelp": "Facultatif : enregistrez votre propre application Polar AccessLink et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Polar partagée du serveur.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Identifiants enregistrés",
@@ -4729,7 +4722,6 @@
"ouraViewData": "Voir votre sommeil et récupération",
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"ouraCredentials": "Identifiants API",
- "ouraCredentialsHelp": "Facultatif : enregistrez votre propre application Oura et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Oura partagée du serveur.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Identifiants enregistrés",
@@ -6435,7 +6427,11 @@
},
"showMeasurements": "Afficher les mesures",
"trendStrip": {
- "label": "7 derniers jours"
+ "label": "7 derniers jours",
+ "summary": "Dernière valeur {value}, {direction} sur 7 jours.",
+ "rising": "en hausse",
+ "falling": "en baisse",
+ "steady": "stable"
}
},
"moduleList": {
diff --git a/messages/it.json b/messages/it.json
index b668a943e..86edcbc3c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4367,9 +4367,7 @@
"withingsNoCredentials": "Inserisci prima le tue credenziali API qui sopra per collegare Withings.",
"whoop": "WHOOP",
"whoopDescription": "Collega la fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti direttamente in HealthLog.",
- "whoopOverlapNote": "Se WHOOP e un'altra fonte forniscono lo stesso valore vitale — frequenza cardiaca a riposo, ossigeno nel sangue, temperatura corporea, frequenza respiratoria o fasi del sonno —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
"whoopCredentials": "Credenziali API",
- "whoopCredentialsHelp": "Registra la tua app sviluppatore WHOOP e incolla qui il suo Client ID e Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Credenziali salvate",
@@ -4404,11 +4402,8 @@
"fitbit": "Google Health",
"fitbitTag": "Fitbit e Pixel",
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
- "fitbitOverlapNote": "Se Google Health e un'altra fonte forniscono lo stesso parametro vitale — frequenza cardiaca a riposo, saturazione di ossigeno, temperatura corporea o frequenza respiratoria —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
"fitbitExperimentalBadge": "Sperimentale",
- "fitbitExperimentalNote": "Questa integrazione è in versione beta: la copertura dei dati e la mappatura dei campi sono ancora in fase di verifica con account Google Health reali, quindi alcuni valori potrebbero mancare o cambiare in un futuro aggiornamento.",
"fitbitCredentials": "Credenziali API",
- "fitbitCredentialsHelp": "Registra il tuo client OAuth di Google Cloud per l'API Google Health e incolla qui il suo Client ID e Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credenziali salvate",
@@ -4680,7 +4675,6 @@
"nightscoutTag": "CGM",
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.",
"nightscoutViewData": "Vedi la tua glicemia",
- "nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.",
"nightscoutUrl": "URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo",
"nightscoutToken": "Token API",
@@ -4701,7 +4695,6 @@
"polarViewData": "Vedi sonno e recupero",
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"polarCredentials": "Credenziali API",
- "polarCredentialsHelp": "Facoltativo: registra la tua app Polar AccessLink e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Polar condivisa del server.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credenziali salvate",
@@ -4729,7 +4722,6 @@
"ouraViewData": "Vedi sonno e recupero",
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"ouraCredentials": "Credenziali API",
- "ouraCredentialsHelp": "Facoltativo: registra la tua app Oura e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Oura condivisa del server.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credenziali salvate",
@@ -6435,7 +6427,11 @@
},
"showMeasurements": "Mostra le misurazioni",
"trendStrip": {
- "label": "Ultimi 7 giorni"
+ "label": "Ultimi 7 giorni",
+ "summary": "Ultimo valore {value}, {direction} su 7 giorni.",
+ "rising": "in aumento",
+ "falling": "in calo",
+ "steady": "stabile"
}
},
"moduleList": {
diff --git a/messages/pl.json b/messages/pl.json
index d5a1a412e..f6a65b1f4 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4367,9 +4367,7 @@
"withingsNoCredentials": "Najpierw wprowadź powyżej dane logowania API, aby połączyć Withings.",
"whoop": "WHOOP",
"whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi prosto do HealthLog.",
- "whoopOverlapNote": "Jeśli WHOOP i inne źródło dostarczają tę samą wartość życiową — tętno spoczynkowe, saturację, temperaturę ciała, częstość oddechów lub fazy snu — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
"whoopCredentials": "Dane API",
- "whoopCredentialsHelp": "Zarejestruj własną aplikację deweloperską WHOOP i wklej tutaj jej Client ID oraz Client Secret.",
"whoopClientId": "Client ID",
"whoopClientSecret": "Client Secret",
"whoopCredentialsSaved": "Dane zapisane",
@@ -4404,11 +4402,8 @@
"fitbit": "Google Health",
"fitbitTag": "Fitbit i Pixel",
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
- "fitbitOverlapNote": "Jeśli Google Health i inne źródło dostarczają ten sam parametr życiowy — tętno spoczynkowe, saturację, temperaturę ciała lub częstość oddechów — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
"fitbitExperimentalBadge": "Eksperymentalne",
- "fitbitExperimentalNote": "Ta integracja jest w wersji beta — zakres danych i mapowanie pól są wciąż weryfikowane na rzeczywistych kontach Google Health, więc niektóre wartości mogą być niedostępne lub zmienić się w przyszłej aktualizacji.",
"fitbitCredentials": "Dane uwierzytelniające API",
- "fitbitCredentialsHelp": "Zarejestruj własny klient OAuth Google Cloud dla interfejsu Google Health API i wklej tutaj jego Client ID oraz Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Dane uwierzytelniające zapisane",
@@ -4680,7 +4675,6 @@
"nightscoutTag": "CGM",
"nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
"nightscoutViewData": "Zobacz swoją glukozę",
- "nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.",
"nightscoutUrl": "Adres URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić",
"nightscoutToken": "Token API",
@@ -4701,7 +4695,6 @@
"polarViewData": "Zobacz sen i regenerację",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"polarCredentials": "Dane API",
- "polarCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Polar AccessLink i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Polar serwera.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Dane zapisane",
@@ -4729,7 +4722,6 @@
"ouraViewData": "Zobacz sen i regenerację",
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"ouraCredentials": "Dane API",
- "ouraCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Oura i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Oura serwera.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Dane zapisane",
@@ -6435,7 +6427,11 @@
},
"showMeasurements": "Pokaż pomiary",
"trendStrip": {
- "label": "Ostatnie 7 dni"
+ "label": "Ostatnie 7 dni",
+ "summary": "Ostatnia wartość {value}, {direction} w ciągu 7 dni.",
+ "rising": "rosnący",
+ "falling": "malejący",
+ "steady": "stabilny"
}
},
"moduleList": {
From a3b274171ceea7d07e865442a58fbe3f08202c8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:31:58 +0200
Subject: [PATCH 15/71] fix(a11y): announce coach history state and Vorsorge
trend
Add aria-expanded to the coach drawer's history-collapse control so its
expanded state is exposed, matching the expand toggle. Give the Vorsorge
trend strip an sr-only summary of the latest value and seven-day
direction, and key the bars on measuredAt rather than the array index.
---
.../coach-panel/coach-drawer-body.tsx | 1 +
.../vorsorge-trend-strip.tsx | 36 ++++++++++++++-----
2 files changed, 28 insertions(+), 9 deletions(-)
diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx
index 6842439fe..6076a2b4b 100644
--- a/src/components/insights/coach-panel/coach-drawer-body.tsx
+++ b/src/components/insights/coach-panel/coach-drawer-body.tsx
@@ -126,6 +126,7 @@ export function CoachDrawerBody({
onClick={onToggleHistory}
data-slot="coach-history-collapse"
aria-label={t("insights.coach.hideConversations")}
+ aria-expanded={historyOpen}
title={t("insights.coach.hideConversations")}
className="text-muted-foreground hover:text-foreground -mr-1 size-9 shrink-0"
>
diff --git a/src/components/measurement-reminders/vorsorge-trend-strip.tsx b/src/components/measurement-reminders/vorsorge-trend-strip.tsx
index 5d476c702..8250e273d 100644
--- a/src/components/measurement-reminders/vorsorge-trend-strip.tsx
+++ b/src/components/measurement-reminders/vorsorge-trend-strip.tsx
@@ -43,10 +43,7 @@ function useRecentValues(measurementType: string | null, enabled: boolean) {
const res = await apiGet<{ measurements: RecentMeasurement[] }>(
`/api/measurements?${params}`,
);
- return res.measurements
- .slice()
- .reverse()
- .map((m) => m.value);
+ return res.measurements.slice().reverse();
},
enabled: enabled && measurementType != null,
});
@@ -64,27 +61,48 @@ export function VorsorgeTrendStrip({
// Nothing to show for a free-text reminder or a too-thin window.
if (measurementType == null) return null;
- const values = data ?? [];
- if (values.length < 2) return null;
+ const readings = data ?? [];
+ if (readings.length < 2) return null;
+ const values = readings.map((r) => r.value);
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
+ // 7-day direction from first → last reading in the window. Latest value
+ // and direction feed the sr-only summary so a screen reader gets the
+ // trend, not just the generic "Last 7 days" label.
+ const latest = values[values.length - 1];
+ const delta = latest - values[0];
+ const direction =
+ delta > 0
+ ? t("measurementReminders.trendStrip.rising")
+ : delta < 0
+ ? t("measurementReminders.trendStrip.falling")
+ : t("measurementReminders.trendStrip.steady");
+
return (
- {values.map((v, i) => {
+
+ {t("measurementReminders.trendStrip.summary", {
+ value: latest,
+ direction,
+ })}
+
+ {readings.map((r, i) => {
// Floor at ~18% so a flat series still reads as bars, not a line.
- const pct = 0.18 + ((v - min) / span) * 0.82;
+ const pct = 0.18 + ((r.value - min) / span) * 0.82;
return (
);
})}
From f002a9d565f3993fe4e3d31af18c364de32b2fc6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:34:20 +0200
Subject: [PATCH 16/71] fix(bp): keep non-physiological readings out of the
seed and the series
The demo seed's random-walk BP generator could drift below the
physiological floor and mint a systolic-0 row (iOS #33). Clamp the walk
to the same band the input validator enforces (systolic >= 40,
diastolic >= 20), reusing the VALUE_RANGES floors, so a fresh seed never
writes a sub-floor reading. Add a read-side selection guard on the blood
pressure series endpoint that excludes rows below those floors from both
the series and the implicit latest point, so a corrupt row that slipped
in before the floor was enforced can never surface as the latest
reading. Export VALUE_RANGES so both sites share one source of truth.
---
scripts/seed-demo.ts | 18 +++++++++++++-----
src/app/api/measurements/series/route.ts | 9 +++++++++
src/lib/validations/measurement.ts | 2 +-
3 files changed, 23 insertions(+), 6 deletions(-)
diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts
index 44d4db708..7bc70996b 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -13,6 +13,7 @@
*/
import pg from "pg";
+import { VALUE_RANGES } from "../src/lib/validations/measurement";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
@@ -55,13 +56,20 @@ function randomWalk(
target: number,
days: number,
volatility: number,
+ // Optional physiological band. A free walk can drift below the floor and
+ // mint an out-of-range row (a systolic-0 BP reading was observed in a
+ // fresh seed — iOS #33); clamping every step keeps the series inside the
+ // same band the input validator enforces.
+ clamp?: { min: number; max: number },
): number[] {
const values: number[] = [start];
for (let i = 1; i < days; i++) {
const prev = values[i - 1];
const drift = (target - prev) * 0.03; // mean reversion
const noise = (Math.random() - 0.5) * volatility;
- values.push(Math.round((prev + drift + noise) * 10) / 10);
+ let next = Math.round((prev + drift + noise) * 10) / 10;
+ if (clamp) next = Math.min(clamp.max, Math.max(clamp.min, next));
+ values.push(next);
}
return values;
}
@@ -124,10 +132,10 @@ async function seed() {
// Weight: 86.5 → trending down to ~82.5
const weights = randomWalk(86.5, 82.0, days, 0.6);
- // Systolic BP: 132 → improving to ~124
- const sysBP = randomWalk(132, 124, days, 4);
- // Diastolic BP: 85 → improving to ~78
- const diaBP = randomWalk(85, 78, days, 3);
+ // Systolic BP: 132 → improving to ~124 (clamped to the physiological band)
+ const sysBP = randomWalk(132, 124, days, 4, VALUE_RANGES.BLOOD_PRESSURE_SYS);
+ // Diastolic BP: 85 → improving to ~78 (clamped to the physiological band)
+ const diaBP = randomWalk(85, 78, days, 3, VALUE_RANGES.BLOOD_PRESSURE_DIA);
// Pulse: 72 → slight improvement to ~68
const pulse = randomWalk(72, 68, days, 3);
// Body fat: 24.5% → trending to ~22%
diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts
index 916f9a82e..e782360e0 100644
--- a/src/app/api/measurements/series/route.ts
+++ b/src/app/api/measurements/series/route.ts
@@ -23,6 +23,7 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
+import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
@@ -264,6 +265,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
};
});
} else if (kind === "bloodPressure") {
+ // Exclude non-physiological rows (systolic < 40 / diastolic < 20) from the
+ // series AND the implicit "latest" (the last point). BP is two rows paired
+ // client-side, so a corrupt row that slipped in before the input floor was
+ // enforced — a seed-era systolic-0, iOS #33 — must never surface as the
+ // latest reading. Floors mirror VALUE_RANGES (the input validator's min);
+ // this is a read-side selection guard, not a tightening of the write floor.
const [sys, dia] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -271,6 +278,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since },
deletedAt: null,
+ value: { gte: VALUE_RANGES.BLOOD_PRESSURE_SYS.min },
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
@@ -281,6 +289,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
type: "BLOOD_PRESSURE_DIA",
measuredAt: { gte: since },
deletedAt: null,
+ value: { gte: VALUE_RANGES.BLOOD_PRESSURE_DIA.min },
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index 1e39e7cd5..ce3dc4574 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -343,7 +343,7 @@ export function getUnitForType(type: string): string {
}
// Plausible ranges per measurement type
-const VALUE_RANGES: Record = {
+export const VALUE_RANGES: Record = {
WEIGHT: { min: 1, max: 500 },
BLOOD_PRESSURE_SYS: { min: 40, max: 300 },
BLOOD_PRESSURE_DIA: { min: 20, max: 200 },
From 2e27c3c4415e2cc361430cca5a0ede192dd5ba2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:35:25 +0200
Subject: [PATCH 17/71] refactor(insights): share the briefing row layout
KeyFindingRow and SignalRow rendered an identical tone bar + metric icon
+ headline/body/delta and the same link-when-routed behaviour, differing
only in their data-slot and body copy. Extract a single BriefingRow and
have both delegate to it.
---
src/components/insights/daily-briefing.tsx | 135 +++++++++------------
1 file changed, 56 insertions(+), 79 deletions(-)
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index f62aa1cfc..27bc08591 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -179,40 +179,51 @@ function DeltaBadge({
);
}
-function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
- const Icon = METRIC_ICON[finding.sourceMetric];
- const href = METRIC_HREF[finding.sourceMetric];
- // v1.4.27 MB7 / CF-68 — when the briefing's `sourceMetric` maps to
- // a routed sub-page, wrap the entire row in a ` ` so the
- // whole card is tappable on mobile instead of the user having to
- // hit the small headline text. Hover + focus paint a subtle bg
- // shift; metrics that don't have a sub-page yet render the
- // original static row.
+/**
+ * v1.18.7 — shared row layout for the key-finding row and the signal-of-the-day
+ * row. Both render the same tone bar + metric icon + headline/body/delta and
+ * the same "wrap in a ` ` when the metric owns a sub-page" behaviour; the
+ * two only differ in their `data-slot` and the body copy they pass.
+ */
+function BriefingRow({
+ sourceMetric,
+ tone,
+ headline,
+ body,
+ delta,
+ dataSlot,
+}: {
+ sourceMetric: DailyBriefingKeyFinding["sourceMetric"];
+ tone: DailyBriefingKeyFinding["tone"];
+ headline: string;
+ body: string;
+ delta: string | null;
+ dataSlot: "daily-briefing-finding" | "daily-briefing-signal";
+}) {
+ const Icon = METRIC_ICON[sourceMetric];
+ const href = METRIC_HREF[sourceMetric];
const rowContent = (
<>
- {stripChartTokens(finding.headline)}
+ {stripChartTokens(headline)}
-
+
- {stripChartTokens(finding.detail)}
+ {stripChartTokens(body)}
>
@@ -221,8 +232,8 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
return (
{rowContent}
@@ -244,70 +255,36 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
);
}
+// v1.4.27 MB7 / CF-68 — the whole row is tappable on mobile (wrapped in a
+// ` ` by `BriefingRow`) so the user need not hit the small headline text.
+function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
+ return (
+
+ );
+}
+
/**
* v1.18.7 — "Signals of the day" row. The present-focused lead of the
* briefing: a NOW-anchored headline + a concrete nudge the user can act on.
- * Shares the tone bar + metric icon visual language of the key-finding row,
- * and pins the same type/route mapping so a signal is tappable when its
- * metric owns a sub-page. Typography matches the key-finding row (the rest of
- * the insight cards): `text-sm` headline, `text-xs` muted body.
+ * Shares `BriefingRow` with the key-finding row.
*/
function SignalRow({ signal }: { signal: DailyBriefingSignal }) {
- const Icon = METRIC_ICON[signal.sourceMetric];
- const href = METRIC_HREF[signal.sourceMetric];
- const rowContent = (
- <>
-
-
-
-
-
- {stripChartTokens(signal.headline)}
-
-
-
-
- {stripChartTokens(signal.nudge)}
-
-
- >
- );
- if (href) {
- return (
-
- {rowContent}
-
- );
- }
return (
-
- {rowContent}
-
+
);
}
From f90ce8bace7c04cdc965a6db56492efe6980a283 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:45:46 +0200
Subject: [PATCH 18/71] test(withings): scope the expired-nonce assertion to
the token URL
The expired-nonce callback test asserted the global fetch spy was never
called. A preceding successful-connection test fires its webhook
subscriptions fire-and-forget, and those promises resolve into the next
test's freshly installed spy, so the broad assertion failed whenever the
background calls landed in time. Filter the recorded calls to the token
endpoint and assert none reached it, matching the replay and cross-user
tests in the same file and keeping the check true to its intent.
---
tests/integration/withings-oauth-flow.test.ts | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/tests/integration/withings-oauth-flow.test.ts b/tests/integration/withings-oauth-flow.test.ts
index 4cf7faf7d..f6edb6d75 100644
--- a/tests/integration/withings-oauth-flow.test.ts
+++ b/tests/integration/withings-oauth-flow.test.ts
@@ -270,8 +270,15 @@ describe("Withings OAuth nonce ledger (real Postgres)", () => {
"withings=error&reason=expired",
);
- // Token endpoint never hit.
- expect(fetchSpy).not.toHaveBeenCalled();
+ // Token endpoint never hit. Scope the assertion to the token URL so a
+ // prior connection's fire-and-forget webhook subscriptions, which resolve
+ // asynchronously into this spy, cannot make the check flaky.
+ const tokenCalls = fetchSpy.mock.calls.filter(
+ ([input]) =>
+ (typeof input === "string" ? input : (input as Request).url) ===
+ WITHINGS_TOKEN_URL,
+ );
+ expect(tokenCalls).toHaveLength(0);
// The handler stamps out the expired row so a clock-skewed replay
// can't slip through.
From 3e04035de8bf199c9bec058afe88559a1dec0012 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:48:02 +0200
Subject: [PATCH 19/71] fix(coach): paint the launcher glyph white on the
gradient
The floating Coach launcher inherited the button's dark foreground token,
which left the sparkle glyph as a dark outline on the purple-pink
gradient. The glyph is decorative and the button carries its own
accessible name, so the white mark reads cleaner without affecting the
control's non-text contrast, which the offset focus ring already
provides.
---
.../insights/__tests__/layout-coach-fab.test.tsx | 14 ++++++++------
src/components/insights/layout-coach-fab.tsx | 2 +-
2 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx
index 92bb3d3e8..02183430a 100644
--- a/src/components/insights/__tests__/layout-coach-fab.test.tsx
+++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx
@@ -71,18 +71,20 @@ describe("", () => {
expect(html).not.toContain("lg:bottom-6");
});
- it("paints a dark glyph + offset focus ring on the gradient", () => {
+ it("paints a white glyph + offset focus ring on the gradient", () => {
mockPathname = "/";
const html = render(
,
);
- // White on the purple/pink gradient sat at ≈2.3:1; the background
- // token reads ≈6.5:1.
- expect(html).toContain("text-background");
- expect(html).not.toContain("text-white");
- // The offset ring draws a visible halo around the gradient circle.
+ // The glyph is decorative (aria-hidden) and the button carries its own
+ // accessible name, so the text-contrast rule does not gate it; the
+ // white glyph reads as a clean mark on the gradient. The button still
+ // sets text-background for any non-glyph text it might hold.
+ expect(html).toContain("text-white");
+ // The offset ring draws a visible halo around the gradient circle and
+ // provides the non-text UI-component contrast against the page.
expect(html).toContain("focus-visible:ring-offset-2");
expect(html).toContain("focus-visible:ring-offset-background");
});
diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index ac0c877e6..cd544013e 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -238,7 +238,7 @@ export function LayoutCoachFab() {
"[body:has([data-testid=onboarding-tour])_&]:invisible",
)}
>
-
+
{unread ? (
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
// Deliberately NOT an alarming red (the medication-card rule:
From b3d303217ce639facd40a6a7d15a8c8d08406d92 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:52:31 +0200
Subject: [PATCH 20/71] perf(db): index session and challenge expiry columns
The sliding-30-day session reaper and the passkey-challenge sweep both
filter on expires_at, but neither sessions nor auth_challenges indexed
that column, so the cleanup degraded to a sequential scan on a busy
instance. Every sibling expiry table already indexes its expiry column;
add the two missing indexes via migration 0178.
---
.../migration.sql | 19 +++++++++++++++++++
prisma/schema.prisma | 6 ++++++
2 files changed, 25 insertions(+)
create mode 100644 prisma/migrations/0178_v1187_session_expiry_indexes/migration.sql
diff --git a/prisma/migrations/0178_v1187_session_expiry_indexes/migration.sql b/prisma/migrations/0178_v1187_session_expiry_indexes/migration.sql
new file mode 100644
index 000000000..36ec5317d
--- /dev/null
+++ b/prisma/migrations/0178_v1187_session_expiry_indexes/migration.sql
@@ -0,0 +1,19 @@
+-- v1.18.7 — expiry indexes for the two tables swept by the session reaper.
+--
+-- `sessions` (sliding-30-day session expiry) and `auth_challenges` (passkey
+-- registration / authentication challenges) are both pruned on `expires_at`,
+-- but neither carried an index on that column. On a busy multi-user instance
+-- the cleanup degraded to a sequential scan. Every sibling expiry table
+-- (`refresh_tokens`, `whoop_oauth_states`, `idempotency_keys`,
+-- `withings_oauth_states`, …) already indexes its expiry column; this brings
+-- the two stragglers in line. Additive; no data touched.
+--
+-- Reversibility (down):
+-- DROP INDEX IF EXISTS "sessions_expires_at_idx";
+-- DROP INDEX IF EXISTS "auth_challenges_expires_at_idx";
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "sessions_expires_at_idx" ON "sessions"("expires_at");
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "auth_challenges_expires_at_idx" ON "auth_challenges"("expires_at");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c2f620a3c..708de1110 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -721,6 +721,9 @@ model Session {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
+ // v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without
+ // this index the cleanup degrades to a sequential scan on a busy instance.
+ @@index([expiresAt])
@@map("sessions")
}
@@ -735,6 +738,9 @@ model AuthChallenge {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
+ // v1.18.7 — challenges are swept by expiry; index the column the reaper
+ // filters on so the cleanup stays index-backed.
+ @@index([expiresAt])
@@map("auth_challenges")
}
From 0278e22ed116bf0dc28906aca3bedc6f46831956 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:54:56 +0200
Subject: [PATCH 21/71] feat(jobs): add retention prune for stored Coach
history
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
coach_messages is append-only and grows forever — a daily Coach user
accrues encrypted conversation rows with no lifecycle, while every
sibling ledger (push_attempts, audit_logs, host_metrics) already prunes.
Add a daily pg-boss cleanup that hard-deletes rows past a configurable
window (COACH_MESSAGE_RETENTION_DAYS, default 365, 30-day floor so a
misconfig cannot drop live in-context history). Wire it into the
maintenance registrar with the dead-queue contract guard and a focused
helper test.
---
.../coach-message-cleanup-queue.test.ts | 56 ++++++++++++
.../__tests__/coach-message-cleanup.test.ts | 90 +++++++++++++++++++
src/lib/jobs/coach-message-cleanup.ts | 50 +++++++++++
src/lib/jobs/reminder/cleanup-handlers.ts | 20 +++++
src/lib/jobs/reminder/register-maintenance.ts | 26 ++++++
5 files changed, 242 insertions(+)
create mode 100644 src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts
create mode 100644 src/lib/jobs/__tests__/coach-message-cleanup.test.ts
create mode 100644 src/lib/jobs/coach-message-cleanup.ts
diff --git a/src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts b/src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts
new file mode 100644
index 000000000..42ba476e2
--- /dev/null
+++ b/src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts
@@ -0,0 +1,56 @@
+/**
+ * v1.18.7 — coach-message-cleanup queue registration guard.
+ *
+ * Same source-text-grep approach as the other queue-wiring guards: assert the
+ * queue is registered in `allQueues`, scheduled, and wired to a `boss.work`
+ * handler — without booting pg-boss + Prisma. An unregistered queue silently
+ * never drains (the recurring v1.4.37 dead-queue bug), which would leave the
+ * encrypted Coach history growing unbounded with the retention job dark.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const REGISTRAR_PATH = join(
+ __dirname,
+ "..",
+ "reminder",
+ "register-maintenance.ts",
+);
+const workerSource =
+ readFileSync(REGISTRAR_PATH, "utf8") +
+ readFileSync(join(__dirname, "..", "reminder", "cleanup-handlers.ts"), "utf8");
+
+describe("reminder-worker — coach-message-cleanup wiring", () => {
+ it("imports the cleanup handler from the cleanup-handlers module", () => {
+ expect(workerSource).toMatch(/\bhandleCoachMessageCleanup\b/);
+ expect(workerSource).toMatch(/\bCoachMessageCleanupPayload\b/);
+ });
+
+ it("registers the coach-message-cleanup queue in the allQueues loop", () => {
+ const allQueuesMatch = workerSource.match(
+ /const allQueues\s*=\s*\[([\s\S]*?)\];/,
+ );
+ expect(allQueuesMatch).not.toBeNull();
+ expect(allQueuesMatch![1]).toMatch(/\bCOACH_MESSAGE_CLEANUP_QUEUE\b/);
+ });
+
+ it("schedules the coach-message-cleanup cron", () => {
+ expect(workerSource).toMatch(
+ /\[COACH_MESSAGE_CLEANUP_QUEUE,\s*COACH_MESSAGE_CLEANUP_CRON\]/,
+ );
+ });
+
+ it("registers a boss.work handler for the queue", () => {
+ expect(workerSource).toMatch(
+ /boss\.work[\s\S]{0,200}COACH_MESSAGE_CLEANUP_QUEUE[\s\S]{0,200}handleCoachMessageCleanup/,
+ );
+ });
+
+ it("prunes coach messages inside the handler", () => {
+ expect(workerSource).toMatch(
+ /handleCoachMessageCleanup[\s\S]{0,400}cleanupOldCoachMessages/,
+ );
+ });
+});
diff --git a/src/lib/jobs/__tests__/coach-message-cleanup.test.ts b/src/lib/jobs/__tests__/coach-message-cleanup.test.ts
new file mode 100644
index 000000000..dfe02e057
--- /dev/null
+++ b/src/lib/jobs/__tests__/coach-message-cleanup.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type { PrismaClient } from "@/generated/prisma/client";
+import {
+ cleanupOldCoachMessages,
+ getCoachMessageRetentionDays,
+ DEFAULT_COACH_MESSAGE_RETENTION_DAYS,
+} from "../coach-message-cleanup";
+
+function makePrismaMock(deletedCount: number) {
+ return {
+ coachMessage: {
+ deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
+ },
+ } as unknown as PrismaClient;
+}
+
+const ORIGINAL_ENV = { ...process.env };
+
+beforeEach(() => {
+ process.env = { ...ORIGINAL_ENV };
+ delete process.env.COACH_MESSAGE_RETENTION_DAYS;
+});
+
+afterEach(() => {
+ process.env = ORIGINAL_ENV;
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe("getCoachMessageRetentionDays", () => {
+ it("returns 365 when env is unset", () => {
+ expect(getCoachMessageRetentionDays()).toBe(
+ DEFAULT_COACH_MESSAGE_RETENTION_DAYS,
+ );
+ });
+
+ it("respects a valid override", () => {
+ process.env.COACH_MESSAGE_RETENTION_DAYS = "180";
+ expect(getCoachMessageRetentionDays()).toBe(180);
+ });
+
+ it("ignores nonsensical values (NaN, 0, negative)", () => {
+ process.env.COACH_MESSAGE_RETENTION_DAYS = "0";
+ expect(getCoachMessageRetentionDays()).toBe(
+ DEFAULT_COACH_MESSAGE_RETENTION_DAYS,
+ );
+ process.env.COACH_MESSAGE_RETENTION_DAYS = "-100";
+ expect(getCoachMessageRetentionDays()).toBe(
+ DEFAULT_COACH_MESSAGE_RETENTION_DAYS,
+ );
+ process.env.COACH_MESSAGE_RETENTION_DAYS = "not a number";
+ expect(getCoachMessageRetentionDays()).toBe(
+ DEFAULT_COACH_MESSAGE_RETENTION_DAYS,
+ );
+ });
+
+ it("rejects too-short retention (< 30 days) to protect live history", () => {
+ process.env.COACH_MESSAGE_RETENTION_DAYS = "7";
+ expect(getCoachMessageRetentionDays()).toBe(
+ DEFAULT_COACH_MESSAGE_RETENTION_DAYS,
+ );
+ });
+});
+
+describe("cleanupOldCoachMessages", () => {
+ it("deletes coach messages older than 365 days by default", async () => {
+ const prisma = makePrismaMock(42);
+ const now = new Date("2026-05-04T00:00:00Z");
+ const deleted = await cleanupOldCoachMessages(prisma, now);
+
+ expect(deleted).toBe(42);
+ expect(prisma.coachMessage.deleteMany).toHaveBeenCalledTimes(1);
+ const cutoff = new Date(now.getTime() - 365 * 86_400_000);
+ expect(prisma.coachMessage.deleteMany).toHaveBeenCalledWith({
+ where: { createdAt: { lt: cutoff } },
+ });
+ });
+
+ it("uses the configured retention", async () => {
+ process.env.COACH_MESSAGE_RETENTION_DAYS = "90";
+ const prisma = makePrismaMock(7);
+ const now = new Date("2026-05-04T00:00:00Z");
+ await cleanupOldCoachMessages(prisma, now);
+
+ const cutoff = new Date(now.getTime() - 90 * 86_400_000);
+ expect(prisma.coachMessage.deleteMany).toHaveBeenCalledWith({
+ where: { createdAt: { lt: cutoff } },
+ });
+ });
+});
diff --git a/src/lib/jobs/coach-message-cleanup.ts b/src/lib/jobs/coach-message-cleanup.ts
new file mode 100644
index 000000000..78768d9c1
--- /dev/null
+++ b/src/lib/jobs/coach-message-cleanup.ts
@@ -0,0 +1,50 @@
+/**
+ * Daily cleanup for the `coach_messages` table.
+ *
+ * v1.18.7 retention (GDPR Art. 5(1)(e) "storage limitation"): the Coach
+ * persists every turn's prose as an encrypted `encryptedContent` Bytes
+ * column. The table is append-only and grows forever — a daily Coach user
+ * accrues rows indefinitely with no lifecycle. The conversation history is
+ * useful context, but stale beyond a year it is dead weight inflating the
+ * table and the `(conversation_id, created_at)` indexes.
+ *
+ * Default retention is 365 days (configurable via COACH_MESSAGE_RETENTION_DAYS
+ * env). Rows older than the cutoff are deleted in a single bulk `deleteMany`;
+ * runs daily via pg-boss. The DELETE is keyed on `created_at` directly — the
+ * pre-existing `(conversation_id, created_at)` index is not a perfect cover
+ * for an unqualified `created_at` scan, but the trailing-edge delete touches
+ * only the oldest sliver of the table.
+ *
+ * The retention floor is 30 days: a misconfigured tiny value never nukes
+ * recent, in-context conversation history. Content stays encrypted at rest
+ * until the moment it is hard-deleted; there is no plaintext path.
+ */
+import type { PrismaClient } from "@/generated/prisma/client";
+
+export const DEFAULT_COACH_MESSAGE_RETENTION_DAYS = 365;
+
+export function getCoachMessageRetentionDays(): number {
+ const raw = process.env.COACH_MESSAGE_RETENTION_DAYS;
+ if (raw === undefined) return DEFAULT_COACH_MESSAGE_RETENTION_DAYS;
+ const parsed = parseInt(raw, 10);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return DEFAULT_COACH_MESSAGE_RETENTION_DAYS;
+ }
+ // Refuse a too-short retention window. Coach history is in-context for the
+ // proactive nudge frequency gate and the conversation rail; 30 days is the
+ // safe floor so a misconfig measured in seconds cannot prune live history.
+ if (parsed < 30) return DEFAULT_COACH_MESSAGE_RETENTION_DAYS;
+ return parsed;
+}
+
+export async function cleanupOldCoachMessages(
+ prisma: PrismaClient,
+ now: Date = new Date(),
+): Promise {
+ const days = getCoachMessageRetentionDays();
+ const cutoff = new Date(now.getTime() - days * 86_400_000);
+ const { count } = await prisma.coachMessage.deleteMany({
+ where: { createdAt: { lt: cutoff } },
+ });
+ return count;
+}
diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts
index 8ae6c8415..9ae4568b3 100644
--- a/src/lib/jobs/reminder/cleanup-handlers.ts
+++ b/src/lib/jobs/reminder/cleanup-handlers.ts
@@ -12,6 +12,7 @@ import {
} from "@/lib/jobs/whoop-oauth-state-cleanup";
import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup";
import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup";
+import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup";
import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup";
import {
cleanupExpiredMeasurementTombstones,
@@ -49,6 +50,10 @@ export interface AuditLogCleanupPayload {
triggeredAt: string;
}
+export interface CoachMessageCleanupPayload {
+ triggeredAt: string;
+}
+
export interface WithingsOAuthStateCleanupPayload {
triggeredAt: string;
}
@@ -201,6 +206,21 @@ export async function handleAuditLogCleanup(
});
}
+export async function handleCoachMessageCleanup(
+ jobs: Job[],
+) {
+ void jobs;
+ await withBackgroundEvent("job.coach_message_cleanup", async (evt) => {
+ const p = getWorkerPrisma();
+ try {
+ const deleted = await cleanupOldCoachMessages(p);
+ evt.addMeta("coach_message_cleanup_deleted", deleted);
+ } catch (err) {
+ evt.addWarning(`coach-message-cleanup failed: ${err}`);
+ }
+ });
+}
+
export async function handleWithingsOAuthStateCleanup(
jobs: Job[],
) {
diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts
index e601c0f67..e003d1d2a 100644
--- a/src/lib/jobs/reminder/register-maintenance.ts
+++ b/src/lib/jobs/reminder/register-maintenance.ts
@@ -77,6 +77,8 @@ import {
handleRateLimitCleanup,
handleIdempotencyCleanup,
handleAuditLogCleanup,
+ CoachMessageCleanupPayload,
+ handleCoachMessageCleanup,
} from "./cleanup-handlers";
import {
HostMetricSamplePayload,
@@ -156,6 +158,16 @@ const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *";
const MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE = "measurement-tombstone-cleanup";
const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
+// v1.18.7 — daily prune for the encrypted Coach conversation history.
+// coach_messages is append-only and grows forever; rows older than the
+// configurable retention window (default 365 days) are hard-deleted.
+// Slots at 03:50 Europe/Berlin after the measurement-tombstone cleanup
+// (03:40) and the cumulative drain (03:45), staying inside the existing
+// 03:xx maintenance window.
+
+const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup";
+
+const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *";
const allQueues = [
DATA_BACKUP_QUEUE,
@@ -193,6 +205,10 @@ const allQueues = [
// the daily schedule silently no-ops and pruned-past-retention tombstones
// pile up forever.
MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE,
+ // v1.18.7 — Coach conversation-history retention prune. Without this entry
+ // the daily schedule silently no-ops and the encrypted coach_messages
+ // table grows unbounded.
+ COACH_MESSAGE_CLEANUP_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -242,6 +258,8 @@ const schedules: ScheduleEntry[] = [
// v1.7.0 — daily 03:40 Europe/Berlin prune for expired measurement
// tombstones.
[MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON],
+ // v1.18.7 — daily 03:50 Europe/Berlin prune for stale Coach history.
+ [COACH_MESSAGE_CLEANUP_QUEUE, COACH_MESSAGE_CLEANUP_CRON],
];
/**
@@ -323,6 +341,14 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleMeasurementTombstoneCleanup,
);
+ // v1.18.7 — daily prune of the encrypted Coach conversation history.
+ // Single-flight like every other cleanup queue; two ticks racing on the
+ // same DELETE is wasted work and the second is a no-op.
+ await boss.work(
+ COACH_MESSAGE_CLEANUP_QUEUE,
+ { localConcurrency: 1 },
+ handleCoachMessageCleanup,
+ );
await boss.work(
PR_DETECTION_QUEUE,
{ localConcurrency: PR_DETECTION_CONCURRENCY },
From 288569297f229cddbf93651ecf4fca43f46364a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:55:46 +0200
Subject: [PATCH 22/71] fix(crypto): fail closed on weak dev key outside
development/test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dev-only SHA-256 key padding was gated on the absence of a production
marker, so a staging or preview container with NODE_ENV unset silently
derived a weak key the operator never recorded — rotating to a real key
later orphaned those rows (decrypt is fail-closed). Gate the padding on an
explicit NODE_ENV of development or test; every other case, including unset
NODE_ENV, throws the same hard error as in production.
---
src/lib/__tests__/crypto.test.ts | 36 ++++++++++++++++++++++++++++++++
src/lib/crypto.ts | 12 +++++++++--
2 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/src/lib/__tests__/crypto.test.ts b/src/lib/__tests__/crypto.test.ts
index 25efbcd5c..fc2a0a299 100644
--- a/src/lib/__tests__/crypto.test.ts
+++ b/src/lib/__tests__/crypto.test.ts
@@ -132,4 +132,40 @@ describe("crypto (AES-256-GCM, key versioning)", () => {
_resetCryptoCacheForTests();
expect(() => encrypt("test")).toThrow(/no matching entry/);
});
+
+ // A 40-char hex string is too short for a 256-bit key but long enough to
+ // reach the dev-only SHA-256 padding branch (base64-decodes to 30 bytes, so
+ // it falls through to the >=32-hex path). It must only pad in development /
+ // test and otherwise fail closed.
+ const SHORT_HEX_KEY = "0123456789abcdef0123456789abcdef01234567";
+
+ it("fails closed when NODE_ENV is unset and only a short hex key is set", () => {
+ vi.stubEnv("NODE_ENV", undefined);
+ vi.stubEnv("ENCRYPTION_KEYS", "");
+ vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
+ vi.stubEnv("ENCRYPTION_KEY", SHORT_HEX_KEY);
+ _resetCryptoCacheForTests();
+ expect(() => encrypt("test")).toThrow(
+ /must be 64 hex characters .* outside development\/test/,
+ );
+ });
+
+ it("fails closed for a short hex key in production", () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("ENCRYPTION_KEYS", "");
+ vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
+ vi.stubEnv("ENCRYPTION_KEY", SHORT_HEX_KEY);
+ _resetCryptoCacheForTests();
+ expect(() => encrypt("test")).toThrow(/outside development\/test/);
+ });
+
+ it("still pads a short hex key in development/test for local use", () => {
+ vi.stubEnv("NODE_ENV", "development");
+ vi.stubEnv("ENCRYPTION_KEYS", "");
+ vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
+ vi.stubEnv("ENCRYPTION_KEY", SHORT_HEX_KEY);
+ _resetCryptoCacheForTests();
+ const ct = encrypt("dev-secret");
+ expect(decrypt(ct)).toBe("dev-secret");
+ });
});
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
index 913871e9a..b2bba0cb4 100644
--- a/src/lib/crypto.ts
+++ b/src/lib/crypto.ts
@@ -59,9 +59,17 @@ function decodeKey(raw: string, label: string): Buffer {
}
}
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length >= 32) {
- if (process.env.NODE_ENV === "production") {
+ // Fail closed unless we are explicitly in a local dev or test run. The
+ // SHA-256 padding below derives a key the operator never recorded; if a
+ // staging/preview container runs with NODE_ENV unset it would silently
+ // encrypt under that derived key, and rotating to a real key later
+ // orphans those rows (decrypt is fail-closed → undecryptable). Only the
+ // two known throwaway-data environments may take the padding path; every
+ // other case (production AND unset NODE_ENV) throws the same hard error.
+ const env = process.env.NODE_ENV;
+ if (env !== "development" && env !== "test") {
throw new Error(
- `Encryption key '${label}' must be 64 hex characters (256 bits) in production. ` +
+ `Encryption key '${label}' must be 64 hex characters (256 bits) outside development/test. ` +
`Generate one with: openssl rand -hex 32`,
);
}
From bda06c9ec4ba98f7f7c47019d14197d8eb0d1e8e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:57:18 +0200
Subject: [PATCH 23/71] refactor(query-keys): route remaining bare arrays
through the factory
The target editor invalidated a bare ["insights"] array on save and reset.
That literal sat outside the centralised factory, so the broad insights
fan-out it intended never matched the reads, which key through
insightsTargets/insightsRoot. Route the editor's reads and invalidations
through userThresholds(), insightsRoot(), insightsTargets() and analytics()
so a saved target actually refreshes every insights surface.
Migrate the other call sites the lint rule does not reach onto the factory:
the Coach conversation rail + thread reads, the per-metric personal-records
badge, the per-day measurement drill-down, the dashboard intake
compliance-chart fan-out, the per-medication side-effects list, the
onboarding auth invalidations, the notification-preferences read, and the
PWA persister's auth-me lookup.
Add the matching factory keys: coachConversations, coachConversation,
personalRecordsByMetric, measurementDrilldown, complianceChartInline and
medicationSideEffects. Each new key reuses the prefix its reads already use
so no invalidation shape drifts.
---
.../dashboard/medication-intake-quick-add.tsx | 2 +-
.../insights/coach-panel/use-coach.ts | 7 ++++---
.../insights/personal-record-badge.tsx | 3 ++-
.../measurements/measurement-list.tsx | 2 +-
.../medications/SideEffectsSection.tsx | 8 ++------
src/components/onboarding/BaselineForm.tsx | 3 ++-
src/components/onboarding/GoalsChipPicker.tsx | 3 ++-
src/components/onboarding/SourceCardGrid.tsx | 3 ++-
src/components/onboarding/WelcomeCarousel.tsx | 3 ++-
.../onboarding/getting-started-checklist.tsx | 2 +-
src/components/onboarding/tour-launcher.tsx | 3 ++-
src/components/targets/target-edit-sheet.tsx | 10 +++++-----
src/lib/pwa/query-persister.ts | 6 +++++-
src/lib/query-keys/coach.ts | 14 ++++++++++++++
src/lib/query-keys/measurements.ts | 19 +++++++++++++++++++
src/lib/query-keys/medications.ts | 16 ++++++++++++++++
16 files changed, 80 insertions(+), 24 deletions(-)
diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx
index b85d45fa2..25d7f1c4a 100644
--- a/src/components/dashboard/medication-intake-quick-add.tsx
+++ b/src/components/dashboard/medication-intake-quick-add.tsx
@@ -281,7 +281,7 @@ export function MedicationIntakeQuickAdd({
// Fan-out to every inline compliance chart key (one per medication)
// so the detail-page tile refreshes when the user re-enters it.
await queryClient.invalidateQueries({
- queryKey: ["compliance-chart-inline"],
+ queryKey: queryKeys.complianceChartInline(),
});
toast.success(t("common.saved"));
diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts
index 3aa7058c9..46c92bf2f 100644
--- a/src/components/insights/coach-panel/use-coach.ts
+++ b/src/components/insights/coach-panel/use-coach.ts
@@ -13,6 +13,7 @@ import type {
CoachSuggestion,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.20 phase B2b — TanStack Query + SSE client for the AI Coach
@@ -32,8 +33,8 @@ import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
*/
const QUERY_KEYS = {
- list: () => ["coachConversations"] as const,
- one: (id: string) => ["coachConversation", id] as const,
+ list: () => queryKeys.coachConversations(),
+ one: (id: string) => queryKeys.coachConversation(id),
};
/**
@@ -74,7 +75,7 @@ export function useCoachConversation(id: string | null) {
// never fires when `id` is null. Key directly on `id` (TanStack
// Query accepts `null` in the array) — the previous "null"
// placeholder branch was structurally equivalent dead code.
- queryKey: ["coachConversation", id] as const,
+ queryKey: queryKeys.coachConversation(id),
queryFn: async (): Promise => {
if (!id) throw new Error("missing id");
return apiGet(`/api/insights/chat/${id}`, {
diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx
index fa87dd6a4..00d3562a7 100644
--- a/src/components/insights/personal-record-badge.tsx
+++ b/src/components/insights/personal-record-badge.tsx
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
@@ -75,7 +76,7 @@ export function PersonalRecordBadge({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
- queryKey: ["personal-records", "by-metric", metricType],
+ queryKey: queryKeys.personalRecordsByMetric(metricType),
queryFn: async () => {
try {
const data = await apiGet(
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx
index 899baa6ba..51765bbdb 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -1508,7 +1508,7 @@ function DayDrillDown({
const { t, locale } = useTranslations();
const { isAuthenticated } = useAuth();
const { data, isLoading, error } = useQuery({
- queryKey: ["measurement-drilldown", type, dayKey],
+ queryKey: queryKeys.measurementDrilldown(type, dayKey),
queryFn: async () => {
const params = new URLSearchParams();
params.set("type", type);
diff --git a/src/components/medications/SideEffectsSection.tsx b/src/components/medications/SideEffectsSection.tsx
index b21ee6d25..834437e05 100644
--- a/src/components/medications/SideEffectsSection.tsx
+++ b/src/components/medications/SideEffectsSection.tsx
@@ -33,6 +33,7 @@ import {
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.25 W19d — GLP-1 side-effect section for the medication detail
@@ -114,12 +115,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const [footerEl, setFooterEl] = useState(null);
const formId = useId();
- const listKey = [
- "medications",
- medicationId,
- "side-effects",
- "list",
- ] as const;
+ const listKey = queryKeys.medicationSideEffects(medicationId);
const { data, isLoading } = useQuery({
queryKey: listKey,
diff --git a/src/components/onboarding/BaselineForm.tsx b/src/components/onboarding/BaselineForm.tsx
index d7a27ec56..f4bed1707 100644
--- a/src/components/onboarding/BaselineForm.tsx
+++ b/src/components/onboarding/BaselineForm.tsx
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import {
AnamnesisCard,
buildAnamnesisAboutMeBody,
@@ -141,7 +142,7 @@ export function BaselineForm() {
}
}
await apiPost("/api/onboarding/step", { step: 4 });
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/4");
} catch (err) {
const message =
diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx
index 488acd625..6a9d46e1c 100644
--- a/src/components/onboarding/GoalsChipPicker.tsx
+++ b/src/components/onboarding/GoalsChipPicker.tsx
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.25 W14b-Content — onboarding step 1 (goals).
@@ -127,7 +128,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
step: 2,
goals: Array.from(selected),
});
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/2");
} catch (err) {
const message =
diff --git a/src/components/onboarding/SourceCardGrid.tsx b/src/components/onboarding/SourceCardGrid.tsx
index 635f20a6e..85e53cd6d 100644
--- a/src/components/onboarding/SourceCardGrid.tsx
+++ b/src/components/onboarding/SourceCardGrid.tsx
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
/**
* Onboarding step 2 (source).
@@ -105,7 +106,7 @@ export function SourceCardGrid() {
setAdvancing(true);
try {
await apiPost("/api/onboarding/step", { step: 3 });
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/3");
} catch (err) {
const message =
diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx
index 986a666f7..fde5a41bc 100644
--- a/src/components/onboarding/WelcomeCarousel.tsx
+++ b/src/components/onboarding/WelcomeCarousel.tsx
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
/**
@@ -152,7 +153,7 @@ export function WelcomeCarousel() {
});
}
await apiPost("/api/onboarding/step", { step: 1 });
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/1");
} catch (err) {
const message =
diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx
index 821c21469..750580612 100644
--- a/src/components/onboarding/getting-started-checklist.tsx
+++ b/src/components/onboarding/getting-started-checklist.tsx
@@ -239,7 +239,7 @@ export function GettingStartedChecklist() {
});
const { data: notificationsData } = useQuery({
- queryKey: ["notifications", "preferences"],
+ queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
return apiGet("/api/notifications/preferences");
},
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index 02a7e5869..43bc22475 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -45,6 +45,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiPost } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
import { OnboardingTour } from "./tour";
const POST_WIZARD_GRACE_MS = 1500;
@@ -371,7 +372,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
if (user?.id) writeSessionDismissed(user.id);
try {
await apiPost("/api/onboarding/tour", { completed: true, outcome });
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
} catch {
// Swallow — the session-dismiss flag prevents a re-open.
// Next visit will retry via the same code path.
diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx
index c43bfec75..27df912ae 100644
--- a/src/components/targets/target-edit-sheet.tsx
+++ b/src/components/targets/target-edit-sheet.tsx
@@ -144,7 +144,7 @@ function TargetEditSheetBody({
// tone vs. the persisted override). The body only mounts when the
// dialog is open, so we don't need a separate `enabled` flag.
const { data: thresholdsData } = useQuery({
- queryKey: ["user", "thresholds"],
+ queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet("/api/user/thresholds");
},
@@ -227,10 +227,10 @@ function TargetEditSheetBody({
await apiPut("/api/user/thresholds", payload);
},
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
+ queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
- queryClient.invalidateQueries({ queryKey: ["insights"] });
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
toast.success(t("targets.edit.saveSuccess"));
onOpenChange(false);
},
@@ -253,10 +253,10 @@ function TargetEditSheetBody({
await apiDelete(`/api/user/thresholds?metric=${metric}`);
},
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
+ queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
- queryClient.invalidateQueries({ queryKey: ["insights"] });
+ queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
toast.success(t("targets.edit.resetSuccess"));
onOpenChange(false);
},
diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts
index 302926f17..b1fdc9c1d 100644
--- a/src/lib/pwa/query-persister.ts
+++ b/src/lib/pwa/query-persister.ts
@@ -32,6 +32,8 @@ import {
type QueryClient,
} from "@tanstack/react-query";
+import { queryKeys } from "@/lib/query-keys";
+
const DB_NAME = "healthlog-query-cache";
const STORE = "kv";
const KEY = "react-query";
@@ -143,7 +145,9 @@ interface PersistedPayload {
/** Read the current account id from the live `["auth","me"]` cache entry. */
function currentUserId(queryClient: QueryClient): string | null {
- const me = queryClient.getQueryData<{ id?: string } | null>(["auth", "me"]);
+ const me = queryClient.getQueryData<{ id?: string } | null>(
+ queryKeys.authMe(),
+ );
return me?.id ?? null;
}
diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts
index 61ffa23a2..ed7d6c6b3 100644
--- a/src/lib/query-keys/coach.ts
+++ b/src/lib/query-keys/coach.ts
@@ -13,4 +13,18 @@ export const coachKeys = {
coachNudgeStatus: () => ["coach-nudge-status"] as const,
/** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */
coachMarkSeen: () => ["coach-mark-seen"] as const,
+ /**
+ * v1.18.7 — the Coach conversation rail list (`GET /api/insights/chat`).
+ * Previously a hook-local bare `["coachConversations"]`; centralised so
+ * the optimistic delete and the streaming hook's post-turn invalidation
+ * share one factory-routed key.
+ */
+ coachConversations: () => ["coachConversations"] as const,
+ /**
+ * v1.18.7 — one decrypted Coach conversation (`GET /api/insights/chat/[id]`).
+ * Keyed on the conversation id so two open threads never share a cache slot;
+ * the streaming hook invalidates this slot once the persisted twin lands.
+ */
+ coachConversation: (id: string | null) =>
+ ["coachConversation", id] as const,
};
diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts
index 3fa99852e..f1cf9936e 100644
--- a/src/lib/query-keys/measurements.ts
+++ b/src/lib/query-keys/measurements.ts
@@ -126,6 +126,25 @@ export const measurementKeys = {
measurementDiversity: (type: string) =>
["measurement-diversity", type] as const,
+ /**
+ * v1.18.7 — per-day drill-down list for one `MeasurementType`
+ * (`GET /api/measurements?type=&dayKey=`), opened from a values-list
+ * day row. Keyed by `(type, dayKey)` so each expanded day caches its own
+ * slot. Distinct `["measurement-drilldown"]` prefix (not `["measurements"]`)
+ * because the day list is a stable historical slice that the drain has
+ * already settled — it does not need a measurement-write fan-out.
+ */
+ measurementDrilldown: (type: string, dayKey: string) =>
+ ["measurement-drilldown", type, dayKey] as const,
+
+ /**
+ * v1.18.7 — per-metric personal-records read (`GET /api/personal-records`)
+ * backing the decorative PR badge on a metric tile. Per-metric key so two
+ * tiles never share a cache slot; its own `["personal-records"]` prefix.
+ */
+ personalRecordsByMetric: (metric: string) =>
+ ["personal-records", "by-metric", metric] as const,
+
/**
* v1.18.7 (Wave E) — bounded last-7 readings for one `MeasurementType`,
* powering the discreet 7-day trend strip on a Vorsorge card. Rides under
diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts
index 8a5ae44ca..429f5ac08 100644
--- a/src/lib/query-keys/medications.ts
+++ b/src/lib/query-keys/medications.ts
@@ -8,6 +8,22 @@ export const medicationKeys = {
medicationDetail: (id: string) => ["medications", id] as const,
medicationComplianceChart: (medicationId: string) =>
["compliance-chart-inline", medicationId] as const,
+ /**
+ * v1.18.7 — the bare `["compliance-chart-inline"]` prefix, for fan-out
+ * invalidation across every per-medication inline compliance tile in one
+ * tick (e.g. a dashboard quick-add intake). `medicationComplianceChart(id)`
+ * above keys an individual tile; this routes the parent prefix through the
+ * factory so the fan-out is not a bare array at the call site.
+ */
+ complianceChartInline: () => ["compliance-chart-inline"] as const,
+ /**
+ * v1.18.7 — per-medication side-effects list, last 30 days
+ * (`GET /api/medications/[id]/side-effects`). Rides under the
+ * `["medications", id, …]` prefix so a CRUD mutation reaches it through
+ * `medicationDependentKeys`.
+ */
+ medicationSideEffects: (medicationId: string) =>
+ ["medications", medicationId, "side-effects", "list"] as const,
/**
* v1.4.42 W3-QUERYKEY-LONGTAIL — per-medication compliance KPI used
* by `medication-card` + `glp1-medication-card`. Centralising lets
From 7530324fd1a044db3e7a22d5ea5173c307c8906b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:57:37 +0200
Subject: [PATCH 24/71] refactor(ai): centralise per-surface generation budgets
Replace the scattered `?? 0.3` / `?? 1000` temperature and maxTokens
magic numbers across the status provider, Coach route, comprehensive
generator and period-narrative generator with a single typed
`ai-budgets.ts` map. Each entry documents the output contract that
fixes its token ceiling.
Right-size the per-metric status output from 1000 to 250 tokens: the
card renders one `{ "summary": "..." }` of 2-4 sentences (30-60 words),
so 1000 was an order of magnitude over the real ceiling.
---
src/app/api/insights/chat/route.ts | 5 +-
src/lib/ai/__tests__/ai-budgets.test.ts | 39 +++++++++
src/lib/ai/ai-budgets.ts | 85 +++++++++++++++++++
src/lib/insights/comprehensive-generate.ts | 5 +-
.../narrative/period-narrative-generate.ts | 5 +-
src/lib/insights/status-provider.ts | 5 +-
6 files changed, 136 insertions(+), 8 deletions(-)
create mode 100644 src/lib/ai/__tests__/ai-budgets.test.ts
create mode 100644 src/lib/ai/ai-budgets.ts
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 93311440c..09ecf7d5d 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -46,6 +46,7 @@ import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import type { CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import {
coachChatRequestSchema,
@@ -439,8 +440,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
params: {
systemPrompt,
userPrompt,
- temperature: 0.4,
- maxTokens: 600,
+ temperature: AI_BUDGETS.coach.temperature,
+ maxTokens: AI_BUDGETS.coach.maxTokens,
},
});
result = fallback.result;
diff --git a/src/lib/ai/__tests__/ai-budgets.test.ts b/src/lib/ai/__tests__/ai-budgets.test.ts
new file mode 100644
index 000000000..7741da12f
--- /dev/null
+++ b/src/lib/ai/__tests__/ai-budgets.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+
+import { AI_BUDGETS, REFERENCE_AI_SEED } from "../ai-budgets";
+
+describe("AI_BUDGETS", () => {
+ it("right-sizes the per-metric status output to its 30-60 word contract", () => {
+ // The status card renders exactly one `{ "summary": "..." }` of 2-4
+ // sentences; 1000 was over-generous. 250 caps the ceiling without
+ // clipping the contract.
+ expect(AI_BUDGETS.status.maxTokens).toBe(250);
+ expect(AI_BUDGETS.status.temperature).toBe(0.3);
+ });
+
+ it("keeps the comprehensive briefing budget at 1500 / 0.3", () => {
+ expect(AI_BUDGETS.comprehensive.maxTokens).toBe(1500);
+ expect(AI_BUDGETS.comprehensive.temperature).toBe(0.3);
+ });
+
+ it("keeps the narrative budget at 400 / 0.3", () => {
+ expect(AI_BUDGETS.narrative.maxTokens).toBe(400);
+ expect(AI_BUDGETS.narrative.temperature).toBe(0.3);
+ });
+
+ it("keeps the coach budget at 600 / 0.4", () => {
+ expect(AI_BUDGETS.coach.maxTokens).toBe(600);
+ expect(AI_BUDGETS.coach.temperature).toBe(0.4);
+ });
+
+ it("keeps the worker budgets (summary / facts / self-context)", () => {
+ expect(AI_BUDGETS.summary).toEqual({ temperature: 0.3, maxTokens: 200 });
+ expect(AI_BUDGETS.facts).toEqual({ temperature: 0.2, maxTokens: 300 });
+ expect(AI_BUDGETS.selfContext.temperature).toBe(0.4);
+ });
+
+ it("exposes a stable reference seed constant", () => {
+ expect(typeof REFERENCE_AI_SEED).toBe("number");
+ expect(Number.isInteger(REFERENCE_AI_SEED)).toBe(true);
+ });
+});
diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts
new file mode 100644
index 000000000..ce6c49434
--- /dev/null
+++ b/src/lib/ai/ai-budgets.ts
@@ -0,0 +1,85 @@
+/**
+ * Per-surface AI generation budgets — the single source of truth for the
+ * `temperature` and `maxTokens` each AI surface sends to the provider.
+ *
+ * Before this module the values were scattered as `?? 0.3` / `?? 1000`
+ * magic numbers across the status provider, the Coach route, the
+ * comprehensive generator and the period-narrative generator. Centralising
+ * them keeps the cost ceiling auditable in one place and ties every number
+ * to the output contract it serves (commented per entry below).
+ *
+ * `seed` (where present) is a deterministic constant pinned for the
+ * reference/reproducible surfaces (per the v1.18.7 AI audit MEDIUM-4):
+ * the status cards and the period narrative are reference assessments, so a
+ * fixed seed makes a prompt change diff-able against a stable baseline. The
+ * daily-briefing paragraph is deliberately NOT seeded (it re-rolls for
+ * phrasing variety — see comprehensive-generate.ts).
+ */
+
+export interface AiBudget {
+ /** Sampling temperature. Low for reference surfaces, slightly higher for chat. */
+ temperature: number;
+ /** Output-token ceiling. Sized to the surface's documented output contract. */
+ maxTokens: number;
+}
+
+/**
+ * Deterministic seed pinned for the reproducible reference surfaces. A stable
+ * constant (not a per-request value) so two runs of the same prompt+snapshot
+ * on a seed-aware provider produce byte-identical text — the QA-attribution
+ * baseline the audit's MEDIUM-4 calls for.
+ */
+export const REFERENCE_AI_SEED = 1_618_033 as const;
+
+/**
+ * Surface budgets. Each entry's comment states the output contract that
+ * fixes its `maxTokens`.
+ */
+export const AI_BUDGETS = {
+ /**
+ * Comprehensive insight + dailyBriefing — the largest structured payload
+ * (summary + recommendations + dailyBriefing 80-200 words + signals +
+ * keyFindings + trendAnnotations + storyboardAnnotations). 1500 tokens.
+ */
+ comprehensive: { temperature: 0.3, maxTokens: 1500 },
+
+ /**
+ * Per-metric status assessment cards — output is a single
+ * `{ "summary": "..." }` of 2-4 sentences / 30-60 words
+ * (base-system.ts `length` section). ~80 tokens of content + JSON
+ * envelope; 250 is a comfortable ceiling (was an over-generous 1000).
+ */
+ status: { temperature: 0.3, maxTokens: 250 },
+
+ /**
+ * Period narrative (week/month) — 2-4 short plain-text sentences
+ * (period-narrative-generate.ts SYSTEM_PROMPT). 400 tokens.
+ */
+ narrative: { temperature: 0.3, maxTokens: 400 },
+
+ /**
+ * Coach SSE chat — a 60-180 word prose reply plus an optional evidence
+ * sentinel block (system-prompt.ts rule 1). 600 tokens. Slightly higher
+ * temperature than the reference surfaces for conversational variety.
+ */
+ coach: { temperature: 0.4, maxTokens: 600 },
+
+ /**
+ * Coach rolling-conversation summary worker — a compact summary of the
+ * elided older turns. 200 tokens.
+ */
+ summary: { temperature: 0.3, maxTokens: 200 },
+
+ /**
+ * Coach durable-fact extraction worker — a short structured fact list.
+ * 300 tokens. Lowest temperature: extraction must be faithful, not
+ * creative.
+ */
+ facts: { temperature: 0.2, maxTokens: 300 },
+
+ /**
+ * Coach self-context question generation — a small set of clarifying
+ * questions. Temperature only; the caller pins its own token const.
+ */
+ selfContext: { temperature: 0.4 },
+} as const satisfies Record;
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 139319d65..9d3ce33e8 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -74,6 +74,7 @@ import {
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
import { annotate } from "@/lib/logging/context";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -697,8 +698,8 @@ export async function generateComprehensiveInsight(
params: {
systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
userPrompt,
- temperature: 0.3,
- maxTokens: 1500,
+ temperature: AI_BUDGETS.comprehensive.temperature,
+ maxTokens: AI_BUDGETS.comprehensive.maxTokens,
},
});
result = fallback.result;
diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts
index d383d4cb7..d1c414036 100644
--- a/src/lib/insights/narrative/period-narrative-generate.ts
+++ b/src/lib/insights/narrative/period-narrative-generate.ts
@@ -29,6 +29,7 @@ import { prisma as defaultPrisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { annotate } from "@/lib/logging/context";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
buildPeriodNarrativeContext,
@@ -261,8 +262,8 @@ export async function generatePeriodNarrative(
consentSurface: "insights",
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
userPrompt: buildNarrativeUserPrompt(context, locale),
- temperature: 0.3,
- maxTokens: 400,
+ temperature: AI_BUDGETS.narrative.temperature,
+ maxTokens: AI_BUDGETS.narrative.maxTokens,
});
// A provider error / timeout is non-fatal — the last good row stays as-is
diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts
index 12a66484a..104704e82 100644
--- a/src/lib/insights/status-provider.ts
+++ b/src/lib/insights/status-provider.ts
@@ -10,6 +10,7 @@ import {
type ConsentSurface,
} from "@/lib/ai/consent-guard";
import { annotate } from "@/lib/logging/context";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
/**
@@ -161,8 +162,8 @@ export async function runStatusCompletion(
params: {
systemPrompt,
userPrompt,
- temperature: args.temperature ?? 0.3,
- maxTokens: args.maxTokens ?? 1000,
+ temperature: args.temperature ?? AI_BUDGETS.status.temperature,
+ maxTokens: args.maxTokens ?? AI_BUDGETS.status.maxTokens,
},
}),
STATUS_PROVIDER_TIMEOUT_MS,
From a460c74e6c0b215f6e9e15084e46f1c840f60175 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 20:58:46 +0200
Subject: [PATCH 25/71] feat(ai): support a deterministic seed on the reference
surfaces
Add an optional `seed` to CompletionParams and thread it onto the
OpenAI and local (Ollama / OpenAI-compatible) request bodies. The field
is dropped when unset, so existing callers are byte-identical.
Pin a stable seed for the status/reference cards (defaulted in the
status provider) and the period narrative, so a prompt change is
diff-able against a fixed baseline rather than masked by low-temperature
non-determinism. Anthropic's Messages API has no seed knob, so its
client ignores the field (documented inline). The seed is annotated on
the narrative wide-event.
---
src/lib/ai/__tests__/openai-client.test.ts | 29 +++++++++++++++++++
src/lib/ai/anthropic-client.ts | 3 ++
src/lib/ai/local-client.ts | 4 +++
src/lib/ai/openai-client.ts | 4 +++
src/lib/ai/types.ts | 7 +++++
.../narrative/period-narrative-generate.ts | 7 +++--
src/lib/insights/status-provider.ts | 11 ++++++-
7 files changed, 62 insertions(+), 3 deletions(-)
diff --git a/src/lib/ai/__tests__/openai-client.test.ts b/src/lib/ai/__tests__/openai-client.test.ts
index 2dfb52125..ac7fa1dd9 100644
--- a/src/lib/ai/__tests__/openai-client.test.ts
+++ b/src/lib/ai/__tests__/openai-client.test.ts
@@ -39,6 +39,35 @@ describe("OpenAIClient", () => {
expect(body.model).toBe("gpt-4o-mini");
expect(body.messages).toHaveLength(2);
expect(body.response_format).toEqual({ type: "json_object" });
+ // No seed passed → field omitted from the body entirely.
+ expect(body).not.toHaveProperty("seed");
+ });
+
+ it("threads a deterministic seed onto the request body when supplied", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ choices: [{ message: { content: '{"summary":"x"}' } }],
+ usage: { total_tokens: 5 },
+ }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const client = new OpenAIClient({
+ apiKey: "sk-test",
+ model: "gpt-4o-mini",
+ baseUrl: "https://api.openai.com/v1",
+ });
+
+ await client.generateCompletion({
+ systemPrompt: "test",
+ userPrompt: "test",
+ seed: 1234,
+ });
+
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body.seed).toBe(1234);
});
it("throws on non-ok response", async () => {
diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts
index 4228fd322..b58d8e317 100644
--- a/src/lib/ai/anthropic-client.ts
+++ b/src/lib/ai/anthropic-client.ts
@@ -45,6 +45,9 @@ export class AnthropicClient implements AIProvider {
"x-api-key": this.config.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
},
+ // NOTE: Anthropic's Messages API has no `seed` parameter, so
+ // `params.seed` is intentionally not forwarded here — output on this
+ // provider is non-deterministic regardless of the pinned seed.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts
index df03ff991..4e69db65a 100644
--- a/src/lib/ai/local-client.ts
+++ b/src/lib/ai/local-client.ts
@@ -65,6 +65,10 @@ export class LocalOpenAICompatibleClient implements AIProvider {
],
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
+ // Deterministic seed for reproducible reference output. Ollama +
+ // most OpenAI-compatible local servers honour it; servers that
+ // ignore it simply disregard the field. Omitted when unset.
+ ...(params.seed !== undefined ? { seed: params.seed } : {}),
}),
},
{ timeoutMs: 60_000, requirePublicHost: !allowPrivate },
diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts
index a898acaed..7260e6320 100644
--- a/src/lib/ai/openai-client.ts
+++ b/src/lib/ai/openai-client.ts
@@ -48,6 +48,10 @@ export class OpenAIClient implements AIProvider {
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
response_format: { type: "json_object" },
+ // Deterministic seed for reproducible reference output; omitted
+ // (undefined → dropped by JSON.stringify) when the caller does
+ // not pin one (e.g. the seedless daily-briefing re-roll).
+ ...(params.seed !== undefined ? { seed: params.seed } : {}),
}),
},
// 60 s ceiling so a tar-pit upstream cannot pin a worker
diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts
index a6dd524d2..54345b244 100644
--- a/src/lib/ai/types.ts
+++ b/src/lib/ai/types.ts
@@ -119,6 +119,13 @@ export interface CompletionParams {
userPrompt: string;
temperature?: number;
maxTokens?: number;
+ /**
+ * Optional deterministic seed. Threaded onto the OpenAI and local
+ * (Ollama / OpenAI-compatible) request bodies for reproducible output on
+ * the reference surfaces (status cards + period narrative). Anthropic's
+ * Messages API has no seed knob, so the Anthropic client ignores it.
+ */
+ seed?: number;
}
export interface CompletionResult {
diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts
index d1c414036..d5be8d839 100644
--- a/src/lib/insights/narrative/period-narrative-generate.ts
+++ b/src/lib/insights/narrative/period-narrative-generate.ts
@@ -29,7 +29,7 @@ import { prisma as defaultPrisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { annotate } from "@/lib/logging/context";
-import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
+import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
buildPeriodNarrativeContext,
@@ -264,6 +264,9 @@ export async function generatePeriodNarrative(
userPrompt: buildNarrativeUserPrompt(context, locale),
temperature: AI_BUDGETS.narrative.temperature,
maxTokens: AI_BUDGETS.narrative.maxTokens,
+ // v1.18.7 — reference surface: pin a deterministic seed so a prompt
+ // change is diff-able against a stable baseline (MEDIUM-4).
+ seed: REFERENCE_AI_SEED,
});
// A provider error / timeout is non-fatal — the last good row stays as-is
@@ -316,7 +319,7 @@ export async function generatePeriodNarrative(
});
annotate({
action: { name: "insights.narrative.generated" },
- meta: { period, locale, provider: providerType },
+ meta: { period, locale, provider: providerType, seed: REFERENCE_AI_SEED },
});
};
diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts
index 104704e82..7be41ada2 100644
--- a/src/lib/insights/status-provider.ts
+++ b/src/lib/insights/status-provider.ts
@@ -10,7 +10,7 @@ import {
type ConsentSurface,
} from "@/lib/ai/consent-guard";
import { annotate } from "@/lib/logging/context";
-import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
+import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
/**
@@ -51,6 +51,12 @@ interface RunStatusCompletionArgs {
userPrompt: string;
temperature?: number;
maxTokens?: number;
+ /**
+ * v1.18.7 — optional deterministic seed override. Defaults to
+ * `REFERENCE_AI_SEED` for every status/reference surface (reproducible
+ * QA); the period narrative passes the same constant explicitly.
+ */
+ seed?: number;
/**
* v1.12.1 — which AI surface this generation serves, for the consent gate.
* `insights` for the per-metric status cards + period narrative; `coach`
@@ -164,6 +170,9 @@ export async function runStatusCompletion(
userPrompt,
temperature: args.temperature ?? AI_BUDGETS.status.temperature,
maxTokens: args.maxTokens ?? AI_BUDGETS.status.maxTokens,
+ // v1.18.7 — status/reference output is reproducible: pin the
+ // deterministic seed unless a caller overrides it.
+ seed: args.seed ?? REFERENCE_AI_SEED,
},
}),
STATUS_PROVIDER_TIMEOUT_MS,
From a61b7fe4ad85fa5807c016a7f670d01c3e766a56 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:01:27 +0200
Subject: [PATCH 26/71] feat(ai): force JSON on the non-OpenAI provider chains
Add an opt-in `responseFormat: "json"` to CompletionParams that the
structured surfaces (comprehensive briefing + per-metric status cards)
set. On local (Ollama / OpenAI-compatible) it adds `format: "json"`; on
Anthropic it prefills the assistant turn with `{` and re-prepends the
brace to the reply, so the first emitted token is already inside a JSON
object. Both cut the first-pass JSON-failure rate that fence-stripping
otherwise has to absorb.
OpenAI already pins `response_format: json_object` and is unchanged. The
period narrative (plain text) and the Coach (streaming prose) leave the
flag unset, so their non-JSON paths are untouched.
---
src/lib/ai/__tests__/anthropic-client.test.ts | 59 +++++++++++++++++++
src/lib/ai/__tests__/local-client.test.ts | 31 ++++++++++
src/lib/ai/anthropic-client.ts | 29 ++++++++-
src/lib/ai/local-client.ts | 5 ++
src/lib/ai/types.ts | 11 ++++
src/lib/insights/comprehensive-generate.ts | 4 ++
.../narrative/period-narrative-generate.ts | 3 +
src/lib/insights/status-provider.ts | 10 ++++
8 files changed, 149 insertions(+), 3 deletions(-)
diff --git a/src/lib/ai/__tests__/anthropic-client.test.ts b/src/lib/ai/__tests__/anthropic-client.test.ts
index b8aa85683..3db2580da 100644
--- a/src/lib/ai/__tests__/anthropic-client.test.ts
+++ b/src/lib/ai/__tests__/anthropic-client.test.ts
@@ -53,6 +53,65 @@ describe("AnthropicClient", () => {
expect(body.messages[0].content.toLowerCase()).toContain("json");
});
+ it("prefills the assistant turn with `{` and re-prepends it for JSON surfaces", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ // Model continues from the prefilled `{`, so its text omits it.
+ content: [{ type: "text", text: '"summary":"ok"}' }],
+ usage: { input_tokens: 5, output_tokens: 5 },
+ }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const client = new AnthropicClient({
+ apiKey: "sk-ant-test",
+ model: "claude-3-5-sonnet-latest",
+ });
+
+ const result = await client.generateCompletion({
+ systemPrompt: "s",
+ userPrompt: "u",
+ responseFormat: "json",
+ });
+
+ // The returned content is the complete object (the `{` re-prepended).
+ expect(result.content).toBe('{"summary":"ok"}');
+ expect(JSON.parse(result.content)).toEqual({ summary: "ok" });
+
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body.messages).toHaveLength(2);
+ expect(body.messages[1]).toEqual({ role: "assistant", content: "{" });
+ });
+
+ it("does NOT prefill for the prose (non-JSON) path", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ content: [{ type: "text", text: "Just some prose." }],
+ usage: { input_tokens: 1, output_tokens: 1 },
+ }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const client = new AnthropicClient({
+ apiKey: "sk-ant-test",
+ model: "claude-3-5-sonnet-latest",
+ });
+
+ const result = await client.generateCompletion({
+ systemPrompt: "s",
+ userPrompt: "u",
+ });
+
+ expect(result.content).toBe("Just some prose.");
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body.messages).toHaveLength(1);
+ expect(body.messages[0].role).toBe("user");
+ });
+
it("uses custom baseUrl when provided", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
diff --git a/src/lib/ai/__tests__/local-client.test.ts b/src/lib/ai/__tests__/local-client.test.ts
index e0f6f70be..ba6c38ffb 100644
--- a/src/lib/ai/__tests__/local-client.test.ts
+++ b/src/lib/ai/__tests__/local-client.test.ts
@@ -55,6 +55,37 @@ describe("LocalOpenAICompatibleClient", () => {
// CRUCIAL: do not send response_format because many local servers reject it.
expect(body).not.toHaveProperty("response_format");
+ // No JSON opt-in → no `format` field.
+ expect(body).not.toHaveProperty("format");
+ });
+
+ it("sends Ollama `format: json` only when the caller opts into JSON", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ choices: [{ message: { content: '{"summary":"x"}' } }],
+ usage: { total_tokens: 3 },
+ }),
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const client = new LocalOpenAICompatibleClient({
+ apiKey: null,
+ model: "llama3:8b",
+ baseUrl: "http://localhost:11434/v1",
+ });
+
+ await client.generateCompletion({
+ systemPrompt: "s",
+ userPrompt: "u",
+ responseFormat: "json",
+ seed: 99,
+ });
+
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body.format).toBe("json");
+ expect(body.seed).toBe(99);
});
it("omits Authorization header when no API key is provided", async () => {
diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts
index b58d8e317..87ac4d5af 100644
--- a/src/lib/ai/anthropic-client.ts
+++ b/src/lib/ai/anthropic-client.ts
@@ -48,12 +48,26 @@ export class AnthropicClient implements AIProvider {
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
+ //
+ // v1.18.7 — JSON-reliability prefill. For the structured surfaces
+ // (`responseFormat: "json"`) we seed the assistant turn with a bare
+ // `{`. Anthropic continues from that token, so the first emitted
+ // character is already inside a JSON object — this drops the
+ // first-pass "prose preamble before the JSON" failure the
+ // fence-stripping net otherwise has to catch. We re-prepend the `{`
+ // to the returned text below so the caller sees a complete object.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
temperature: params.temperature ?? 0.3,
system: params.systemPrompt,
- messages: [{ role: "user", content: wrapForJson(params.userPrompt) }],
+ messages:
+ params.responseFormat === "json"
+ ? [
+ { role: "user", content: wrapForJson(params.userPrompt) },
+ { role: "assistant", content: "{" },
+ ]
+ : [{ role: "user", content: wrapForJson(params.userPrompt) }],
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
@@ -89,12 +103,21 @@ export class AnthropicClient implements AIProvider {
};
const textBlock = json.content?.find((c) => c.type === "text");
- const content = textBlock?.text;
+ const rawText = textBlock?.text;
- if (!content) {
+ if (!rawText) {
throw new Error("Anthropic returned empty content");
}
+ // When we prefilled the assistant turn with `{`, the model continues
+ // from there and its returned text omits that leading brace — re-prepend
+ // it so the caller parses a complete object. Guard against a model that
+ // (rarely) echoes the prefill itself.
+ const content =
+ params.responseFormat === "json" && !rawText.trimStart().startsWith("{")
+ ? `{${rawText}`
+ : rawText;
+
const inputTokens = json.usage?.input_tokens ?? 0;
const outputTokens = json.usage?.output_tokens ?? 0;
const tokensUsed = inputTokens + outputTokens || null;
diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts
index 4e69db65a..905e4e62b 100644
--- a/src/lib/ai/local-client.ts
+++ b/src/lib/ai/local-client.ts
@@ -69,6 +69,11 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// most OpenAI-compatible local servers honour it; servers that
// ignore it simply disregard the field. Omitted when unset.
...(params.seed !== undefined ? { seed: params.seed } : {}),
+ // v1.18.7 — Ollama's native structured-output switch. The JSON
+ // surfaces opt in via `responseFormat: "json"`; it cuts the
+ // first-pass JSON-failure rate that the fence-stripping safety net
+ // otherwise has to catch. The Coach (prose) leaves it unset.
+ ...(params.responseFormat === "json" ? { format: "json" } : {}),
}),
},
{ timeoutMs: 60_000, requirePublicHost: !allowPrivate },
diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts
index 54345b244..d5844da2c 100644
--- a/src/lib/ai/types.ts
+++ b/src/lib/ai/types.ts
@@ -126,6 +126,17 @@ export interface CompletionParams {
* Messages API has no seed knob, so the Anthropic client ignores it.
*/
seed?: number;
+ /**
+ * v1.18.7 — opt the structured (JSON) surfaces into the provider's
+ * strongest JSON-reliability mode. When `"json"`:
+ * - local (Ollama / OpenAI-compatible): adds `format: "json"`.
+ * - Anthropic: prefills the assistant turn with `{` so the first token
+ * is forced into a JSON object.
+ * The OpenAI client already pins `response_format: json_object`
+ * unconditionally and is unaffected. The Coach (prose, NOT JSON) leaves
+ * this unset, so its streaming path is untouched.
+ */
+ responseFormat?: "json";
}
export interface CompletionResult {
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 9d3ce33e8..fab7310de 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -700,6 +700,10 @@ export async function generateComprehensiveInsight(
userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
+ // v1.18.7 — structured surface: opt the non-OpenAI chains into their
+ // strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a
+ // first-pass JSON miss is rarer; stripJsonFences stays the net.
+ responseFormat: "json",
},
});
result = fallback.result;
diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts
index d5be8d839..55c63c73c 100644
--- a/src/lib/insights/narrative/period-narrative-generate.ts
+++ b/src/lib/insights/narrative/period-narrative-generate.ts
@@ -267,6 +267,9 @@ export async function generatePeriodNarrative(
// v1.18.7 — reference surface: pin a deterministic seed so a prompt
// change is diff-able against a stable baseline (MEDIUM-4).
seed: REFERENCE_AI_SEED,
+ // The narrative output is PLAIN TEXT (no markdown, no JSON), so suppress
+ // the JSON-mode opt-in the status cards default into.
+ responseFormat: "text",
});
// A provider error / timeout is non-fatal — the last good row stays as-is
diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts
index 7be41ada2..cbe32d023 100644
--- a/src/lib/insights/status-provider.ts
+++ b/src/lib/insights/status-provider.ts
@@ -57,6 +57,13 @@ interface RunStatusCompletionArgs {
* QA); the period narrative passes the same constant explicitly.
*/
seed?: number;
+ /**
+ * v1.18.7 — output contract of this generation. The per-metric status
+ * cards return a JSON `{ "summary": ... }` (the default), so they opt the
+ * non-OpenAI chains into their strongest JSON mode. The period narrative
+ * returns PLAIN TEXT and passes `"text"` to suppress that.
+ */
+ responseFormat?: "json" | "text";
/**
* v1.12.1 — which AI surface this generation serves, for the consent gate.
* `insights` for the per-metric status cards + period narrative; `coach`
@@ -173,6 +180,9 @@ export async function runStatusCompletion(
// v1.18.7 — status/reference output is reproducible: pin the
// deterministic seed unless a caller overrides it.
seed: args.seed ?? REFERENCE_AI_SEED,
+ // Status cards are JSON by default; the narrative opts out via
+ // `"text"`.
+ responseFormat: args.responseFormat === "text" ? undefined : "json",
},
}),
STATUS_PROVIDER_TIMEOUT_MS,
From 431259bb06e022bfc5bd228c6c8e8656cfe554c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:02:42 +0200
Subject: [PATCH 27/71] feat(demo): reshape demo seed to read current and
healthy
Tune the public demo tenant for App-Store screenshots:
- Extend the measurement loop to include today so every headline metric
has a current reading.
- Retarget blood pressure into the optimal band (~118/76), weight to a
stable healthy BMI, and add a clean resting-heart-rate series so the
resting-pulse tile scores it directly instead of the spot pulse.
- Write a full week of per-stage sleep nights (awake/REM/core/deep,
~7.5h, source APPLE_HEALTH) including last night, in minutes, and skip
the aggregate row for those nights to avoid double-counting.
- Schedule today's doses on-track: morning meds taken, the evening dose
pending and never auto-missed, so today never reads overdue or 0%.
- Add Vorsorge reminders (dental check-up, annual physical), one healthy
lab panel, one resolved illness episode, and an AI-configured Coach
with a short encrypted sample conversation.
All dates stay relative to now so the demo refreshes on every re-seed.
---
scripts/seed-demo.ts | 336 +++++++++++++++++++++++++++++++++++++------
1 file changed, 295 insertions(+), 41 deletions(-)
diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts
index 7bc70996b..96b9b1104 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -1,18 +1,37 @@
/**
* Demo Data Seed Script for HealthLog
*
- * Creates realistic demo data:
+ * Creates a realistic, aspirational demo tenant for the public `demo`
+ * account (demo / demo123demo123 — credentials are intentionally public).
+ * The shape is tuned so a fresh seed reads healthy, engaged, and current
+ * for App-Store / marketing screenshots:
+ *
* - 1 admin user (demo/demo123demo123)
- * - 90 days of measurements (weight, BP, pulse, body fat, sleep, steps)
- * - 3 medications with schedules and intake history
+ * - 90 days of measurements (weight, BP, pulse, resting HR, body fat,
+ * sleep, steps) — flat-or-improving trends, most-recent point from today
+ * - A full week of per-stage sleep nights (awake/REM/light(core)/deep),
+ * ~7.5 h each, including last night
+ * - 3 medications with schedules and ~90 days of intake history at high
+ * compliance, with today scheduled on-track (taken or not-yet-due)
* - 90 days of mood entries
+ * - Vorsorge (preventive-care) reminders — upcoming dental + annual physical
+ * - One lab panel of biomarkers in healthy ranges
+ * - One resolved illness episode in the past
+ * - An AI-configured Coach with one short sample conversation
* - App settings (registration disabled, English locale)
*
+ * Every date is relative to "now" so the demo stays fresh on every re-seed.
+ *
* Usage: npx tsx scripts/seed-demo.ts
- * Requires DATABASE_URL env var.
+ * Requires DATABASE_URL env var. The Coach sample conversation additionally
+ * needs ENCRYPTION_KEYS / ENCRYPTION_KEY (the same key the app runs with).
*/
+import { Buffer } from "node:buffer";
+
import pg from "pg";
+import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
+import { encrypt } from "../src/lib/crypto";
import { VALUE_RANGES } from "../src/lib/validations/measurement";
const DATABASE_URL = process.env.DATABASE_URL;
@@ -46,6 +65,16 @@ function daysAgo(n: number): Date {
return d;
}
+// A specific local instant N days ago, at a fixed wall-clock time. Used for
+// the per-stage sleep segments and the on-track intake slots where the hour
+// matters (a random morning hour would scatter the sleep stages).
+function daysAgoAt(n: number, hour: number, minute = 0): Date {
+ const d = new Date();
+ d.setDate(d.getDate() - n);
+ d.setHours(hour, minute, 0, 0);
+ return d;
+}
+
function formatDate(d: Date): string {
return d.toISOString().split("T")[0];
}
@@ -83,6 +112,11 @@ async function seed() {
await client.query("BEGIN");
console.log("Cleaning existing data...");
+ await client.query("DELETE FROM coach_messages");
+ await client.query("DELETE FROM coach_conversations");
+ await client.query("DELETE FROM illness_episodes");
+ await client.query("DELETE FROM lab_results");
+ await client.query("DELETE FROM measurement_reminders");
await client.query("DELETE FROM mood_entries");
await client.query("DELETE FROM medication_intake_events");
await client.query("DELETE FROM medication_schedules");
@@ -109,9 +143,17 @@ async function seed() {
const passwordHash =
"$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
+ // AI Coach is configured to the Anthropic provider so the Coach surface
+ // reads as connected. The stored key is a placeholder ciphertext: the
+ // public demo never makes a live provider call (the sample conversation
+ // below supplies the visible content), so no real credential is seeded.
+ const aiProvider = "ANTHROPIC";
+ const aiModel = "claude-3-5-sonnet";
+ const aiKeyEncrypted = encrypt("sk-ant-demo-placeholder-not-a-live-key");
+
await client.query(
- `INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`,
+ `INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, ai_provider, ai_model, ai_anthropic_key_encrypted, onboarding_completed_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW(), NOW())`,
[
userId,
"demo",
@@ -123,29 +165,38 @@ async function seed() {
"MALE",
"Europe/Berlin",
"en",
+ aiProvider,
+ aiModel,
+ aiKeyEncrypted,
],
);
// ── Measurements (90 days) ────────────────
+ // The loop runs i = 0..days (inclusive) so the final iteration lands a
+ // point for TODAY (daysAgo(0)) — every headline metric has a current
+ // reading for the dashboard, not one from yesterday.
console.log("Creating 90 days of measurements...");
const days = 90;
+ const span = days + 1; // include today
- // Weight: 86.5 → trending down to ~82.5
- const weights = randomWalk(86.5, 82.0, days, 0.6);
- // Systolic BP: 132 → improving to ~124 (clamped to the physiological band)
- const sysBP = randomWalk(132, 124, days, 4, VALUE_RANGES.BLOOD_PRESSURE_SYS);
- // Diastolic BP: 85 → improving to ~78 (clamped to the physiological band)
- const diaBP = randomWalk(85, 78, days, 3, VALUE_RANGES.BLOOD_PRESSURE_DIA);
- // Pulse: 72 → slight improvement to ~68
- const pulse = randomWalk(72, 68, days, 3);
- // Body fat: 24.5% → trending to ~22%
- const bodyFat = randomWalk(24.5, 22.0, days, 0.4);
- // Sleep: 6.5h → improving to ~7.5h
- const sleep = randomWalk(6.5, 7.5, days, 0.8);
- // Steps: 5000 → improving to ~8000
- const steps = randomWalk(5000, 8500, days, 1500);
+ // Weight: 86.5 → trending down to a stable ~82 (healthy BMI ~24.7 at 182cm)
+ const weights = randomWalk(86.5, 82.0, span, 0.6);
+ // Systolic BP: 128 → settled in the optimal band ~118 (clamped)
+ const sysBP = randomWalk(128, 118, span, 3, VALUE_RANGES.BLOOD_PRESSURE_SYS);
+ // Diastolic BP: 82 → settled ~76 (clamped)
+ const diaBP = randomWalk(82, 76, span, 2.5, VALUE_RANGES.BLOOD_PRESSURE_DIA);
+ // Spot pulse (daytime heart rate): ~72, gently lower
+ const pulse = randomWalk(74, 70, span, 3, VALUE_RANGES.PULSE);
+ // Resting heart rate: clean ~60, the metric the resting-pulse tile scores
+ const restingHr = randomWalk(64, 58, span, 2, VALUE_RANGES.RESTING_HEART_RATE);
+ // Body fat: 24% → trending to a healthy ~19%
+ const bodyFat = randomWalk(24.0, 19.0, span, 0.4, VALUE_RANGES.BODY_FAT);
+ // Sleep duration in MINUTES (the SLEEP_DURATION unit): ~7h → ~7h45m
+ const sleepMin = randomWalk(420, 465, span, 25, VALUE_RANGES.SLEEP_DURATION);
+ // Steps: 6500 → a solid ~9000
+ const steps = randomWalk(6500, 9000, span, 1500);
- for (let i = 0; i < days; i++) {
+ for (let i = 0; i < span; i++) {
const date = daysAgo(days - i);
// Weight (daily)
@@ -167,13 +218,20 @@ async function seed() {
[cuid(), userId, Math.round(diaBP[i]), date],
);
- // Pulse (daily)
+ // Spot pulse (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(pulse[i]), date],
);
+ // Resting heart rate (daily) — scored by the resting-pulse tile
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'RESTING_HEART_RATE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
+ [cuid(), userId, Math.round(restingHr[i]), date],
+ );
+
// Body fat (every 2-3 days)
if (i % 2 === 0 || Math.random() > 0.5) {
await client.query(
@@ -183,12 +241,19 @@ async function seed() {
);
}
- // Sleep (daily)
- await client.query(
- `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
- VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`,
- [cuid(), userId, Math.round(sleep[i] * 10) / 10, date],
- );
+ // Sleep duration (daily, in minutes). This single aggregate row keeps
+ // the 90-day sleep series continuous for the trend chart. The most
+ // recent seven nights are instead written as per-stage breakdowns below
+ // (one row per stage); skip the aggregate for those so a night is not
+ // double-counted (aggregate + summed stages).
+ const nightsAgo = days - i;
+ if (nightsAgo >= 7) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'MANUAL', $4, $4, $4)`,
+ [cuid(), userId, Math.round(sleepMin[i]), date],
+ );
+ }
// Steps (daily)
await client.query(
@@ -198,6 +263,35 @@ async function seed() {
);
}
+ // ── Per-stage sleep nights (last 7 nights, incl. last night) ──
+ // One SLEEP_DURATION row per stage, all stamped at the same wake instant,
+ // mirroring how a HealthKit night lands (AWAKE / REM / CORE / DEEP, with
+ // the legacy aggregate kept above). The reader sums the stages for the
+ // night's total. Durations target a healthy ~7.5 h with a normal stage
+ // mix (~55% core, ~22% REM, ~18% deep, ~5% awake). Source APPLE_HEALTH
+ // with device_type 'watch' so the night reads as device-tracked rather
+ // than a manual estimate.
+ console.log("Creating per-stage sleep nights (7 nights)...");
+ const sleepStages: Array<{ stage: string; fraction: number }> = [
+ { stage: "AWAKE", fraction: 0.05 },
+ { stage: "REM", fraction: 0.22 },
+ { stage: "CORE", fraction: 0.55 },
+ { stage: "DEEP", fraction: 0.18 },
+ ];
+ for (let n = 1; n <= 7; n++) {
+ // Wake at ~06:50 with small per-night jitter; total ~7.5 h.
+ const wake = daysAgoAt(n - 1, 6, 40 + Math.floor(Math.random() * 25));
+ const totalMin = 440 + Math.floor(Math.random() * 40); // 7h20m – 8h
+ for (const { stage, fraction } of sleepStages) {
+ const stageMin = Math.round(totalMin * fraction);
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, sleep_stage, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'APPLE_HEALTH', $4, 'watch', $5, $5, $5)`,
+ [cuid(), userId, stageMin, stage, wake],
+ );
+ }
+ }
+
// ── Medications ───────────────────────────
console.log("Creating medications...");
@@ -219,7 +313,7 @@ async function seed() {
const med2Id = cuid();
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
- VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`,
+ VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $3)`,
[med2Id, userId, daysAgo(90)],
);
const sched2Id = cuid();
@@ -244,13 +338,17 @@ async function seed() {
);
// ── Medication Intake Events ──────────────
+ // High compliance with no overdue doses. Historical days (i < days) carry
+ // a taken/skip row at ~95%+ adherence. TODAY is handled separately below
+ // so the dashboard always reads on-track (morning doses taken, the
+ // evening dose left as a not-yet-due pending row — never a miss).
console.log("Creating intake events (90 days)...");
for (let i = 0; i < days; i++) {
const date = daysAgo(days - i);
- // Ramipril: 96% compliance (miss ~1 every 25 days)
- const takeMed1 = Math.random() > 0.04;
+ // Ramipril: 98% compliance (miss ~1 every 50 days)
+ const takeMed1 = Math.random() > 0.02;
if (takeMed1) {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(Math.random() * 45), 0);
@@ -267,8 +365,8 @@ async function seed() {
);
}
- // Vitamin D3: 92% compliance
- const takeMed2 = Math.random() > 0.08;
+ // Vitamin D3: 95% compliance
+ const takeMed2 = Math.random() > 0.05;
if (takeMed2) {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0);
@@ -285,9 +383,9 @@ async function seed() {
);
}
- // Magnesium: only last 60 days, 88% compliance
+ // Magnesium: only last 60 days, 94% compliance
if (i >= 30) {
- const takeMed3 = Math.random() > 0.12;
+ const takeMed3 = Math.random() > 0.06;
if (takeMed3) {
const takenTime = new Date(date);
takenTime.setHours(20, Math.floor(Math.random() * 60), 0);
@@ -306,6 +404,31 @@ async function seed() {
}
}
+ // ── Today's doses (on-track, no overdue) ──
+ // Morning meds: taken inside their 08:00–10:00 window. Evening Magnesium:
+ // a pending row anchored on tonight's 20:00 slot, never auto-missed — it
+ // reads as "not yet due" regardless of the seed wall-clock, and the
+ // compliance engine excludes a plain pending row from the denominator, so
+ // "today" never shows 0% or an overdue dose.
+ console.log("Creating today's on-track doses...");
+ const todayMorning = daysAgoAt(0, 8, 30);
+ await client.query(
+ `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
+ [cuid(), userId, med1Id, todayMorning],
+ );
+ await client.query(
+ `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
+ [cuid(), userId, med2Id, todayMorning],
+ );
+ const todayEvening = daysAgoAt(0, 20, 0);
+ await client.query(
+ `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_missed, source, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, NULL, false, false, 'WEB', $4, $4)`,
+ [cuid(), userId, med3Id, todayEvening],
+ );
+
// ── Mood Entries ─────────────────────────
console.log("Creating mood entries (90 days)...");
@@ -318,10 +441,11 @@ async function seed() {
["exhausted", "pain"],
];
- // Mood trend: starts around 3, improves to ~4 over time
- const moodTrend = randomWalk(3.0, 4.2, days, 0.8);
+ // Mood trend: starts solid (~3.5) and improves to ~4.4 — flat-or-up, so
+ // the score never dips into an alarming band.
+ const moodTrend = randomWalk(3.5, 4.4, span, 0.7);
- for (let i = 0; i < days; i++) {
+ for (let i = 0; i < span; i++) {
// Skip ~5% of days (forgot to log)
if (Math.random() < 0.05) continue;
@@ -347,6 +471,73 @@ async function seed() {
);
}
+ // ── Vorsorge reminders (preventive care) ──
+ // User-created (origin VORSORGE) checklist reminders with no auto-resolve
+ // measurement target. Both anchored to fire comfortably in the future so
+ // the Vorsorge surface shows sensible upcoming items, not overdue ones.
+ console.log("Creating Vorsorge reminders...");
+ const dentalNextDue = daysAgo(-45); // ~6 weeks out
+ await client.query(
+ `INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at)
+ VALUES ($1, $2, 'Dental check-up', 365, $3, 'VORSORGE', 9, 'Dentist', $4, NOW(), NOW())`,
+ [cuid(), userId, daysAgo(320), dentalNextDue],
+ );
+ const physicalNextDue = daysAgo(-90); // ~3 months out
+ await client.query(
+ `INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at)
+ VALUES ($1, $2, 'Annual physical', 365, $3, 'VORSORGE', 9, 'Family doctor', $4, NOW(), NOW())`,
+ [cuid(), userId, daysAgo(275), physicalNextDue],
+ );
+
+ // ── Lab panel (biomarkers in healthy ranges) ──
+ // One grouped panel taken ~10 days ago, every analyte comfortably inside
+ // its reference range, so the labs surface reads clean.
+ console.log("Creating lab panel...");
+ const panelTakenAt = daysAgo(10);
+ const labResults: Array<{
+ analyte: string;
+ value: number;
+ unit: string;
+ low: number;
+ high: number;
+ }> = [
+ { analyte: "Total Cholesterol", value: 178, unit: "mg/dL", low: 0, high: 200 },
+ { analyte: "LDL", value: 98, unit: "mg/dL", low: 0, high: 130 },
+ { analyte: "HDL", value: 58, unit: "mg/dL", low: 40, high: 100 },
+ { analyte: "Triglycerides", value: 92, unit: "mg/dL", low: 0, high: 150 },
+ { analyte: "HbA1c", value: 5.2, unit: "%", low: 4.0, high: 5.6 },
+ { analyte: "Fasting Glucose", value: 89, unit: "mg/dL", low: 70, high: 99 },
+ { analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 },
+ { analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 },
+ { analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 },
+ ];
+ for (const lab of labResults) {
+ await client.query(
+ `INSERT INTO lab_results (id, user_id, panel, analyte, value, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
+ VALUES ($1, $2, 'Annual blood panel', $3, $4, $5, $6, $7, $8, 'MANUAL', NOW(), NOW())`,
+ [
+ cuid(),
+ userId,
+ lab.analyte,
+ lab.value,
+ lab.unit,
+ lab.low,
+ lab.high,
+ panelTakenAt,
+ ],
+ );
+ }
+
+ // ── Resolved illness episode ──────────────
+ // A short cold ~7 weeks ago, fully recovered, so the illness journal shows
+ // a clean "resolved" entry rather than an active sickness.
+ console.log("Creating resolved illness episode...");
+ await client.query(
+ `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, created_at, updated_at)
+ VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, NOW(), NOW())`,
+ [cuid(), userId, daysAgo(49), daysAgo(43)],
+ );
+
// ── Achievements ─────────────────────────
console.log("Creating achievements...");
@@ -374,6 +565,64 @@ async function seed() {
);
}
+ // ── Coach sample conversation ─────────────
+ // A short, encrypted two-turn exchange so the Coach surface shows real
+ // content. Messages use the same AES-256-GCM Bytes codec the app writes
+ // with (encryptToBytes → encrypt() under ENCRYPTION_KEYS), so they decrypt
+ // exactly like a live conversation — no faked / plaintext rows.
+ console.log("Creating Coach sample conversation...");
+ const convoId = cuid();
+ const convoStart = daysAgo(3);
+ await client.query(
+ `INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $4)`,
+ [convoId, userId, "Blood pressure trend", convoStart],
+ );
+
+ const coachTurns: Array<{
+ role: string;
+ content: string;
+ metricSourceJson?: string;
+ }> = [
+ {
+ role: "user",
+ content: "How has my blood pressure been trending lately?",
+ },
+ {
+ role: "assistant",
+ content:
+ "Your blood pressure has been settling nicely. Over the last few " +
+ "weeks the morning readings have drifted into the optimal band, and " +
+ "your resting heart rate is sitting in a comfortable range too. " +
+ "Keeping up the steady sleep and daily movement looks like it is " +
+ "paying off — worth staying consistent with the morning measurements " +
+ "so the trend stays easy to read.",
+ metricSourceJson: JSON.stringify({
+ window: "last30days",
+ metrics: ["bloodPressure", "restingHeartRate", "sleep"],
+ }),
+ },
+ ];
+ for (let t = 0; t < coachTurns.length; t++) {
+ const turn = coachTurns[t];
+ const ts = new Date(convoStart.getTime() + t * 60_000);
+ const encrypted = Buffer.from(encryptToBytes(turn.content));
+ await client.query(
+ `INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ [
+ cuid(),
+ convoId,
+ turn.role,
+ encrypted,
+ turn.metricSourceJson ?? null,
+ turn.role === "assistant" ? aiProvider : null,
+ turn.role === "assistant" ? "demo" : null,
+ ts,
+ ],
+ );
+ }
+
// ── App Settings ─────────────────────────
console.log("Creating app settings...");
await client.query(
@@ -396,9 +645,14 @@ async function seed() {
await client.query("COMMIT");
console.log("\nDemo data seeded successfully!");
console.log(` User: demo / demo123demo123`);
- console.log(` Measurements: ${days * 6} entries (7 types, 90 days)`);
- console.log(` Medications: 3 (with ${days * 2 + 60} intake events)`);
- console.log(` Mood: ~${Math.round(days * 0.95)} entries`);
+ console.log(` Measurements: ~${span * 8} entries (8 types, today included)`);
+ console.log(` Sleep: 7 per-stage nights (4 stages each) + daily aggregate`);
+ console.log(` Medications: 3 (high compliance, today on-track)`);
+ console.log(` Mood: ~${Math.round(span * 0.95)} entries`);
+ console.log(` Vorsorge reminders: 2 (dental, annual physical)`);
+ console.log(` Lab panel: ${labResults.length} biomarkers`);
+ console.log(` Illness: 1 resolved episode`);
+ console.log(` Coach: 1 conversation (${coachTurns.length} messages)`);
console.log(` Achievements: ${achievements.length}`);
} catch (err) {
await client.query("ROLLBACK");
From a4b7ae596607556dcab02ffc35a0061d492fe6ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:03:59 +0200
Subject: [PATCH 28/71] feat(apns): forward reminderId to iOS on measurement
reminders
The measurement-reminder push already carries the reminder id in its
metadata, but the iOS allowlist stripped it before it reached the device.
Forward the key so a foreground or handled notification can post the
completion directly instead of routing through the capture deep link.
---
.../senders/__tests__/apns.test.ts | 19 +++++++++++++++++++
src/lib/notifications/senders/apns.ts | 4 ++++
2 files changed, 23 insertions(+)
diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts
index 72b3072ed..d73764379 100644
--- a/src/lib/notifications/senders/__tests__/apns.test.ts
+++ b/src/lib/notifications/senders/__tests__/apns.test.ts
@@ -590,6 +590,25 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.payload.eventType).toBe("MEDICATION_REMINDER");
});
+ it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => {
+ // The iOS "Erledigt" action POSTs the reminder completion directly
+ // from a foreground / handled notification, so the reminder's id must
+ // ride the APNs payload; channel-specific extras stay stripped.
+ vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
+ { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
+ ] as never);
+ sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
+ await sendViaApns("u-1", {
+ title: "t",
+ message: "m",
+ eventType: "MEASUREMENT_REMINDER",
+ metadata: { reminderId: "rem-1", replyMarkup: { inline: true } },
+ });
+ const note = sendMock.mock.calls[0][0];
+ expect(note.payload.reminderId).toBe("rem-1");
+ expect(note.payload.replyMarkup).toBeUndefined();
+ });
+
it("forwards MOOD_REMINDER eventType as aps.category too", async () => {
// SR-2 contract: the mood-reminder daily job emits this event-type;
// the iOS app registers a `MOOD_REMINDER` category whose only
diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts
index 1e50797b9..56e01c0b3 100644
--- a/src/lib/notifications/senders/apns.ts
+++ b/src/lib/notifications/senders/apns.ts
@@ -904,6 +904,10 @@ const IOS_METADATA_ALLOWLIST = new Set([
"url",
"runwayDays",
"thresholdDays",
+ // v1.18.7 — MEASUREMENT_REMINDER carries the reminder's id so a
+ // foreground / handled notification can POST the completion directly
+ // instead of routing through the capture deep link.
+ "reminderId",
]);
function pickIosMetadata(
From 853a1cd301d72484f4ec6be14be9a2f35eb17b07 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:05:19 +0200
Subject: [PATCH 29/71] feat(insights): vary daily-briefing prose on unchanged
data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The content-hash gate re-stamps the prior text whenever the snapshot is
unchanged, so the "daily" briefing read byte-stable day-over-day. Decouple
phrasing freshness from data freshness for the dailyBriefing.paragraph
alone: on a hash-unchanged hit, re-roll just the paragraph once per
calendar day at a higher, seedless temperature, keeping every structured
finding verbatim from the cached payload.
A new `insightsBriefingRerollDate` column gates it to at most one extra
call per day, and a re-roll miss still stamps the day so a failing
provider cannot be re-driven on every page-open. The hash gate stays
fully intact for the status cards (reference assessments — monotony is
fine).
---
.../migration.sql | 5 +
prisma/schema.prisma | 7 +
.../comprehensive-briefing-reroll.test.ts | 182 ++++++++++++++++
src/lib/insights/comprehensive-generate.ts | 201 +++++++++++++++---
4 files changed, 365 insertions(+), 30 deletions(-)
create mode 100644 prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql
create mode 100644 src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts
diff --git a/prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql b/prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql
new file mode 100644
index 000000000..08148180a
--- /dev/null
+++ b/prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql
@@ -0,0 +1,5 @@
+-- v1.18.7 — track the daily-briefing phrasing re-roll date.
+-- The content-hash gate keeps the briefing findings byte-stable on
+-- unchanged data; this column lets the paragraph be re-rolled once per
+-- calendar day (UTC) for phrasing variety without re-paying for findings.
+ALTER TABLE "users" ADD COLUMN "insights_briefing_reroll_date" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c2f620a3c..75745cf83 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -71,6 +71,13 @@ model User {
// so a persistently broken provider chain cannot be re-driven into a
// full generation attempt by every page-open.
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
+ // v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll.
+ // The content-hash gate keeps the FINDINGS byte-stable on unchanged data,
+ // but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a
+ // higher, seedless temperature so the prose reads fresh day-over-day
+ // without re-paying for the findings. This stamps the day a re-roll ran so
+ // the gate stays at most one extra call per day.
+ insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts
new file mode 100644
index 000000000..1357a76d2
--- /dev/null
+++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts
@@ -0,0 +1,182 @@
+/**
+ * v1.18.7 (MEDIUM-3) — daily-briefing phrasing re-roll on unchanged data.
+ *
+ * The content-hash gate keeps the FINDINGS byte-stable when the snapshot is
+ * unchanged, but the daily-briefing PARAGRAPH is re-rolled at most once per
+ * calendar day at a higher, seedless temperature so the prose reads fresh.
+ * These tests pin: the re-roll fires when today's date has not been stamped,
+ * it preserves every cached field except the paragraph, it is seedless +
+ * higher-temperature, and it does NOT fire a second time the same day.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const findUnique = vi.fn();
+const userUpdate = vi.fn();
+const resolveProviderChain = vi.fn();
+const resolveProvider = vi.fn();
+const runRawCompletionWithFallback = vi.fn();
+const extractFeatures = vi.fn();
+const invalidateUserInsights = vi.fn();
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ user: {
+ findUnique: (...a: unknown[]) => findUnique(...a),
+ update: (...a: unknown[]) => userUpdate(...a),
+ },
+ auditLog: { deleteMany: vi.fn() },
+ },
+}));
+vi.mock("@/lib/ai/provider", () => ({
+ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
+ resolveProvider: (...a: unknown[]) => resolveProvider(...a),
+}));
+vi.mock("@/lib/ai/provider-runner", () => ({
+ AllProvidersFailedError: class extends Error {},
+ runRawCompletionWithFallback: (...a: unknown[]) =>
+ runRawCompletionWithFallback(...a),
+}));
+vi.mock("@/lib/insights/features", () => ({
+ FeaturesPayloadTooLargeError: class extends Error {
+ sizeBytes = 0;
+ },
+ extractFeatures: (...a: unknown[]) => extractFeatures(...a),
+}));
+vi.mock("@/lib/insights/glp1-plateau", () => ({
+ detectGlp1Plateau: vi.fn(async () => null),
+ buildGlp1PlateauPrompt: vi.fn(() => ""),
+}));
+vi.mock("@/lib/ai/coach/about-me", () => ({
+ getSelfContextTextForUser: vi.fn(async () => null),
+ buildAboutMeInsightBlock: vi.fn(() => ""),
+}));
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserInsights: (...a: unknown[]) => invalidateUserInsights(...a),
+}));
+
+import { generateComprehensiveInsight } from "../comprehensive-generate";
+import { hashInsightSnapshot } from "../snapshot-hash";
+import { compactSections } from "@/lib/ai/prompts/compact-sections";
+
+const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } };
+const FEATURES_HASH = hashInsightSnapshot({
+ features: compactSections(FEATURES as unknown as Record),
+ aboutMe: null,
+ comparisonBaseline: "none",
+ generationLocale: "de",
+});
+
+const todayKey = new Date().toISOString().slice(0, 10);
+
+/** A cached payload carrying a real briefing paragraph + sibling findings. */
+const CACHED = JSON.stringify({
+ summary: "stable summary",
+ dailyBriefing: {
+ paragraph: "Yesterday's phrasing.",
+ keyFindings: [{ headline: "BP steady", tone: "good" }],
+ },
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ resolveProviderChain.mockResolvedValue([
+ { providerType: "openai", instance: {} },
+ ]);
+ resolveProvider.mockResolvedValue({ type: "none" });
+ extractFeatures.mockResolvedValue(FEATURES);
+ userUpdate.mockResolvedValue({});
+});
+
+describe("daily-briefing re-roll on unchanged data", () => {
+ it("re-rolls only the paragraph, preserves findings, seedless + 0.6 temp", async () => {
+ findUnique.mockResolvedValue({
+ insightsPrivacyMode: "aggregated",
+ insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
+ insightsCachedText: CACHED,
+ insightsExcludeMetrics: [],
+ insightsSnapshotHash: FEATURES_HASH,
+ insightsBriefingRerollDate: null, // never re-rolled today
+ });
+ runRawCompletionWithFallback.mockResolvedValue({
+ result: {
+ content: JSON.stringify({
+ dailyBriefing: { paragraph: "Today's fresh phrasing." },
+ }),
+ tokensUsed: 50,
+ providerType: "openai",
+ model: "m",
+ },
+ workingProvider: { providerType: "openai" },
+ fallbackHops: [],
+ });
+
+ const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
+
+ expect(outcome).toEqual({ status: "rerolled", providerType: "openai" });
+
+ // Seedless + higher temperature.
+ const params = runRawCompletionWithFallback.mock.calls[0][0].params;
+ expect(params.temperature).toBe(0.6);
+ expect(params.seed).toBeUndefined();
+
+ // The cache write swaps only the paragraph; findings + summary preserved.
+ const write = userUpdate.mock.calls.find(
+ (c) =>
+ (c[0] as { data: Record }).data.insightsCachedText !==
+ undefined,
+ );
+ expect(write).toBeTruthy();
+ const data = (write![0] as { data: Record }).data;
+ const stored = JSON.parse(data.insightsCachedText as string);
+ expect(stored.dailyBriefing.paragraph).toBe("Today's fresh phrasing.");
+ expect(stored.dailyBriefing.keyFindings).toEqual([
+ { headline: "BP steady", tone: "good" },
+ ]);
+ expect(stored.summary).toBe("stable summary");
+ expect(data.insightsBriefingRerollDate).toBe(todayKey);
+ expect(invalidateUserInsights).toHaveBeenCalledWith("u1");
+ });
+
+ it("does NOT re-roll a second time the same calendar day", async () => {
+ findUnique.mockResolvedValue({
+ insightsPrivacyMode: "aggregated",
+ insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
+ insightsCachedText: CACHED,
+ insightsExcludeMetrics: [],
+ insightsSnapshotHash: FEATURES_HASH,
+ insightsBriefingRerollDate: todayKey, // already re-rolled today
+ });
+
+ const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
+
+ expect(outcome).toEqual({ status: "unchanged" });
+ expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
+ });
+
+ it("falls back to a plain refresh (still stamps the day) when the re-roll JSON is unusable", async () => {
+ findUnique.mockResolvedValue({
+ insightsPrivacyMode: "aggregated",
+ insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
+ insightsCachedText: CACHED,
+ insightsExcludeMetrics: [],
+ insightsSnapshotHash: FEATURES_HASH,
+ insightsBriefingRerollDate: null,
+ });
+ runRawCompletionWithFallback.mockResolvedValue({
+ result: { content: "not json at all", tokensUsed: 5, model: "m" },
+ workingProvider: { providerType: "openai" },
+ fallbackHops: [],
+ });
+
+ const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
+
+ expect(outcome).toEqual({ status: "unchanged" });
+ // The day is stamped so a broken provider can't be re-driven all day.
+ const write = userUpdate.mock.calls.find(
+ (c) =>
+ (c[0] as { data: Record }).data
+ .insightsBriefingRerollDate !== undefined,
+ );
+ expect(write).toBeTruthy();
+ });
+});
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index fab7310de..97c1b2ea9 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -133,9 +133,107 @@ export type GenerateOutcome =
* (GET read, nightly discovery) treat the cache as current.
*/
| { status: "unchanged" }
+ /**
+ * v1.18.7 — the snapshot was unchanged (findings byte-stable) but the
+ * daily-briefing PARAGRAPH was re-rolled at a higher, seedless
+ * temperature for phrasing variety. At most one re-roll per calendar day.
+ */
+ | { status: "rerolled"; providerType: string }
| { status: "skipped"; reason: "no-provider" | "no-consent" }
| { status: "failed"; reason: string };
+/** Higher, seedless temperature for the daily-briefing phrasing re-roll. */
+const BRIEFING_REROLL_TEMPERATURE = 0.6;
+
+/** UTC YYYY-MM-DD calendar-day key gating the once-per-day briefing re-roll. */
+function buildRerollDateKey(at: Date = new Date()): string {
+ return at.toISOString().slice(0, 10);
+}
+
+/**
+ * Re-roll ONLY the daily-briefing paragraph on a hash-unchanged hit.
+ *
+ * Runs the same comprehensive prompt at a higher, seedless temperature, then
+ * keeps the cached payload verbatim and swaps in just the freshly-generated
+ * `dailyBriefing.paragraph`. So the structured findings stay byte-stable
+ * (reference assessments, monotony is fine) while the prose the user reads at
+ * the top of /insights varies day-over-day. Returns null on any miss (no
+ * provider success, invalid JSON, no cached/new paragraph) so the caller
+ * falls back to the plain timestamp refresh.
+ */
+async function rerollBriefingParagraph(args: {
+ userId: string;
+ chain: Awaited>;
+ systemPrompt: string;
+ userPrompt: string;
+ cachedText: string;
+ locale: "de" | "en";
+}): Promise<{ text: string; providerType: string } | null> {
+ let cached: Record;
+ try {
+ cached = JSON.parse(args.cachedText) as Record;
+ } catch {
+ return null;
+ }
+ const cachedBriefing = cached.dailyBriefing;
+ // Nothing to re-roll when the cached payload carries no briefing paragraph.
+ if (
+ cachedBriefing === null ||
+ typeof cachedBriefing !== "object" ||
+ typeof (cachedBriefing as { paragraph?: unknown }).paragraph !== "string"
+ ) {
+ return null;
+ }
+
+ let result;
+ let providerType: string;
+ try {
+ const fallback = await runRawCompletionWithFallback({
+ userId: args.userId,
+ providers: args.chain,
+ params: {
+ systemPrompt: args.systemPrompt,
+ userPrompt: args.userPrompt,
+ // Seedless on purpose: the seed would pin the same phrasing, which is
+ // exactly what we are varying. Higher temperature for prose variety.
+ temperature: BRIEFING_REROLL_TEMPERATURE,
+ maxTokens: AI_BUDGETS.comprehensive.maxTokens,
+ responseFormat: "json",
+ },
+ });
+ result = fallback.result;
+ providerType = fallback.workingProvider.providerType;
+ } catch {
+ return null;
+ }
+
+ let parsed: Record;
+ try {
+ parsed = JSON.parse(stripJsonFences(result.content)) as Record<
+ string,
+ unknown
+ >;
+ } catch {
+ return null;
+ }
+ const freshBriefing = parsed.dailyBriefing;
+ const freshParagraph =
+ freshBriefing && typeof freshBriefing === "object"
+ ? (freshBriefing as { paragraph?: unknown }).paragraph
+ : undefined;
+ if (typeof freshParagraph !== "string" || freshParagraph.trim().length === 0) {
+ return null;
+ }
+
+ // Keep the cached payload verbatim; swap in only the fresh paragraph.
+ const mergedBriefing = {
+ ...(cachedBriefing as Record),
+ paragraph: freshParagraph,
+ };
+ const merged = { ...cached, dailyBriefing: mergedBriefing };
+ return { text: JSON.stringify(merged), providerType };
+}
+
/**
* Comparison-snapshot builder — shared with the on-demand route. Returns
* null when the user's comparison toggle is off (most users), so the
@@ -532,6 +630,7 @@ export async function generateComprehensiveInsight(
insightsCachedText: true,
insightsExcludeMetrics: true,
insightsSnapshotHash: true,
+ insightsBriefingRerollDate: true,
// The comparison-baseline setting joins the snapshot fingerprint
// (see the hash construction below), so it must be readable BEFORE
// the gate — the comparison snapshot itself is only built after.
@@ -643,20 +742,15 @@ export async function generateComprehensiveInsight(
comparisonBaseline,
generationLocale: locale,
});
- if (
- dbUser?.insightsCachedText &&
- dbUser.insightsSnapshotHash === snapshotHash
- ) {
- await prisma.user.update({
- where: { id: userId },
- data: { insightsCachedAt: new Date() },
- });
- annotate({
- action: { name: "insights.generate.skipped_unchanged" },
- meta: { locale },
- });
- return { status: "unchanged" };
- }
+ // v1.12.7 (B5) — the curated SOURCES block for the metrics present, shared
+ // by both the full generation below and the unchanged-data re-roll.
+ const referenceMetrics = metricsFromPresentSections({
+ bloodPressure: features.bloodPressure != null,
+ weight: features.weight != null,
+ pulse: features.pulse != null,
+ mood: features.mood != null,
+ medication: (features.medications?.length ?? 0) > 0,
+ });
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
const plateauContext = await detectGlp1Plateau(userId);
@@ -669,25 +763,72 @@ export async function generateComprehensiveInsight(
if (plateauContext) {
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
- // v1.15.20 — fold the user-authored "about me" self-description
- // (Settings → AI) into the nightly briefing exactly like the
- // on-demand route. Null (no text / undecryptable) costs nothing.
- // (Fetched above the hash gate — it is part of the fingerprint.)
if (aboutMe) {
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
}
+ const systemPrompt = buildSystemPromptWithReferences(locale, referenceMetrics);
- // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
- // this generation actually carries, so a normative claim ("target < 140/90")
- // can cite a real `referenceId` the schema + UI footnote already support.
- // Returns the plain prompt unchanged when no applicable metric is present.
- const referenceMetrics = metricsFromPresentSections({
- bloodPressure: features.bloodPressure != null,
- weight: features.weight != null,
- pulse: features.pulse != null,
- mood: features.mood != null,
- medication: (features.medications?.length ?? 0) > 0,
- });
+ if (
+ dbUser?.insightsCachedText &&
+ dbUser.insightsSnapshotHash === snapshotHash
+ ) {
+ // v1.18.7 (MEDIUM-3) — findings are byte-stable, but re-roll the daily-
+ // briefing PARAGRAPH once per calendar day so the prose reads fresh
+ // day-over-day. Gated on `insightsBriefingRerollDate` so it costs at
+ // most one extra call/day; the higher, seedless temperature varies the
+ // phrasing while the structured findings are preserved verbatim from the
+ // cached payload. Any miss falls through to the plain timestamp refresh.
+ const todayKey = buildRerollDateKey();
+ if (dbUser.insightsBriefingRerollDate !== todayKey) {
+ const rerolled = await rerollBriefingParagraph({
+ userId,
+ chain,
+ systemPrompt,
+ userPrompt,
+ cachedText: dbUser.insightsCachedText,
+ locale,
+ });
+ if (rerolled) {
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ insightsCachedAt: new Date(),
+ insightsCachedText: rerolled.text,
+ insightsBriefingRerollDate: todayKey,
+ },
+ });
+ invalidateUserInsights(userId);
+ annotate({
+ action: { name: "insights.generate.briefing_rerolled" },
+ meta: { locale, provider: rerolled.providerType },
+ });
+ return { status: "rerolled", providerType: rerolled.providerType };
+ }
+ // A re-roll miss still stamps the day so a persistently failing
+ // provider cannot be re-driven on every page-open; the next day retries.
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ insightsCachedAt: new Date(),
+ insightsBriefingRerollDate: todayKey,
+ },
+ });
+ annotate({
+ action: { name: "insights.generate.skipped_unchanged" },
+ meta: { locale, rerollAttempted: true },
+ });
+ return { status: "unchanged" };
+ }
+ await prisma.user.update({
+ where: { id: userId },
+ data: { insightsCachedAt: new Date() },
+ });
+ annotate({
+ action: { name: "insights.generate.skipped_unchanged" },
+ meta: { locale },
+ });
+ return { status: "unchanged" };
+ }
let result;
let workingProviderType: string;
@@ -696,7 +837,7 @@ export async function generateComprehensiveInsight(
userId,
providers: chain,
params: {
- systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics),
+ systemPrompt,
userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
From 43d9610f0361fcdc563b3e156c5c20ed24cd33a9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:06:25 +0200
Subject: [PATCH 30/71] refactor(settings): fold Labs, Vorsorge, and Illness
into the shell
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Promote the three per-module customise surfaces from standalone
ModuleSettingsFrame pages to first-class Settings sections. They now
render inside the Settings shell — same left nav, heading, and chrome as
account / ai / integrations / notifications — instead of their own
back-button-and-title frame outside it.
Add labs, illness, and vorsorge to the slug registry (after mood) and to
SETTINGS_SECTIONS with FlaskConical / Thermometer / CalendarCheck icons.
labs and illness gate on their toggleable modules; vorsorge has no
module, so its entry is always shown. Three thin section components wrap
the existing LabsSettings / IllnessSettings / VorsorgeSettings content,
mirroring MedicationsSection / CoachSection.
Delete the three standalone /settings/{labs,vorsorge,illness} routes so
the wrench deep links resolve through the dynamic [section] route, and
drop ModuleSettingsFrame now that nothing renders it. Add the section
title / subtitle / description keys across all six locales and remove the
dead per-module *.settings.* blocks the old pages consumed.
---
messages/de.json | 30 ++++++-------
messages/en.json | 30 ++++++-------
messages/es.json | 30 ++++++-------
messages/fr.json | 30 ++++++-------
messages/it.json | 30 ++++++-------
messages/pl.json | 30 ++++++-------
src/app/settings/[section]/page.tsx | 6 +++
src/app/settings/illness/page.tsx | 39 -----------------
src/app/settings/labs/page.tsx | 39 -----------------
src/app/settings/vorsorge/page.tsx | 43 -------------------
.../module-list/module-settings-frame.tsx | 42 ------------------
.../__tests__/settings-shell.test.tsx | 6 +++
src/components/settings/illness-section.tsx | 21 +++++++++
src/components/settings/labs-section.tsx | 22 ++++++++++
.../settings/section-placeholder.tsx | 6 +++
src/components/settings/section-slugs.ts | 13 ++++++
src/components/settings/settings-shell.tsx | 31 +++++++++++++
src/components/settings/vorsorge-section.tsx | 22 ++++++++++
18 files changed, 217 insertions(+), 253 deletions(-)
delete mode 100644 src/app/settings/illness/page.tsx
delete mode 100644 src/app/settings/labs/page.tsx
delete mode 100644 src/app/settings/vorsorge/page.tsx
delete mode 100644 src/components/module-list/module-settings-frame.tsx
create mode 100644 src/components/settings/illness-section.tsx
create mode 100644 src/components/settings/labs-section.tsx
create mode 100644 src/components/settings/vorsorge-section.tsx
diff --git a/messages/de.json b/messages/de.json
index 3ce0ec929..a70f807a6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4025,6 +4025,21 @@
"description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl.",
"subtitle": "Organisiere deine Stimmungs-Tags, Gruppen und Auswahlreihenfolge."
},
+ "labs": {
+ "title": "Laborwerte",
+ "description": "Ansicht, Sortierung und Biomarker deiner Laborwerte.",
+ "subtitle": "Ansicht, Sortierung und Biomarker verwalten."
+ },
+ "illness": {
+ "title": "Krankheitstagebuch",
+ "description": "Ansicht und Reihenfolge deiner Krankheitsepisoden.",
+ "subtitle": "Ansicht und Reihenfolge der Episoden verwalten."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "description": "Ansicht, Reihenfolge und einzelne Vorsorge-Erinnerungen.",
+ "subtitle": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten."
+ },
"modules": {
"title": "Module",
"saved": "Gespeichert.",
@@ -6407,11 +6422,6 @@
"onTrack": "Im Plan",
"due": "Jetzt fällig"
},
- "settings": {
- "title": "Vorsorge anpassen",
- "description": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten.",
- "back": "Zurück zur Vorsorge"
- },
"capture": {
"title": "Messwert erfassen",
"description": "Wert eintragen; die Erinnerung wird als erledigt markiert."
@@ -6461,11 +6471,6 @@
"deleteError": "Wert konnte nicht gelöscht werden",
"loadError": "Laborwerte konnten nicht geladen werden",
"customize": "Laborwerte anpassen",
- "settings": {
- "title": "Laborwerte anpassen",
- "description": "Ansicht, Sortierung und Biomarker verwalten.",
- "back": "Zurück zu den Laborwerten"
- },
"sort": {
"heading": "Sortierung",
"label": "Reihenfolge der Werte",
@@ -6664,11 +6669,6 @@
"title": "Krankheitstagebuch",
"subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
"customize": "Krankheitstagebuch anpassen",
- "settings": {
- "title": "Krankheitstagebuch anpassen",
- "description": "Ansicht und Reihenfolge der Episoden verwalten.",
- "back": "Zurück zum Krankheitstagebuch"
- },
"newEpisode": "Neue Episode",
"logDay": "Tag erfassen",
"markRecovered": "Als genesen markieren",
diff --git a/messages/en.json b/messages/en.json
index 535db02d5..551a46efe 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4025,6 +4025,21 @@
"description": "Groups, tags, and archived tags for the mood picker.",
"subtitle": "Organize your mood tags, groups, and picker order."
},
+ "labs": {
+ "title": "Labs",
+ "description": "View, sort order, and biomarkers for your labs.",
+ "subtitle": "Manage the view, sort order, and biomarkers."
+ },
+ "illness": {
+ "title": "Illness journal",
+ "description": "View and order of your illness episodes.",
+ "subtitle": "Manage the view and order of episodes."
+ },
+ "vorsorge": {
+ "title": "Preventive care",
+ "description": "View, order, and individual preventive-care reminders.",
+ "subtitle": "Manage the view, order, and individual reminders."
+ },
"modules": {
"title": "Modules",
"saved": "Saved.",
@@ -6407,11 +6422,6 @@
"onTrack": "On track",
"due": "Due now"
},
- "settings": {
- "title": "Customize preventive care",
- "description": "Manage the view, order, and individual reminders.",
- "back": "Back to preventive care"
- },
"capture": {
"title": "Log measurement",
"description": "Enter the reading; the reminder is marked done."
@@ -6461,11 +6471,6 @@
"deleteError": "Could not delete the result",
"loadError": "Could not load lab results",
"customize": "Customize labs",
- "settings": {
- "title": "Customize labs",
- "description": "Manage the view, sort order, and biomarkers.",
- "back": "Back to labs"
- },
"sort": {
"heading": "Sort order",
"label": "Order of values",
@@ -6664,11 +6669,6 @@
"title": "Illness journal",
"subtitle": "A retrospective log of your illness and condition episodes.",
"customize": "Customize illness journal",
- "settings": {
- "title": "Customize illness journal",
- "description": "Manage the view and order of episodes.",
- "back": "Back to the illness journal"
- },
"newEpisode": "New episode",
"logDay": "Log a day",
"markRecovered": "Mark recovered",
diff --git a/messages/es.json b/messages/es.json
index 15ed8347c..c4c7cc722 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4025,6 +4025,21 @@
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.",
"subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector."
},
+ "labs": {
+ "title": "Laboratorio",
+ "description": "Vista, orden y biomarcadores de tu laboratorio.",
+ "subtitle": "Gestiona la vista, el orden y los biomarcadores."
+ },
+ "illness": {
+ "title": "Diario de enfermedad",
+ "description": "Vista y orden de tus episodios de enfermedad.",
+ "subtitle": "Gestiona la vista y el orden de los episodios."
+ },
+ "vorsorge": {
+ "title": "Prevención",
+ "description": "Vista, orden y recordatorios de prevención individuales.",
+ "subtitle": "Gestiona la vista, el orden y los recordatorios individuales."
+ },
"modules": {
"title": "Módulos",
"saved": "Guardado.",
@@ -6407,11 +6422,6 @@
"onTrack": "Al día",
"due": "Toca ahora"
},
- "settings": {
- "title": "Personalizar prevención",
- "description": "Gestiona la vista, el orden y los recordatorios individuales.",
- "back": "Volver a prevención"
- },
"capture": {
"title": "Registrar medición",
"description": "Introduce el valor; el recordatorio se marca como hecho."
@@ -6461,11 +6471,6 @@
"deleteError": "No se pudo eliminar el resultado",
"loadError": "No se pudieron cargar los resultados",
"customize": "Personalizar laboratorio",
- "settings": {
- "title": "Personalizar laboratorio",
- "description": "Gestiona la vista, el orden y los biomarcadores.",
- "back": "Volver al laboratorio"
- },
"sort": {
"heading": "Orden",
"label": "Orden de los valores",
@@ -6664,11 +6669,6 @@
"title": "Diario de enfermedad",
"subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
"customize": "Personalizar diario de enfermedad",
- "settings": {
- "title": "Personalizar diario de enfermedad",
- "description": "Gestiona la vista y el orden de los episodios.",
- "back": "Volver al diario de enfermedad"
- },
"newEpisode": "Nuevo episodio",
"logDay": "Registrar un día",
"markRecovered": "Marcar como recuperado",
diff --git a/messages/fr.json b/messages/fr.json
index c2ce0dfc7..532b2cbf9 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4025,6 +4025,21 @@
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.",
"subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur."
},
+ "labs": {
+ "title": "Analyses",
+ "description": "Affichage, tri et biomarqueurs de vos analyses.",
+ "subtitle": "Gérez l’affichage, le tri et les biomarqueurs."
+ },
+ "illness": {
+ "title": "Journal de maladie",
+ "description": "Affichage et ordre de vos épisodes de maladie.",
+ "subtitle": "Gérez l’affichage et l’ordre des épisodes."
+ },
+ "vorsorge": {
+ "title": "Prévention",
+ "description": "Affichage, ordre et rappels de prévention individuels.",
+ "subtitle": "Gérez l’affichage, l’ordre et les rappels individuels."
+ },
"modules": {
"title": "Modules",
"saved": "Enregistré.",
@@ -6407,11 +6422,6 @@
"onTrack": "Dans les temps",
"due": "À faire maintenant"
},
- "settings": {
- "title": "Personnaliser la prévention",
- "description": "Gérer l'affichage, l'ordre et les rappels individuels.",
- "back": "Retour à la prévention"
- },
"capture": {
"title": "Enregistrer une mesure",
"description": "Saisissez la valeur ; le rappel est marqué comme fait."
@@ -6461,11 +6471,6 @@
"deleteError": "Impossible de supprimer le résultat",
"loadError": "Impossible de charger les résultats",
"customize": "Personnaliser les analyses",
- "settings": {
- "title": "Personnaliser les analyses",
- "description": "Gérer l'affichage, le tri et les biomarqueurs.",
- "back": "Retour aux analyses"
- },
"sort": {
"heading": "Tri",
"label": "Ordre des valeurs",
@@ -6664,11 +6669,6 @@
"title": "Journal de maladie",
"subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
"customize": "Personnaliser le journal de maladie",
- "settings": {
- "title": "Personnaliser le journal de maladie",
- "description": "Gérer l'affichage et l'ordre des épisodes.",
- "back": "Retour au journal de maladie"
- },
"newEpisode": "Nouvel épisode",
"logDay": "Enregistrer un jour",
"markRecovered": "Marquer comme rétabli",
diff --git a/messages/it.json b/messages/it.json
index 86edcbc3c..0e28f4af2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4025,6 +4025,21 @@
"description": "Gruppi, tag e tag archiviati del selettore dell’umore.",
"subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore."
},
+ "labs": {
+ "title": "Esami",
+ "description": "Vista, ordinamento e biomarcatori dei tuoi esami.",
+ "subtitle": "Gestisci la vista, l’ordinamento e i biomarcatori."
+ },
+ "illness": {
+ "title": "Diario delle malattie",
+ "description": "Vista e ordine dei tuoi episodi di malattia.",
+ "subtitle": "Gestisci la vista e l’ordine degli episodi."
+ },
+ "vorsorge": {
+ "title": "Prevenzione",
+ "description": "Vista, ordine e singoli promemoria di prevenzione.",
+ "subtitle": "Gestisci la vista, l’ordine e i singoli promemoria."
+ },
"modules": {
"title": "Moduli",
"saved": "Salvato.",
@@ -6407,11 +6422,6 @@
"onTrack": "In linea",
"due": "Da fare ora"
},
- "settings": {
- "title": "Personalizza prevenzione",
- "description": "Gestisci la vista, l'ordine e i singoli promemoria.",
- "back": "Torna alla prevenzione"
- },
"capture": {
"title": "Registra misurazione",
"description": "Inserisci il valore; il promemoria viene segnato come fatto."
@@ -6461,11 +6471,6 @@
"deleteError": "Impossibile eliminare il risultato",
"loadError": "Impossibile caricare i risultati",
"customize": "Personalizza esami",
- "settings": {
- "title": "Personalizza esami",
- "description": "Gestisci la vista, l'ordinamento e i biomarcatori.",
- "back": "Torna agli esami"
- },
"sort": {
"heading": "Ordinamento",
"label": "Ordine dei valori",
@@ -6664,11 +6669,6 @@
"title": "Diario delle malattie",
"subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
"customize": "Personalizza diario delle malattie",
- "settings": {
- "title": "Personalizza diario delle malattie",
- "description": "Gestisci la vista e l'ordine degli episodi.",
- "back": "Torna al diario delle malattie"
- },
"newEpisode": "Nuovo episodio",
"logDay": "Registra un giorno",
"markRecovered": "Segna come guarito",
diff --git a/messages/pl.json b/messages/pl.json
index f6a65b1f4..b528246d6 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4025,6 +4025,21 @@
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.",
"subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru."
},
+ "labs": {
+ "title": "Wyniki badań",
+ "description": "Widok, sortowanie i biomarkery Twoich wyników badań.",
+ "subtitle": "Zarządzaj widokiem, sortowaniem i biomarkerami."
+ },
+ "illness": {
+ "title": "Dziennik chorób",
+ "description": "Widok i kolejność Twoich epizodów chorobowych.",
+ "subtitle": "Zarządzaj widokiem i kolejnością epizodów."
+ },
+ "vorsorge": {
+ "title": "Profilaktyka",
+ "description": "Widok, kolejność i poszczególne przypomnienia profilaktyczne.",
+ "subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami."
+ },
"modules": {
"title": "Moduły",
"saved": "Zapisano.",
@@ -6407,11 +6422,6 @@
"onTrack": "Zgodnie z planem",
"due": "Termin teraz"
},
- "settings": {
- "title": "Dostosuj profilaktykę",
- "description": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami.",
- "back": "Powrót do profilaktyki"
- },
"capture": {
"title": "Zapisz pomiar",
"description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane."
@@ -6461,11 +6471,6 @@
"deleteError": "Nie udało się usunąć wyniku",
"loadError": "Nie udało się wczytać wyników",
"customize": "Dostosuj wyniki badań",
- "settings": {
- "title": "Dostosuj wyniki badań",
- "description": "Zarządzaj widokiem, sortowaniem i biomarkerami.",
- "back": "Powrót do wyników badań"
- },
"sort": {
"heading": "Sortowanie",
"label": "Kolejność wartości",
@@ -6664,11 +6669,6 @@
"title": "Dziennik chorób",
"subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
"customize": "Dostosuj dziennik chorób",
- "settings": {
- "title": "Dostosuj dziennik chorób",
- "description": "Zarządzaj widokiem i kolejnością epizodów.",
- "back": "Powrót do dziennika chorób"
- },
"newEpisode": "Nowy epizod",
"logDay": "Zapisz dzień",
"markRecovered": "Oznacz jako wyleczone",
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index dd3e9159d..4b5c1a3f0 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -10,14 +10,17 @@ import { CoachSection } from "@/components/settings/coach-section";
import { DashboardSection } from "@/components/settings/dashboard-section";
import { ExportSection } from "@/components/settings/export-section";
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
+import { IllnessSection } from "@/components/settings/illness-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
+import { LabsSection } from "@/components/settings/labs-section";
import { LayoutSection } from "@/components/settings/layout-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { ModulesSection } from "@/components/settings/modules-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
+import { VorsorgeSection } from "@/components/settings/vorsorge-section";
// v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries
// again (split out of the Integrations sub-tabs).
import { ChannelsSection } from "@/components/settings/channels-section";
@@ -64,6 +67,9 @@ const SECTION_COMPONENTS: Record<
insights: InsightsSection,
medications: MedicationsSection,
mood: MoodSection,
+ labs: LabsSection,
+ illness: IllnessSection,
+ vorsorge: VorsorgeSection,
thresholds: ThresholdsSection,
api: ApiSection,
gesundheitsakte: GesundheitsakteSection,
diff --git a/src/app/settings/illness/page.tsx b/src/app/settings/illness/page.tsx
deleted file mode 100644
index b6b37b890..000000000
--- a/src/app/settings/illness/page.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client";
-
-import { Loader2 } from "lucide-react";
-
-import { useAuth } from "@/hooks/use-auth";
-import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
-import { IllnessSettings } from "@/components/illness/illness-settings";
-
-/**
- * v1.18.6 (W8 / MOD-03) — the Illness module's own settings page. Dedicated
- * static route (wins over `/settings/[section]`); reached from the wrench
- * beside the Illness page's "hinzufügen" button.
- */
-export default function IllnessSettingsPage() {
- const { isLoading } = useAuth();
- const mounted = useMounted();
- const { t } = useTranslations();
-
- if (!mounted || isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/app/settings/labs/page.tsx b/src/app/settings/labs/page.tsx
deleted file mode 100644
index 5805b5fa9..000000000
--- a/src/app/settings/labs/page.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client";
-
-import { Loader2 } from "lucide-react";
-
-import { useAuth } from "@/hooks/use-auth";
-import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
-import { LabsSettings } from "@/components/labs/labs-settings";
-
-/**
- * v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page.
- * Dedicated static route (wins over `/settings/[section]`); reached from the
- * wrench beside the Labs page's "hinzufügen" button.
- */
-export default function LabsSettingsPage() {
- const { isLoading } = useAuth();
- const mounted = useMounted();
- const { t } = useTranslations();
-
- if (!mounted || isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/app/settings/vorsorge/page.tsx b/src/app/settings/vorsorge/page.tsx
deleted file mode 100644
index 147b3d51f..000000000
--- a/src/app/settings/vorsorge/page.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-
-import { Loader2 } from "lucide-react";
-
-import { useAuth } from "@/hooks/use-auth";
-import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
-import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
-
-/**
- * v1.18.6 (W8 / MOD-03) — the Vorsorge module's own settings page.
- *
- * A dedicated static route that wins over the dynamic `/settings/[section]`
- * router (static segments take precedence in Next.js), so it adds a
- * per-module settings surface without touching the parallel-phase-owned
- * `section-slugs` registry or `settings-shell`. Reached from the wrench
- * beside the Vorsorge page's "hinzufügen" button.
- */
-export default function VorsorgeSettingsPage() {
- const { isLoading } = useAuth();
- const mounted = useMounted();
- const { t } = useTranslations();
-
- if (!mounted || isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/components/module-list/module-settings-frame.tsx b/src/components/module-list/module-settings-frame.tsx
deleted file mode 100644
index 4d2959b19..000000000
--- a/src/components/module-list/module-settings-frame.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-"use client";
-
-import type { ReactNode } from "react";
-
-import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
-import { SettingsSectionFrame } from "@/components/settings/settings-section-frame";
-
-/**
- * v1.18.6 (W8 / MOD-03) — heading frame for the per-module settings pages
- * (Vorsorge / Illness / Labs).
- *
- * Delegates to the shared `SettingsSectionFrame` so every settings page — the
- * 19 shell sections and these three per-module pages — renders one identical
- * heading frame. These pages stay out of the slug registry (their static
- * routes win over `/settings/[section]`), so they drive the frame's explicit
- * title/subtitle mode and supply the "return to the module" back-link through
- * the frame's `topSlot`, which sits above the heading.
- */
-export function ModuleSettingsFrame({
- title,
- description,
- backHref,
- backLabelKey,
- children,
-}: {
- title: string;
- description: string;
- /** Where the "← back" link returns to — the module's own page. */
- backHref: string;
- backLabelKey: string;
- children: ReactNode;
-}) {
- return (
- }
- >
- {children}
-
- );
-}
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 24caa863b..19b600232 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -91,6 +91,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// sits right after `ai`.
// v1.18.1 (D4) — `channels` + `sources` split out of the Integrations
// sub-tabs into their own left-side entries, right after `integrations`.
+ // v1.18.7 — `labs`, `illness`, `vorsorge` move into the shell as
+ // first-class sections, right after `mood` (the three were standalone
+ // `ModuleSettingsFrame` pages).
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"modules",
@@ -103,6 +106,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"insights",
"medications",
"mood",
+ "labs",
+ "illness",
+ "vorsorge",
"thresholds",
"ai",
"coach",
diff --git a/src/components/settings/illness-section.tsx b/src/components/settings/illness-section.tsx
new file mode 100644
index 000000000..214bd7784
--- /dev/null
+++ b/src/components/settings/illness-section.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+/**
+ * v1.18.7 — Settings → Krankheitstagebuch.
+ *
+ * The illness-journal customise surface (list view + episode order) was a
+ * standalone `ModuleSettingsFrame` page at `/settings/illness`; it now
+ * renders as a first-class Settings section. The shell supplies the page
+ * chrome (left nav, heading, subtitle) so this body is the illness cards
+ * only. The nav entry is module-gated on `illness`.
+ */
+
+import { IllnessSettings } from "@/components/illness/illness-settings";
+
+export function IllnessSection() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/settings/labs-section.tsx b/src/components/settings/labs-section.tsx
new file mode 100644
index 000000000..883da6d4c
--- /dev/null
+++ b/src/components/settings/labs-section.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+/**
+ * v1.18.7 — Settings → Labor.
+ *
+ * The labs customise surface (list view, sort order, biomarker management)
+ * was a standalone `ModuleSettingsFrame` page at `/settings/labs`; it now
+ * renders as a first-class Settings section. The shell supplies the page
+ * chrome (left nav, heading, subtitle) so this body is the labs cards only —
+ * the same pattern the other module-gated sections (Medikamente, Coach,
+ * Stimmung) follow. The nav entry is module-gated on `labs`.
+ */
+
+import { LabsSettings } from "@/components/labs/labs-settings";
+
+export function LabsSection() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx
index 4877945bf..3a56bdbb4 100644
--- a/src/components/settings/section-placeholder.tsx
+++ b/src/components/settings/section-placeholder.tsx
@@ -15,8 +15,10 @@ import {
Bell,
Blocks,
Bot,
+ CalendarCheck,
Download,
FileHeart,
+ FlaskConical,
Info,
KeyRound,
Layers,
@@ -28,6 +30,7 @@ import {
SlidersHorizontal,
Smile,
Sparkles,
+ Thermometer,
TrendingUp,
User,
type LucideIcon,
@@ -49,6 +52,9 @@ const SLUG_ICON: Record = {
insights: TrendingUp,
medications: Pill,
mood: Smile,
+ labs: FlaskConical,
+ illness: Thermometer,
+ vorsorge: CalendarCheck,
thresholds: SlidersHorizontal,
ai: Sparkles,
coach: Bot,
diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts
index 284b05acf..80b5d3d68 100644
--- a/src/components/settings/section-slugs.ts
+++ b/src/components/settings/section-slugs.ts
@@ -22,6 +22,16 @@
// v1.17 — `mood` sits after `medications`: the mood-tag management
// surface (groups, custom tags, hide/archive, picker order), reached
// from the /mood page header's wrench glyph.
+// v1.18.7 — `labs`, `illness`, and `vorsorge` move INTO the Settings
+// shell as first-class sections, right after `mood`, so the three
+// tracking-domain customise surfaces sit together. They were standalone
+// `ModuleSettingsFrame` pages (their own back-button + title, outside the
+// shell); now the dynamic `[section]` route renders them in shell chrome
+// 1:1 with the other sections. The wrench deep-links (`/settings/labs`,
+// `/settings/illness`, `/settings/vorsorge`) are unchanged — they now
+// resolve through the shell. `labs` + `illness` are module-gated (they are
+// toggleable modules); `vorsorge` (preventive-care reminders) is not a
+// module, so its entry is always shown.
// v1.17.1 (F-2) — `layout` is the one "Layout & Personalization" home.
// The dashboard / insights / medications / mood personalization editors
// each keep their own route (deep links, page-header cogs, and the hub's
@@ -53,6 +63,9 @@ export const SETTINGS_SECTION_SLUGS = [
"insights",
"medications",
"mood",
+ "labs",
+ "illness",
+ "vorsorge",
"thresholds",
"ai",
// v1.18.0 (S5) — `coach` gathers the Coach preference cards (disable
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 6575b2834..0f709910a 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -21,8 +21,10 @@ import {
Bell,
Blocks,
Bot,
+ CalendarCheck,
Download,
FileHeart,
+ FlaskConical,
Info,
KeyRound,
LayoutDashboard,
@@ -34,6 +36,7 @@ import {
SlidersHorizontal,
Smile,
Sparkles,
+ Thermometer,
User,
type LucideIcon,
} from "lucide-react";
@@ -160,6 +163,34 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
icon: Smile,
moduleGate: "mood",
},
+ // v1.18.7 — Labor: the labs customise surface (view, sort, biomarkers)
+ // becomes a first-class Settings section, gated on the `labs` module. Was
+ // a standalone `ModuleSettingsFrame` page reached from the /labs wrench.
+ {
+ slug: "labs",
+ titleKey: "settings.sections.labs.title",
+ icon: FlaskConical,
+ moduleGate: "labs",
+ },
+ // v1.18.7 — Krankheitstagebuch: the illness-journal customise surface
+ // (view + order of episodes), gated on the `illness` module. Was a
+ // standalone `ModuleSettingsFrame` page reached from the /illness wrench.
+ {
+ slug: "illness",
+ titleKey: "settings.sections.illness.title",
+ icon: Thermometer,
+ moduleGate: "illness",
+ },
+ // v1.18.7 — Vorsorge: the preventive-care reminders customise surface
+ // (view, order, individual reminders). NOT module-gated — preventive-care
+ // reminders are not a toggleable module — so the entry is always shown.
+ // Was a standalone `ModuleSettingsFrame` page reached from the /vorsorge
+ // wrench.
+ {
+ slug: "vorsorge",
+ titleKey: "settings.sections.vorsorge.title",
+ icon: CalendarCheck,
+ },
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
diff --git a/src/components/settings/vorsorge-section.tsx b/src/components/settings/vorsorge-section.tsx
new file mode 100644
index 000000000..e53a1c059
--- /dev/null
+++ b/src/components/settings/vorsorge-section.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+/**
+ * v1.18.7 — Settings → Vorsorge.
+ *
+ * The preventive-care reminders customise surface (list view, order,
+ * individual reminders) was a standalone `ModuleSettingsFrame` page at
+ * `/settings/vorsorge`; it now renders as a first-class Settings section.
+ * The shell supplies the page chrome (left nav, heading, subtitle) so this
+ * body is the preventive-care cards only. Preventive-care reminders are not
+ * a toggleable module, so this entry carries no `moduleGate` (always shown).
+ */
+
+import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
+
+export function VorsorgeSection() {
+ return (
+
+
+
+ );
+}
From 57cf8340e27f82507555df6bf62dd32573acb2e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:06:53 +0200
Subject: [PATCH 31/71] fix(insights): retry the comprehensive JSON parse once
before failing
The comprehensive generator did an ad-hoc JSON.parse with no corrective
retry and failed cold to `invalid-json` on a first-pass miss, while the
strict insight wrapper's `buildRetryCorrectionMessage` sat right next
door. Export that helper and reuse it: on a parse miss, append the
correction to the user prompt and re-run the chain once before declaring
failure. Unifies the JSON-robustness story across the two paths.
---
src/lib/ai/generate-insight.ts | 5 +-
.../comprehensive-json-retry.test.ts | 128 ++++++++++++++++++
src/lib/insights/comprehensive-generate.ts | 72 ++++++++--
3 files changed, 194 insertions(+), 11 deletions(-)
create mode 100644 src/lib/insights/__tests__/comprehensive-json-retry.test.ts
diff --git a/src/lib/ai/generate-insight.ts b/src/lib/ai/generate-insight.ts
index 3cb1444f1..4b8fb939c 100644
--- a/src/lib/ai/generate-insight.ts
+++ b/src/lib/ai/generate-insight.ts
@@ -46,7 +46,10 @@ export interface GenerateInsightOutcome {
* what went wrong on the first attempt. Includes the violated zod
* issues verbatim (truncated to 1KB) so the model can self-correct.
*/
-function buildRetryCorrectionMessage(reason: string, details: string): string {
+export function buildRetryCorrectionMessage(
+ reason: string,
+ details: string,
+): string {
return `
Your previous response did not satisfy the required JSON schema.
Reason: ${reason}
diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts
new file mode 100644
index 000000000..047525350
--- /dev/null
+++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts
@@ -0,0 +1,128 @@
+/**
+ * v1.18.7 (MEDIUM-5) — comprehensive JSON-retry robustness.
+ *
+ * The comprehensive path used to fail cold to `invalid-json` on a first-pass
+ * parse miss. It now reuses `buildRetryCorrectionMessage` for ONE corrective
+ * retry before declaring failure. These tests pin: a first-pass miss followed
+ * by a valid retry succeeds; two misses fail; and a first-pass success runs
+ * no retry.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const findUnique = vi.fn();
+const userUpdate = vi.fn();
+const resolveProviderChain = vi.fn();
+const resolveProvider = vi.fn();
+const runRawCompletionWithFallback = vi.fn();
+const extractFeatures = vi.fn();
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ user: {
+ findUnique: (...a: unknown[]) => findUnique(...a),
+ update: (...a: unknown[]) => userUpdate(...a),
+ },
+ auditLog: { deleteMany: vi.fn() },
+ },
+}));
+vi.mock("@/lib/ai/provider", () => ({
+ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
+ resolveProvider: (...a: unknown[]) => resolveProvider(...a),
+}));
+vi.mock("@/lib/ai/provider-runner", () => ({
+ AllProvidersFailedError: class extends Error {},
+ runRawCompletionWithFallback: (...a: unknown[]) =>
+ runRawCompletionWithFallback(...a),
+}));
+vi.mock("@/lib/insights/features", () => ({
+ FeaturesPayloadTooLargeError: class extends Error {
+ sizeBytes = 0;
+ },
+ extractFeatures: (...a: unknown[]) => extractFeatures(...a),
+}));
+vi.mock("@/lib/insights/glp1-plateau", () => ({
+ detectGlp1Plateau: vi.fn(async () => null),
+ buildGlp1PlateauPrompt: vi.fn(() => ""),
+}));
+vi.mock("@/lib/ai/coach/about-me", () => ({
+ getSelfContextTextForUser: vi.fn(async () => null),
+ buildAboutMeInsightBlock: vi.fn(() => ""),
+}));
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserInsights: vi.fn(),
+}));
+
+import { generateComprehensiveInsight } from "../comprehensive-generate";
+
+const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ resolveProviderChain.mockResolvedValue([
+ { providerType: "openai", instance: {} },
+ ]);
+ resolveProvider.mockResolvedValue({ type: "none" });
+ extractFeatures.mockResolvedValue(FEATURES);
+ userUpdate.mockResolvedValue({});
+ // No cached text / hash → always runs a full generation (no gate hit).
+ findUnique.mockResolvedValue({
+ insightsPrivacyMode: "aggregated",
+ insightsCachedAt: null,
+ insightsCachedText: null,
+ insightsExcludeMetrics: [],
+ insightsSnapshotHash: null,
+ insightsBriefingRerollDate: null,
+ });
+});
+
+const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } });
+
+describe("comprehensive JSON-retry", () => {
+ it("recovers via one corrective retry after a first-pass JSON miss", async () => {
+ runRawCompletionWithFallback
+ .mockResolvedValueOnce({
+ result: { content: "I'm sorry, here is the data:", tokensUsed: 5, model: "m" },
+ workingProvider: { providerType: "openai" },
+ fallbackHops: [],
+ })
+ .mockResolvedValueOnce({
+ result: { content: VALID, tokensUsed: 10, model: "m" },
+ workingProvider: { providerType: "openai" },
+ fallbackHops: [],
+ });
+
+ const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
+
+ expect(outcome).toEqual({ status: "generated", providerType: "openai" });
+ expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
+ // The retry call appends the correction to the user prompt.
+ const retryParams = runRawCompletionWithFallback.mock.calls[1][0].params;
+ expect(retryParams.userPrompt).toContain("did not satisfy the required");
+ });
+
+ it("fails with invalid-json when both attempts miss", async () => {
+ runRawCompletionWithFallback.mockResolvedValue({
+ result: { content: "still not json", tokensUsed: 5, model: "m" },
+ workingProvider: { providerType: "openai" },
+ fallbackHops: [],
+ });
+
+ const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
+
+ expect(outcome).toEqual({ status: "failed", reason: "invalid-json" });
+ expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
+ });
+
+ it("runs no retry when the first pass is valid", async () => {
+ runRawCompletionWithFallback.mockResolvedValue({
+ result: { content: VALID, tokensUsed: 10, model: "m" },
+ workingProvider: { providerType: "openai" },
+ fallbackHops: [],
+ });
+
+ const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
+
+ expect(outcome).toEqual({ status: "generated", providerType: "openai" });
+ expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 97c1b2ea9..7f263103f 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -34,6 +34,7 @@ import {
type ComparisonSnapshot,
} from "@/lib/ai/prompts/insight-system-prompt";
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
+import { buildRetryCorrectionMessage } from "@/lib/ai/generate-insight";
import {
buildAboutMeInsightBlock,
getSelfContextTextForUser,
@@ -234,6 +235,28 @@ async function rerollBriefingParagraph(args: {
return { text: JSON.stringify(merged), providerType };
}
+/**
+ * Parse + (best-effort) validate a comprehensive provider reply. Returns the
+ * parsed payload (validated when it matches `insightResultSchema`, else the
+ * raw object so the richer optional blocks survive), or null when the reply
+ * is not valid JSON even after fence-stripping. Null signals the caller to
+ * run its one corrective retry.
+ */
+function parseComprehensiveResult(
+ content: string,
+): InsightResult | Record | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(stripJsonFences(content));
+ } catch {
+ return null;
+ }
+ const validated = insightResultSchema.safeParse(parsed);
+ return validated.success
+ ? validated.data
+ : (parsed as Record);
+}
+
/**
* Comparison-snapshot builder — shared with the on-demand route. Returns
* null when the user's comparison toggle is off (most users), so the
@@ -856,16 +879,45 @@ export async function generateComprehensiveInsight(
return { status: "failed", reason: "provider-error" };
}
- let insights: InsightResult | Record;
- try {
- // Anthropic + local have no native JSON mode, so a ```json-fenced or
- // sentence-prefixed reply would otherwise fail the whole generation.
- // Strip the fence before parsing; clean JSON passes through unchanged.
- const parsed = JSON.parse(stripJsonFences(result.content));
- const validated = insightResultSchema.safeParse(parsed);
- insights = validated.success ? validated.data : parsed;
- } catch {
- return { status: "failed", reason: "invalid-json" };
+ // Anthropic + local have no native JSON mode, so a ```json-fenced or
+ // sentence-prefixed reply would otherwise fail the whole generation.
+ // Strip the fence before parsing; clean JSON passes through unchanged.
+ let insights = parseComprehensiveResult(result.content);
+ if (insights === null) {
+ // v1.18.7 (MEDIUM-5) — one corrective retry before declaring failure,
+ // reusing the same `buildRetryCorrectionMessage` the strict insight
+ // wrapper uses next door. The comprehensive path previously failed cold
+ // to `invalid-json` on a first-pass miss even though the recovery helper
+ // sat right there. The retry appends the correction to the user prompt
+ // and re-runs the chain once.
+ annotate({
+ action: { name: "insights.generate.json_retry" },
+ meta: { locale },
+ });
+ try {
+ const retry = await runRawCompletionWithFallback({
+ userId,
+ providers: chain,
+ params: {
+ systemPrompt,
+ userPrompt: `${userPrompt}\n\n${buildRetryCorrectionMessage(
+ "Response was not valid JSON",
+ "The previous reply could not be parsed as a JSON object.",
+ )}`,
+ temperature: AI_BUDGETS.comprehensive.temperature,
+ maxTokens: AI_BUDGETS.comprehensive.maxTokens,
+ responseFormat: "json",
+ },
+ });
+ result = retry.result;
+ workingProviderType = retry.workingProvider.providerType;
+ } catch {
+ return { status: "failed", reason: "invalid-json" };
+ }
+ insights = parseComprehensiveResult(result.content);
+ if (insights === null) {
+ return { status: "failed", reason: "invalid-json" };
+ }
}
// v1.9.0 — the caller abandoned this generation (its bounded timeout
From 6609ce0dc7410ca88fa65c92b8f0d2db8da85379 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:08:35 +0200
Subject: [PATCH 32/71] fix(medications): evict supply caches so the card
reflects stock at once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The /medications card and table render the dose-derived stock
(stockUnitsRemaining / stockDosesRemaining) straight from the
GET /api/medications list payload, which reads through cachedSwr on the
60s-fresh + 10-min-stale medications bucket. Registering, adjusting, or
deleting an inventory container changes that stock, but the inventory
routes never invalidated any cache — so the list served the pre-write
stock for the rest of the stale window. On the client the add / adjust /
delete supply mutations only invalidated the per-medication inventory
query, never the medications-list query that the card reads, so a mounted
list never refetched even once the server caught up.
Hard-evict the per-user medications + compliance buckets from every
supply-affecting write (inventory POST, the per-item PATCH / DELETE, and
the deprecated glp1 POST dose-change + ledger branches) so the next read
reflects the change rather than a stale SWR value. On the client,
centralise the supply mutations on a shared invalidateSupplyQueries helper
that drops both the medications-list key and the per-medication inventory
key. The card's neutral styling is unchanged.
---
src/app/api/medications/[id]/glp1/route.ts | 11 +++++
.../[id]/inventory/[itemId]/route.ts | 12 ++++++
.../[id]/inventory/__tests__/route.test.ts | 22 ++++++++++
.../api/medications/[id]/inventory/route.ts | 9 ++++
.../__tests__/inventory-section.test.tsx | 43 +++++++++++++++++++
.../sections/inventory-section.tsx | 43 ++++++++++++++-----
6 files changed, 130 insertions(+), 10 deletions(-)
create mode 100644 src/components/medications/sections/__tests__/inventory-section.test.tsx
diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts
index b6523afd9..ab3de40cb 100644
--- a/src/app/api/medications/[id]/glp1/route.ts
+++ b/src/app/api/medications/[id]/glp1/route.ts
@@ -38,6 +38,7 @@ import {
resolveLowStockRunwayDays,
resolveReorderLeadDays,
} from "@/lib/validations/notification-prefs";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -285,6 +286,11 @@ export const POST = apiHandler(
meta: { medication_id: id, doseValue, doseUnit },
});
+ // A dose change shifts the daily-dose estimate the list payload's
+ // runway derivation uses. Hard-evict the medications + compliance
+ // buckets so the card reflects the new runway on the next read.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess({ doseChange: created }, 201);
}
@@ -324,6 +330,11 @@ export const POST = apiHandler(
meta: { medication_id: id, delta },
});
+ // A legacy ledger delta changes the stock the list payload reports
+ // for a medication with no per-item containers. Hard-evict so the
+ // card reflects it on the next read.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess({ inventory: created }, 201);
},
);
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
index 99ce9346d..f8da4a14e 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
@@ -36,6 +36,7 @@ import {
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
@@ -170,6 +171,12 @@ export const PATCH = apiHandler(
meta: { medication_id: id, prev: existing.state, next: updated.state },
});
+ // A stock correction / first-use / used-up flip changes the
+ // dose-derived stock the medications-list payload carries, which the
+ // card and table render. Hard-evict so the supply shows on the next
+ // read instead of after the `cachedSwr` stale window.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess(serializeInventoryItem(updated));
},
);
@@ -204,6 +211,11 @@ export const DELETE = apiHandler(
meta: { medication_id: id },
});
+ // Removing a container changes the dose-derived stock the
+ // medications-list payload carries. Hard-evict so the card / table
+ // reflect the drop on the very next read.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess({ id: itemId, deleted: true });
},
);
diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
index d27144f42..1b0bea45e 100644
--- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts
@@ -31,6 +31,10 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserMedications: vi.fn(),
+}));
+
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -49,6 +53,7 @@ import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -173,6 +178,13 @@ describe("POST /api/medications/[id]/inventory", () => {
expiresAt: new Date(printed),
}),
});
+ // Staleness regression: the dose-derived stock the medications-list
+ // payload carries must hard-evict on a registration so the card / table
+ // reflect the new supply on the very next read (not after the SWR
+ // stale window). Mark-stale would serve the pre-write stock.
+ expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
+ evict: true,
+ });
});
it("returns 429 when the rate limit is exceeded", async () => {
@@ -271,6 +283,11 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
state: "USED_UP",
unitsRemaining: 0,
});
+ // Staleness regression: a stock-affecting correction must hard-evict
+ // the medications-list bucket so the card reflects the change next read.
+ expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
+ evict: true,
+ });
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
@@ -352,6 +369,11 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
+ // Staleness regression: removing a container drops the dose-derived
+ // stock the list payload reports — hard-evict so it reflects next read.
+ expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
+ evict: true,
+ });
});
});
diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts
index 79bbc9b95..68978f3c1 100644
--- a/src/app/api/medications/[id]/inventory/route.ts
+++ b/src/app/api/medications/[id]/inventory/route.ts
@@ -33,6 +33,7 @@ import {
serializeInventoryItem,
} from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
type RouteParams = { params: Promise<{ id: string }> };
@@ -134,6 +135,14 @@ export const POST = apiHandler(
meta: { medication_id: id },
});
+ // A registered container changes the dose-derived stock the
+ // medications-list payload carries (`stockUnitsRemaining` /
+ // `stockDosesRemaining`), which the card and table render. Hard-evict
+ // the per-user medications + compliance buckets so the supply shows on
+ // the very next read — a mark-stale would let the `cachedSwr` list
+ // serve the pre-write stock for the rest of the stale window.
+ invalidateUserMedications(user.id, { evict: true });
+
return apiSuccess(serializeInventoryItem(created), 201);
},
);
diff --git a/src/components/medications/sections/__tests__/inventory-section.test.tsx b/src/components/medications/sections/__tests__/inventory-section.test.tsx
new file mode 100644
index 000000000..e5f106c55
--- /dev/null
+++ b/src/components/medications/sections/__tests__/inventory-section.test.tsx
@@ -0,0 +1,43 @@
+/**
+ * Supply-staleness regression — the medications-list card / table read
+ * the dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`)
+ * straight from the `GET /api/medications` list payload. The add / adjust /
+ * delete supply mutations used to invalidate only the per-medication
+ * inventory query, leaving the list query (`queryKeys.medications()`)
+ * serving the pre-write stock — so the card kept showing the old supply
+ * until an unrelated refetch landed.
+ *
+ * `invalidateSupplyQueries` is the single helper all three mutations call;
+ * this pins that it drops BOTH the list key (the card / table source) and
+ * the per-medication inventory key. The repo's component-test convention
+ * avoids `@testing-library/react` DOM interaction, so the mutation's
+ * invalidation contract is asserted through the extracted helper against a
+ * minimal QueryClient stand-in.
+ */
+
+import { describe, expect, it, vi } from "vitest";
+
+import { invalidateSupplyQueries } from "@/components/medications/sections/inventory-section";
+import { queryKeys } from "@/lib/query-keys";
+
+describe("invalidateSupplyQueries — supply staleness regression", () => {
+ it("invalidates the medications LIST key AND the per-medication inventory key", async () => {
+ const invalidateQueries = vi.fn().mockResolvedValue(undefined);
+ const queryClient = { invalidateQueries } as unknown as Parameters<
+ typeof invalidateSupplyQueries
+ >[0];
+
+ await invalidateSupplyQueries(queryClient, "med-1");
+
+ const invalidatedKeys = invalidateQueries.mock.calls.map((call) =>
+ JSON.stringify((call[0] as { queryKey: unknown }).queryKey),
+ );
+
+ // The card / table source — the regression was this key being absent.
+ expect(invalidatedKeys).toContain(JSON.stringify(queryKeys.medications()));
+ // The per-medication supply tab still refetches too.
+ expect(invalidatedKeys).toContain(
+ JSON.stringify(queryKeys.medicationInventory("med-1")),
+ );
+ });
+});
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index 28a769047..9018ea169 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -37,7 +37,11 @@
import { useState, type FormEvent } from "react";
import Link from "next/link";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ useQuery,
+ useQueryClient,
+ type QueryClient,
+} from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, PackageOpen, Plus } from "lucide-react";
@@ -74,6 +78,31 @@ import {
} from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
+/**
+ * Invalidate every read key whose payload reflects a medication's supply
+ * after a container write (register / adjust / delete).
+ *
+ * The per-medication inventory read (`medicationInventory`) is the supply
+ * tab's own list. The medications LIST read (`medications`) carries the
+ * dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that
+ * the card and table render — without invalidating it, the card kept
+ * showing the pre-write stock until an unrelated refetch landed (the
+ * supply-staleness bug). Both keys must drop together.
+ */
+export async function invalidateSupplyQueries(
+ queryClient: QueryClient,
+ medicationId: string,
+): Promise {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.medicationInventory(medicationId),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.medications(),
+ }),
+ ]);
+}
+
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
const CONTAINER_TYPES = [
@@ -138,9 +167,7 @@ export function InventorySection({
async function deleteItem(item: InventoryItem) {
try {
await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`);
- await queryClient.invalidateQueries({
- queryKey: queryKeys.medicationInventory(medicationId),
- });
+ await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.deleteSuccess"));
} catch {
toast.error(t("medications.detail.bestand.deleteFailed"));
@@ -492,9 +519,7 @@ export function AddInventoryDialog({
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
});
- await queryClient.invalidateQueries({
- queryKey: queryKeys.medicationInventory(medicationId),
- });
+ await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.addSuccess"));
onClose();
} catch {
@@ -694,9 +719,7 @@ function AdjustInventoryDialog({
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsRemaining: parsed,
});
- await queryClient.invalidateQueries({
- queryKey: queryKeys.medicationInventory(medicationId),
- });
+ await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.adjustSuccess"));
onClose();
} catch {
From 9182b60dc87e5e863df0989daf0b6233f68b15eb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:10:21 +0200
Subject: [PATCH 33/71] feat(settings): add SettingsCard primitive for the
standard card shape
Wrap the ui Card so every Settings section composes from one bordered
container with the shared p-4 md:p-6 padding contract, replacing the
hand-rolled bg-card border-border rounded-xl border p-4 sm:p-6 div that
bypassed the primitive and stepped padding at sm: instead of md:.
---
src/components/settings/settings-card.tsx | 39 +++++++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 src/components/settings/settings-card.tsx
diff --git a/src/components/settings/settings-card.tsx b/src/components/settings/settings-card.tsx
new file mode 100644
index 000000000..237ae497f
--- /dev/null
+++ b/src/components/settings/settings-card.tsx
@@ -0,0 +1,39 @@
+import * as React from "react";
+
+import { Card } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
+/**
+ * `` — the standard Settings card container.
+ *
+ * Every Settings section paints the same bordered surface: a rounded card
+ * with the app's one card-padding contract (`p-4 md:p-6` — 16 px on phones,
+ * 24 px from `md` up). Before this primitive the surface hand-rolled
+ * `bg-card border-border rounded-xl border p-4 sm:p-6` in ~35 files, which
+ * (a) bypassed the `` primitive entirely and (b) stepped padding at
+ * `sm:` (640 px) instead of `md:` (768 px), so on a ~700 px tablet Settings
+ * cards read denser than every other surface.
+ *
+ * This wraps the ui `` so the whole surface composes from one shape.
+ * Unlike the bare `` it does NOT impose the `flex flex-col gap` layout:
+ * Settings cards manage their own internal rhythm (`SettingsCardHeader` +
+ * `mt-4`/`space-y-*` bodies), so the container only owns the border, radius,
+ * background, and padding. Pass `className` for per-card extras
+ * (`scroll-mt-28`, `space-y-4`, `flex h-full flex-col`, …).
+ */
+export function SettingsCard({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
From 4b26e1cef958ac45d843cf7e9a724f99cb55e592 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:10:58 +0200
Subject: [PATCH 34/71] fix(seed): point demo achievements at real registry ids
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The demo seed unlocked twelve achievement ids that no longer exist in
the registry (first-measurement, week-streak-measurements,
three-month-streak, …), so the achievements surface rendered them as
unknown placeholders. Map the unlocked set to live registry keys —
first-entry badges, logging and in-range streaks, compliance and
miss-free streaks, plus the self-context and seven-night sleep badges —
each on a plausible unlock date the seeded history supports.
---
scripts/seed-demo.ts | 44 ++++++++++++++++++++++++++++++++------------
1 file changed, 32 insertions(+), 12 deletions(-)
diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts
index 96b9b1104..2d40d0d75 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -541,19 +541,39 @@ async function seed() {
// ── Achievements ─────────────────────────
console.log("Creating achievements...");
+ // Every id below is a real key from the achievement registry
+ // (src/lib/gamification/achievements.ts) so the badge resolves to a real
+ // title/icon rather than an "unknown" placeholder. Only milestones the
+ // seeded history genuinely supports are unlocked (first-entry badges,
+ // logging streaks, in-range BP/pulse/BMI streaks, compliance + miss-free
+ // streaks, the self-context + 7-night sleep badges). The dynamic streak
+ // families use the `-` naming the generator emits.
const achievements = [
- { id: "first-measurement", daysAgo: 89 },
- { id: "first-medication", daysAgo: 89 },
- { id: "first-mood", daysAgo: 89 },
- { id: "week-streak-measurements", daysAgo: 82 },
- { id: "week-streak-mood", daysAgo: 82 },
- { id: "month-streak-measurements", daysAgo: 59 },
- { id: "compliance-90", daysAgo: 45 },
- { id: "weight-loss-5", daysAgo: 30 },
- { id: "bp-normal", daysAgo: 20 },
- { id: "steps-10k", daysAgo: 15 },
- { id: "month-streak-mood", daysAgo: 10 },
- { id: "three-month-streak", daysAgo: 2 },
+ { id: "weight-first", daysAgo: 89 },
+ { id: "bp-first", daysAgo: 89 },
+ { id: "pulse-first", daysAgo: 89 },
+ { id: "mood-first", daysAgo: 89 },
+ { id: "self-context-complete", daysAgo: 88 },
+ { id: "entry-streak-7", daysAgo: 82 },
+ { id: "mood-streak-7", daysAgo: 82 },
+ { id: "sleep-log-7", daysAgo: 70 },
+ { id: "mood-up-7", daysAgo: 68 },
+ { id: "weight-50", daysAgo: 60 },
+ { id: "bp-50", daysAgo: 55 },
+ { id: "entry-streak-30", daysAgo: 50 },
+ { id: "mood-streak-30", daysAgo: 50 },
+ { id: "miss-free-7", daysAgo: 48 },
+ { id: "consistent-month", daysAgo: 45 },
+ { id: "bp-green-7", daysAgo: 42 },
+ { id: "pulse-green-7", daysAgo: 40 },
+ { id: "bmi-green-7", daysAgo: 38 },
+ { id: "compliance-80-30", daysAgo: 30 },
+ { id: "miss-free-30", daysAgo: 30 },
+ { id: "on-time-perfect-7", daysAgo: 28 },
+ { id: "measurement-weeks-4", daysAgo: 25 },
+ { id: "bp-green-30", daysAgo: 12 },
+ { id: "pulse-green-30", daysAgo: 10 },
+ { id: "miss-free-90", daysAgo: 2 },
];
for (const ach of achievements) {
From 5ebe97d5a3d7aff73f7c4305d797436d0296ff59 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:11:09 +0200
Subject: [PATCH 35/71] feat(seed): broaden demo coverage across every tracking
domain
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extend the demo tenant so it reads as a long-term, fully engaged user
across the whole app, not a thin sample. All series span the existing
ninety-day window with the latest point on today and sit in healthy
bands:
- Body composition (fat, fat-free, lean, muscle mass, total body water,
bone mass, visceral fat, BMI) derived from the same-day weight and
body-fat series so the numbers reconcile with the headline tiles.
- Blood glucose in a healthy non-diabetic band — fasting, post-meal and
bedtime readings stored in mg/dL with the glucose context tagged.
- Cardio fitness and vitals: HRV (SDNN and RMSSD), oxygen saturation,
respiratory rate, periodic VO2 max, active energy, walking/running
distance and flights climbed.
- WHOOP-style recovery, day strain and sleep performance/efficiency/
consistency scores aligned with the seeded sleep nights.
- Three to four workouts a week (running, strength, cycling) with
duration, energy, average and max heart rate, distance, a sparse
per-workout heart-rate sample series, and per-workout strain in the
metadata blob.
Clear the workout tables in the same truncate pass so the seed stays
re-runnable.
---
scripts/seed-demo.ts | 261 ++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 260 insertions(+), 1 deletion(-)
diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts
index 2d40d0d75..5043c57ea 100644
--- a/scripts/seed-demo.ts
+++ b/scripts/seed-demo.ts
@@ -11,6 +11,16 @@
* sleep, steps) — flat-or-improving trends, most-recent point from today
* - A full week of per-stage sleep nights (awake/REM/light(core)/deep),
* ~7.5 h each, including last night
+ * - 90 days of body composition (fat/lean/muscle mass, total body water,
+ * bone mass, visceral fat, BMI) derived from the same-day weight + body fat
+ * - 90 days of blood glucose in a healthy non-diabetic band (fasting +
+ * post-meal + bedtime, contexts tagged)
+ * - 90 days of cardio fitness + vitals (HRV SDNN + RMSSD, SpO2, respiratory
+ * rate, VO2 max, active energy, walking/running distance, flights climbed)
+ * - 90 days of WHOOP-style scores (recovery, day strain, sleep
+ * performance/efficiency/consistency) coherent with the sleep nights
+ * - ~3-4 workouts/week (running, strength, cycling) with per-workout HR
+ * samples and per-workout strain
* - 3 medications with schedules and ~90 days of intake history at high
* compliance, with today scheduled on-track (taken or not-yet-due)
* - 90 days of mood entries
@@ -118,6 +128,8 @@ async function seed() {
await client.query("DELETE FROM lab_results");
await client.query("DELETE FROM measurement_reminders");
await client.query("DELETE FROM mood_entries");
+ await client.query("DELETE FROM workout_samples");
+ await client.query("DELETE FROM workouts");
await client.query("DELETE FROM medication_intake_events");
await client.query("DELETE FROM medication_schedules");
await client.query("DELETE FROM reminder_phase_configs");
@@ -292,6 +304,248 @@ async function seed() {
}
}
+ // ── Body composition (smart-scale series) ──
+ // A full scale-style breakdown, written every 2–3 days like a real
+ // body-composition scale would record. Every component is DERIVED from
+ // the same-day weight + body-fat series above so the numbers reconcile:
+ // fat mass = weight × bodyFat%, fat-free mass = weight − fat mass, and
+ // the muscle / water / bone / BMI components hang off those so the demo
+ // never contradicts its own headline weight + body-fat tiles. Source
+ // APPLE_HEALTH with device_type 'scale' so they read as device-tracked.
+ // Canonical units (see prisma/schema.prisma + VALUE_RANGES): mass
+ // components in kg, VISCERAL_FAT a 1–12 rating, BODY_MASS_INDEX kg/m².
+ console.log("Creating body composition series...");
+ const HEIGHT_M = 1.82;
+ for (let i = 0; i < span; i++) {
+ // Roughly every other day, jittered, so the cadence reads organic.
+ if (i % 2 !== 0 && Math.random() > 0.4) continue;
+ const date = daysAgo(days - i);
+ const weightKg = weights[i];
+ const fatPct = bodyFat[i];
+ const fatMass = Math.round(weightKg * (fatPct / 100) * 10) / 10;
+ const fatFree = Math.round((weightKg - fatMass) * 10) / 10;
+ // Skeletal muscle is a subset of fat-free mass (~75% of it for a lean
+ // adult male); lean body mass ≈ fat-free mass.
+ const muscleMass = Math.round(fatFree * 0.75 * 10) / 10;
+ const leanMass = fatFree;
+ // Total body water ≈ 73% of fat-free mass (kg of water).
+ const bodyWater = Math.round(fatFree * 0.73 * 10) / 10;
+ const boneMass = Math.round((3.1 + (Math.random() - 0.5) * 0.2) * 10) / 10;
+ // Visceral-fat rating on Withings' 1–12 scale; a healthy ~6, easing
+ // down slightly as the body fat trends down.
+ const visceralFat = Math.round(7 - (i / span) * 1.5);
+ const bmi = Math.round((weightKg / (HEIGHT_M * HEIGHT_M)) * 10) / 10;
+
+ const composition: Array<{ type: string; value: number; unit: string }> =
+ [
+ { type: "FAT_MASS", value: fatMass, unit: "kg" },
+ { type: "FAT_FREE_MASS", value: fatFree, unit: "kg" },
+ { type: "LEAN_BODY_MASS", value: leanMass, unit: "kg" },
+ { type: "MUSCLE_MASS", value: muscleMass, unit: "kg" },
+ { type: "TOTAL_BODY_WATER", value: bodyWater, unit: "kg" },
+ { type: "BONE_MASS", value: boneMass, unit: "kg" },
+ { type: "VISCERAL_FAT", value: visceralFat, unit: "rating" },
+ { type: "BODY_MASS_INDEX", value: bmi, unit: "kg/m²" },
+ ];
+ for (const c of composition) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'scale', $6, $6, $6)`,
+ [cuid(), userId, c.type, c.value, c.unit, date],
+ );
+ }
+ }
+
+ // ── Blood glucose (healthy non-diabetic series) ──
+ // A few readings per day over the full window, every value in a healthy
+ // non-diabetic band: fasting ~85–95 mg/dL, a well-controlled post-meal
+ // peak ~110–135, and a calm bedtime ~95–105. Canonical storage is mg/dL
+ // (schema note on BLOOD_GLUCOSE); the per-reading context lands in the
+ // glucose_context column (FASTING / POSTPRANDIAL / BEDTIME) so the
+ // glucose panel can apply the right target band per reading.
+ console.log("Creating blood glucose series...");
+ const glucoseReadings: Array<{
+ context: string;
+ hour: number;
+ base: number;
+ jitter: number;
+ }> = [
+ { context: "FASTING", hour: 7, base: 89, jitter: 6 },
+ { context: "POSTPRANDIAL", hour: 13, base: 122, jitter: 12 },
+ { context: "POSTPRANDIAL", hour: 19, base: 118, jitter: 12 },
+ { context: "BEDTIME", hour: 22, base: 99, jitter: 6 },
+ ];
+ for (let i = 0; i < span; i++) {
+ for (const r of glucoseReadings) {
+ const at = daysAgoAt(days - i, r.hour, Math.floor(Math.random() * 30));
+ const value = Math.round(r.base + (Math.random() - 0.5) * r.jitter);
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'BLOOD_GLUCOSE', $3, 'mg/dL', 'APPLE_HEALTH', $4, 'phone', $5, $5, $5)`,
+ [cuid(), userId, value, r.context, at],
+ );
+ }
+ }
+
+ // ── Cardio fitness + vitals ───────────────
+ // The dashboard + insights surface these device-tracked vitals. All
+ // recent (full window, latest point today), all in healthy/aspirational
+ // bands. Canonical units per schema + VALUE_RANGES: HRV ms,
+ // OXYGEN_SATURATION percent, RESPIRATORY_RATE breaths/min, VO2_MAX
+ // mL/(kg·min), ACTIVE_ENERGY_BURNED kcal, WALKING_RUNNING_DISTANCE metres,
+ // FLIGHTS_CLIMBED count. Source APPLE_HEALTH device_type 'watch'.
+ console.log("Creating cardio fitness + vitals series...");
+ // HRV SDNN: a healthy ~55 ms, gently rising; also write the RMSSD variant.
+ const hrvSdnn = randomWalk(48, 58, span, 5, VALUE_RANGES.HEART_RATE_VARIABILITY);
+ const hrvRmssd = randomWalk(42, 52, span, 5, VALUE_RANGES.HRV_RMSSD);
+ // SpO2: 97–99%.
+ const spo2 = randomWalk(98, 98, span, 0.8, VALUE_RANGES.OXYGEN_SATURATION);
+ // Respiratory rate: 13–16 breaths/min.
+ const respRate = randomWalk(15, 14, span, 0.8, VALUE_RANGES.RESPIRATORY_RATE);
+ // Active energy: ~550 kcal/day.
+ const activeKcal = randomWalk(480, 600, span, 80, VALUE_RANGES.ACTIVE_ENERGY_BURNED);
+ // Walking + running distance (metres): ~6–8 km/day, tracks steps.
+ const distM = randomWalk(6200, 7800, span, 900, VALUE_RANGES.WALKING_RUNNING_DISTANCE);
+ // Flights climbed: ~8–14/day.
+ const flights = randomWalk(9, 13, span, 4, VALUE_RANGES.FLIGHTS_CLIMBED);
+
+ for (let i = 0; i < span; i++) {
+ const date = daysAgo(days - i);
+ const vitals: Array<{ type: string; value: number; unit: string }> = [
+ { type: "HEART_RATE_VARIABILITY", value: Math.round(hrvSdnn[i]), unit: "ms" },
+ { type: "HRV_RMSSD", value: Math.round(hrvRmssd[i]), unit: "ms" },
+ { type: "OXYGEN_SATURATION", value: Math.round(spo2[i] * 10) / 10, unit: "%" },
+ { type: "RESPIRATORY_RATE", value: Math.round(respRate[i] * 10) / 10, unit: "count/min" },
+ { type: "ACTIVE_ENERGY_BURNED", value: Math.round(activeKcal[i]), unit: "kcal" },
+ { type: "WALKING_RUNNING_DISTANCE", value: Math.round(distM[i]), unit: "m" },
+ { type: "FLIGHTS_CLIMBED", value: Math.round(flights[i]), unit: "count" },
+ ];
+ for (const v of vitals) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'watch', $6, $6, $6)`,
+ [cuid(), userId, v.type, v.value, v.unit, date],
+ );
+ }
+ // VO2 max: only every ~10 days (Apple samples it rarely), ~46–49.
+ if (i % 10 === 0) {
+ const vo2 = Math.round((46 + (i / span) * 3) * 10) / 10;
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, 'VO2_MAX', $3, 'mL/(kg·min)', 'APPLE_HEALTH', 'watch', $4, $4, $4)`,
+ [cuid(), userId, vo2, date],
+ );
+ }
+ }
+
+ // ── Recovery / strain / sleep scores (WHOOP-style) ──
+ // The WHOOP-style readiness surface. These are stored as Measurement rows
+ // (type RECOVERY_SCORE / DAY_STRAIN / SLEEP_PERFORMANCE / SLEEP_EFFICIENCY
+ // / SLEEP_CONSISTENCY, unit 'score' or '%') with source WHOOP, distinct
+ // from the COMPUTED engine's own rows. Coherent with the seeded sleep:
+ // well-slept nights pair with high recovery + efficiency, and day-strain
+ // sits in a sustainable band. RECOVERY_SCORE / sleep percentages are
+ // 0–100; DAY_STRAIN is on WHOOP's 0–21 scale.
+ console.log("Creating recovery / strain / sleep scores...");
+ const recovery = randomWalk(68, 78, span, 8, VALUE_RANGES.RECOVERY_SCORE);
+ const dayStrain = randomWalk(11, 13, span, 2.5, VALUE_RANGES.DAY_STRAIN);
+ const sleepPerf = randomWalk(82, 90, span, 6, VALUE_RANGES.SLEEP_PERFORMANCE);
+ const sleepEff = randomWalk(88, 93, span, 4, VALUE_RANGES.SLEEP_EFFICIENCY);
+ const sleepConsistency = randomWalk(74, 84, span, 6, VALUE_RANGES.SLEEP_CONSISTENCY);
+ for (let i = 0; i < span; i++) {
+ // Anchor scores to the morning wake instant so a night's recovery sits
+ // on the day it belongs to.
+ const at = daysAgoAt(days - i, 7, 0);
+ const scores: Array<{ type: string; value: number; unit: string }> = [
+ { type: "RECOVERY_SCORE", value: Math.round(recovery[i]), unit: "score" },
+ { type: "DAY_STRAIN", value: Math.round(dayStrain[i] * 10) / 10, unit: "score" },
+ { type: "SLEEP_PERFORMANCE", value: Math.round(sleepPerf[i]), unit: "%" },
+ { type: "SLEEP_EFFICIENCY", value: Math.round(sleepEff[i]), unit: "%" },
+ { type: "SLEEP_CONSISTENCY", value: Math.round(sleepConsistency[i]), unit: "%" },
+ ];
+ for (const s of scores) {
+ await client.query(
+ `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'WHOOP', 'band', $6, $6, $6)`,
+ [cuid(), userId, s.type, s.value, s.unit, at],
+ );
+ }
+ }
+
+ // ── Workouts ──────────────────────────────
+ // ~3–4 sessions/week across the window: a mix of running, strength, and
+ // cycling, with the fields the workout tiles render — duration, energy,
+ // avg/max HR, distance (running/cycling). Each workout carries a sparse
+ // HR sample series in WorkoutSamples (the per-workout HR chart) and its
+ // per-workout strain in metadata (WORKOUT_STRAIN lives on the workout row
+ // per the schema note, not as a free-floating Measurement). Distances in
+ // metres, energy in kcal, durations in seconds — matching the model.
+ console.log("Creating workouts...");
+ const workoutPlan: Array<{
+ sport: string;
+ durationMin: number;
+ kcal: number;
+ avgHr: number;
+ maxHr: number;
+ distanceM: number | null;
+ strain: number;
+ }> = [
+ { sport: "running", durationMin: 38, kcal: 420, avgHr: 148, maxHr: 171, distanceM: 6500, strain: 13.4 },
+ { sport: "strength", durationMin: 52, kcal: 360, avgHr: 118, maxHr: 152, distanceM: null, strain: 10.2 },
+ { sport: "cycling", durationMin: 65, kcal: 520, avgHr: 134, maxHr: 158, distanceM: 24000, strain: 12.1 },
+ ];
+ // Step backwards through the window placing ~2 sessions a week per slot
+ // so the cadence lands around 3–4/week without overlapping.
+ let planIdx = 0;
+ for (let dayOffset = days - 1; dayOffset >= 1; dayOffset -= 2) {
+ // Skip ~25% of slots so the week isn't perfectly regular.
+ if (Math.random() < 0.25) continue;
+ const plan = workoutPlan[planIdx % workoutPlan.length];
+ planIdx += 1;
+ const startHour = 17 + Math.floor(Math.random() * 3);
+ const startedAt = daysAgoAt(dayOffset, startHour, Math.floor(Math.random() * 50));
+ const durationSec = plan.durationMin * 60;
+ const endedAt = new Date(startedAt.getTime() + durationSec * 1000);
+ const workoutId = cuid();
+ await client.query(
+ `INSERT INTO workouts (id, user_id, sport_type, started_at, ended_at, duration_sec, total_energy_kcal, total_distance_m, avg_heart_rate, max_heart_rate, source, metadata, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'APPLE_HEALTH', $11, $4, $4)`,
+ [
+ workoutId,
+ userId,
+ plan.sport,
+ startedAt,
+ endedAt,
+ durationSec,
+ plan.kcal,
+ plan.distanceM,
+ plan.avgHr,
+ plan.maxHr,
+ JSON.stringify({ workoutStrain: plan.strain }),
+ ],
+ );
+ // A sparse HR series — one sample every ~5 minutes across the session,
+ // rising into the working band and easing at the end. The per-workout
+ // HR chart renders straight from this WorkoutSamples blob.
+ const sampleCount = Math.max(2, Math.round(plan.durationMin / 5));
+ const samples: Array<{ t: string; hr: number }> = [];
+ for (let s = 0; s < sampleCount; s++) {
+ const frac = s / (sampleCount - 1);
+ // Warm up from ~60% to the avg, peak near the max around 70% through.
+ const peakBias = 1 - Math.abs(frac - 0.7) * 0.6;
+ const hr = Math.round(
+ plan.avgHr + (plan.maxHr - plan.avgHr) * peakBias * 0.7,
+ );
+ const t = new Date(startedAt.getTime() + frac * durationSec * 1000);
+ samples.push({ t: t.toISOString(), hr });
+ }
+ await client.query(
+ `INSERT INTO workout_samples (id, workout_id, samples, sample_count, created_at)
+ VALUES ($1, $2, $3, $4, NOW())`,
+ [cuid(), workoutId, JSON.stringify(samples), samples.length],
+ );
+ }
+
// ── Medications ───────────────────────────
console.log("Creating medications...");
@@ -665,9 +919,14 @@ async function seed() {
await client.query("COMMIT");
console.log("\nDemo data seeded successfully!");
console.log(` User: demo / demo123demo123`);
- console.log(` Measurements: ~${span * 8} entries (8 types, today included)`);
+ console.log(` Measurements: weight, BP, pulse, resting HR, body fat, steps, sleep`);
+ console.log(` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`);
+ console.log(` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`);
+ console.log(` Cardio + vitals: HRV (SDNN + RMSSD), SpO2, resp. rate, VO2max, active energy, distance, flights`);
+ console.log(` Scores: recovery, day strain, sleep performance/efficiency/consistency`);
console.log(` Sleep: 7 per-stage nights (4 stages each) + daily aggregate`);
console.log(` Medications: 3 (high compliance, today on-track)`);
+ console.log(` Workouts: ~3-4/week (running, strength, cycling) with HR samples`);
console.log(` Mood: ~${Math.round(span * 0.95)} entries`);
console.log(` Vorsorge reminders: 2 (dental, annual physical)`);
console.log(` Lab panel: ${labResults.length} biomarkers`);
From 57242b9884f49a26a2d19d2bec8aeff396d9ee28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:11:28 +0200
Subject: [PATCH 36/71] refactor(ai): extract shared prompt contracts as a
single source
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The grounding, tone/banned-opener, GLP-1 dose-safety, metric-identifier
ban and forbidden-filler rules were independently encoded across the
comprehensive, status, Coach and narrative prompts and had drifted. Move
the canonical wording into `prompts/shared-contracts.ts` (locale-keyed
fragments) and compose each surface from it.
Where the surfaces had drifted, adopt the stricter wording: most notably
the GLP-1 dose-prescription contract — previously present only in the
comprehensive and Coach prompts — now reaches the status cards and the
period narrative too. A cross-surface coverage test asserts every
enforced fragment appears verbatim in every surface, in de and en.
---
src/lib/ai/coach/system-prompt.ts | 17 ++-
.../assessment-prompt-contract.test.ts | 5 +-
.../shared-contracts-coverage.test.ts | 103 ++++++++++++++++
src/lib/ai/prompts/base-system.ts | 28 ++++-
src/lib/ai/prompts/insight-generator.ts | 24 ++++
src/lib/ai/prompts/shared-contracts.ts | 115 ++++++++++++++++++
.../narrative/period-narrative-generate.ts | 19 ++-
7 files changed, 302 insertions(+), 9 deletions(-)
create mode 100644 src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts
create mode 100644 src/lib/ai/prompts/shared-contracts.ts
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 826b7af66..35aad7aad 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -29,6 +29,7 @@ import {
SELF_REPORT_FENCE_END,
fenceSelfReport,
} from "@/lib/ai/coach/self-report-fence";
+import { composeSharedContracts } from "@/lib/ai/prompts/shared-contracts";
const COACH_PROMPT_EN = `You are the HealthLog Coach — the user's warm, motivating advisor. You
sit alongside them as they look at their own health data — blood pressure,
@@ -863,8 +864,22 @@ export function getCoachSystemPrompt(
base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK[locale];
}
}
+ // v1.18.7 (HIGH-2) — append the shared cross-surface contracts (canonical
+ // wording, single source of truth in `shared-contracts.ts`) for the
+ // hand-composed de/en bodies. The native FR/ES/IT/PL bodies carry their own
+ // per-locale safety wording from the contract matrix, so they are left as-is.
+ const withContracts =
+ locale === "de" || locale === "en"
+ ? `${base}\n\nSHARED CONTRACTS\n\n${composeSharedContracts(locale, [
+ "grounding",
+ "toneContract",
+ "safetyGlp1",
+ "metricIdentifierBan",
+ "forbiddenFiller",
+ ])}`
+ : base;
const prefix = buildPrefsPrefix(locale, prefs);
- const withPrefix = prefix ? `${prefix}\n\n${base}` : base;
+ const withPrefix = prefix ? `${prefix}\n\n${withContracts}` : withContracts;
// v1.18.7 — article-awareness: a compact catalog of the public /learn
// guides so the Coach can point the user at the relevant deeper read. The
// block is locale-independent (English topic labels + URLs) and the model
diff --git a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts
index 8f9178570..780982930 100644
--- a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts
+++ b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts
@@ -34,8 +34,11 @@ describe("base-system assessment prompt — grounding floor preserved", () => {
it(`${locale}: keeps the filler-phrase ban`, () => {
const p = getBaseSystemPrompt(locale);
+ // v1.18.7 — the filler ban is now sourced from the single shared
+ // contract fragment ("FORBIDDEN FILLER"); the banned-phrase list is
+ // preserved verbatim.
if (locale === "en") {
- expect(p).toMatch(/FORBIDDEN PHRASES/);
+ expect(p).toMatch(/FORBIDDEN FILLER/);
expect(p).toContain("make sure to get enough sleep");
expect(p).toContain("consult your doctor");
} else {
diff --git a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts
new file mode 100644
index 000000000..e2c790a07
--- /dev/null
+++ b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts
@@ -0,0 +1,103 @@
+/**
+ * v1.18.7 (HIGH-2) — cross-surface shared-contract coverage.
+ *
+ * The grounding / tone / GLP-1-safety / metric-identifier-ban / forbidden-
+ * filler contracts live once in `shared-contracts.ts`. These guards assert
+ * that each surface that enforces a contract carries the canonical fragment
+ * VERBATIM, in both hand-composed locales (de/en). A future edit that lets a
+ * surface drift from the shared wording — the exact failure mode HIGH-2
+ * documents — trips here instead of shipping silently.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ grounding,
+ toneContract,
+ safetyGlp1,
+ metricIdentifierBan,
+ forbiddenFiller,
+ type ContractLocale,
+} from "../shared-contracts";
+import { getBaseSystemPrompt } from "../base-system";
+import { getStrictInsightsSystemPrompt } from "../insight-generator";
+import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
+
+// The narrative system prompts are module-private; re-derive the same
+// composed shared block the generator appends, and assert each fragment is
+// present by checking the generator module's exported builder indirectly.
+import { SYSTEM_PROMPTS_FOR_TEST } from "@/lib/insights/narrative/period-narrative-generate";
+
+const LOCALES: ContractLocale[] = ["de", "en"];
+
+/** Which contracts each surface enforces (and must carry verbatim). */
+const SURFACES: Record<
+ string,
+ {
+ prompt: (l: ContractLocale) => string;
+ contracts: Record[];
+ }
+> = {
+ "insight-generator (comprehensive)": {
+ prompt: (l) => getStrictInsightsSystemPrompt(l),
+ contracts: [
+ grounding,
+ toneContract,
+ safetyGlp1,
+ metricIdentifierBan,
+ forbiddenFiller,
+ ],
+ },
+ "base-system (status cards)": {
+ prompt: (l) => getBaseSystemPrompt(l),
+ contracts: [
+ grounding,
+ toneContract,
+ safetyGlp1,
+ metricIdentifierBan,
+ forbiddenFiller,
+ ],
+ },
+ coach: {
+ prompt: (l) => getCoachSystemPrompt(l),
+ contracts: [
+ grounding,
+ toneContract,
+ safetyGlp1,
+ metricIdentifierBan,
+ forbiddenFiller,
+ ],
+ },
+ // The narrative is descriptive-only, so it enforces every contract EXCEPT
+ // the motivating tone contract.
+ "period-narrative": {
+ prompt: (l) => SYSTEM_PROMPTS_FOR_TEST[l],
+ contracts: [grounding, safetyGlp1, metricIdentifierBan, forbiddenFiller],
+ },
+};
+
+describe("shared-contract cross-surface coverage", () => {
+ for (const [surface, { prompt, contracts }] of Object.entries(SURFACES)) {
+ for (const locale of LOCALES) {
+ const text = prompt(locale);
+ for (const contract of contracts) {
+ const fragment = contract[locale];
+ // Use a short, stable slice of the fragment as the assertion anchor —
+ // the full fragment is long but must be present verbatim.
+ it(`${surface} carries every enforced contract verbatim (${locale})`, () => {
+ expect(text).toContain(fragment);
+ });
+ }
+ }
+ }
+
+ it("the GLP-1 dose-safety contract reaches all four surfaces", () => {
+ for (const locale of LOCALES) {
+ expect(getStrictInsightsSystemPrompt(locale)).toContain(
+ safetyGlp1[locale],
+ );
+ expect(getBaseSystemPrompt(locale)).toContain(safetyGlp1[locale]);
+ expect(getCoachSystemPrompt(locale)).toContain(safetyGlp1[locale]);
+ expect(SYSTEM_PROMPTS_FOR_TEST[locale]).toContain(safetyGlp1[locale]);
+ }
+ });
+});
diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts
index 63ff478cf..08aeaf6f5 100644
--- a/src/lib/ai/prompts/base-system.ts
+++ b/src/lib/ai/prompts/base-system.ts
@@ -1,4 +1,11 @@
import type { Locale } from "@/lib/i18n/config";
+import {
+ grounding,
+ toneContract,
+ safetyGlp1,
+ metricIdentifierBan,
+ forbiddenFiller,
+} from "./shared-contracts";
/**
* Version of the per-metric assessment base prompt + its signal contract.
@@ -151,13 +158,24 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [
- Jüngste Messung deutlich älter als ~7 Tage (dataCoverage.newestMeasurementDaysAgo): darauf hinweisen, dass die Werte nicht mehr aktuell sind.
- Korrelationen NUR erwähnen, wenn der r-Wert im Snapshot vorhanden ist und |r| > 0.4 ist. Fehlt das Feld, keine Korrelation interpretieren oder erfinden. Immer als "Zusammenhang" formulieren, nie als "Ursache".`,
},
+ // v1.18.7 (HIGH-2) — sourced from the single shared-contract fragment so a
+ // forbidden-filler edit lands on every surface at once.
+ { id: "forbidden", en: forbiddenFiller.en, de: forbiddenFiller.de },
+ // v1.18.7 (HIGH-2) — the shared metric-identifier ban + the GLP-1 dose
+ // safety contract. The status cards can surface a named medication via the
+ // medication-compliance snapshot, so the dose-safety contract (previously
+ // absent from this surface) now applies here too.
{
- id: "forbidden",
- en: `FORBIDDEN PHRASES (they signal ungrounded filler — never emit, except in the disclaimer):
-"make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor".`,
- de: `VERBOTENE FLOSKELN (signalisieren ungegroundeten Fülltext — nie ausgeben, außer im disclaimer):
-"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".`,
+ id: "metricIdentifierBan",
+ en: metricIdentifierBan.en,
+ de: metricIdentifierBan.de,
},
+ { id: "safetyGlp1", en: safetyGlp1.en, de: safetyGlp1.de },
+ // v1.18.7 (HIGH-2) — the shared grounding + tone contracts, canonical
+ // wording. The metric-specific build/tone sections above stay; these pin
+ // the cross-surface wording so an edit lands here and everywhere at once.
+ { id: "sharedGrounding", en: grounding.en, de: grounding.de },
+ { id: "sharedTone", en: toneContract.en, de: toneContract.de },
{
id: "examples",
en: `EXAMPLES (they illustrate form and grounding — do not copy them verbatim; every assessment uses the real snapshot numbers):
diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts
index 0aa9ae197..5d6a4e315 100644
--- a/src/lib/ai/prompts/insight-generator.ts
+++ b/src/lib/ai/prompts/insight-generator.ts
@@ -29,6 +29,13 @@ import {
buildNativeInsightsPrompt,
buildOutOfScopeRefusal,
} from "./native-prompts";
+import {
+ grounding,
+ toneContract,
+ safetyGlp1,
+ metricIdentifierBan,
+ forbiddenFiller,
+} from "./shared-contracts";
/** Stable identifier for the active system prompt revision. */
export const PROMPT_VERSION = "4.29.0" as const;
@@ -745,6 +752,23 @@ user-logged event with a neutral label + detail. Hard cap 20 entries.`,
Eintrag verweist auf ein reales, vom Nutzer geloggtes Ereignis mit
neutralem Label + Detail. Höchstgrenze: 20 Einträge.`,
},
+ // v1.18.7 (HIGH-2) — the shared cross-surface contracts as the canonical
+ // source. The detailed rules above stay for this calibrated surface; these
+ // restate the five contracts in the wording every surface now shares, so a
+ // future safety edit is made once in `shared-contracts.ts`.
+ { id: "sharedGrounding", en: grounding.en, de: grounding.de },
+ { id: "sharedTone", en: toneContract.en, de: toneContract.de },
+ { id: "sharedSafetyGlp1", en: safetyGlp1.en, de: safetyGlp1.de },
+ {
+ id: "sharedMetricIdentifierBan",
+ en: metricIdentifierBan.en,
+ de: metricIdentifierBan.de,
+ },
+ {
+ id: "sharedForbiddenFiller",
+ en: forbiddenFiller.en,
+ de: forbiddenFiller.de,
+ },
{
id: "language",
en: `LANGUAGE
diff --git a/src/lib/ai/prompts/shared-contracts.ts b/src/lib/ai/prompts/shared-contracts.ts
new file mode 100644
index 000000000..72aa3fdfa
--- /dev/null
+++ b/src/lib/ai/prompts/shared-contracts.ts
@@ -0,0 +1,115 @@
+/**
+ * Single source of truth for the cross-surface AI prompt contracts.
+ *
+ * Before this module the grounding, tone/banned-opener, GLP-1 dose-safety,
+ * metric-identifier ban and forbidden-filler rules were independently encoded
+ * in `insight-generator.ts` (comprehensive briefing), `base-system.ts` (status
+ * cards), `coach/system-prompt.ts` (Coach) and `period-narrative-generate.ts`
+ * (narrative) — and had DRIFTED (different banned-opener lists, a GLP-1
+ * contract present only on two of the four surfaces, …). A safety-rule edit
+ * had to be made in up to four places and was easy to miss in one.
+ *
+ * These named, locale-keyed fragments are now the one place each contract
+ * lives. Each surface composes the fragment it enforces. Where the surfaces
+ * had drifted, the STRICTER / more-complete wording was adopted here (most
+ * notably the GLP-1 dose-prescription contract, which previously lived only
+ * in the comprehensive + Coach prompts and is now enforced on every surface
+ * that can ever name a medication).
+ *
+ * Only `de` / `en` are modelled here — they are the two hand-composed
+ * surfaces. The FR/ES/IT/PL bodies are assembled from the safety-contract
+ * matrix in `native-prompts.ts`, which already carries its own per-locale
+ * safety wording.
+ *
+ * The cross-surface coverage test (`shared-contracts-coverage.test.ts`)
+ * asserts each fragment appears verbatim in every surface that enforces it.
+ */
+
+export type ContractLocale = "de" | "en";
+
+/** A named contract fragment with both hand-composed locale texts. */
+export type SharedContract = Record;
+
+/**
+ * Zero-hallucination grounding: every claim traces to a number in the
+ * snapshot, and the comparison is the user's OWN baseline, never a population
+ * norm.
+ */
+export const grounding: SharedContract = {
+ en: `GROUNDING — ZERO HALLUCINATIONS
+Every claim must trace to a number visible in the snapshot you were given. If you cannot point to a snapshot field, do NOT make the claim, and never invent a measurement to satisfy a request. Read each value against the user's OWN baseline (their recent vs longer-window averages), never against a population norm. State a correlation only when its r-value is present and |r| > 0.4, always as an "association", never a "cause".`,
+ de: `ERDUNG — NULL HALLUZINATIONEN
+Jede Aussage muss auf einer Zahl beruhen, die im übergebenen Snapshot sichtbar ist. Lässt sich die Aussage keinem Snapshot-Feld zuordnen, lass sie weg und erfinde nie einen Messwert, um einer Anfrage zu entsprechen. Ordne jeden Wert gegen die EIGENE Baseline des Nutzers ein (jüngeres vs. längeres Mittel), nie gegen eine Bevölkerungsnorm. Nenne einen Zusammenhang nur, wenn der r-Wert vorhanden und |r| > 0,4 ist — immer als "Zusammenhang", nie als "Ursache".`,
+};
+
+/**
+ * Tone contract + the single banned-opener list. Warm, motivating,
+ * autonomy-supporting; encouragement EARNED by the numbers; no generic
+ * data-quality compliment opener.
+ */
+export const toneContract: SharedContract = {
+ en: `TONE — a warm, motivating advisor, never clinical or alarming
+Write in the second person, warm and direct. When the data earns it, name the genuine win plainly and build a little momentum — the person should feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment. Be autonomy-supporting ("worth a try", "can help", never "you must"). Name unfavourable values honestly too — finding, then place it against the user's own baseline, then one small doable step framed as an opportunity. Never alarm, never moralise, never diagnose. No platitudes and no bare number-echoing. Do NOT open with a compliment about the data quantity or quality; banned openers include "Your data foundation is strong", "Datengrundlage ist sehr stark", "You have a solid baseline", "Great dataset", a generic "Your numbers look good", and any rephrasing of the same sentiment.`,
+ de: `TONALITÄT — ein warmer, motivierender Begleiter, nie klinisch oder alarmierend
+Schreibe in der zweiten Person, warm und direkt. Wenn die Daten es hergeben, benenne den echten Erfolg klar und baue ein wenig Schwung auf — die Person soll sich gesehen und unterstützt fühlen, nicht belehrt. Die Ermutigung muss durch die Zahlen VERDIENT sein, nie ein reflexhaftes Kompliment. Sei autonomie-unterstützend ("einen Versuch wert", "kann helfen", nie "du musst"). Benenne auch ungünstige Werte ehrlich — Befund, dann gegen die eigene Baseline einordnen, dann ein kleiner machbarer Schritt, als Chance formuliert. Nie alarmierend, nie moralisierend, nie diagnostisch. Keine Floskeln und keine bloße Zahlenwiederholung. Beginne NICHT mit einem Kompliment über Datenmenge oder -qualität; verbotene Eröffnungen sind u.a. "Datengrundlage ist sehr stark", "Your data foundation is strong", "Du hast eine solide Baseline", "Großartiger Datensatz", ein generisches "Deine Werte sehen gut aus" und jede sinngemäße Umformulierung.`,
+};
+
+/**
+ * GLP-1 dose-prescription safety contract. Previously present ONLY in the
+ * comprehensive + Coach prompts; now enforced on every surface that can name
+ * a medication (the stricter, more-complete posture).
+ */
+export const safetyGlp1: SharedContract = {
+ en: `GLP-1 DOSE SAFETY (contract, not style)
+NEVER prescribe, recommend, or modify medication doses, even when the snapshot names a GLP-1 receptor agonist (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). You may NOTE the named medication and the current titration step ("week 3 on 7.5 mg") when the snapshot carries it, but never write "step up to X mg", "consider increasing to Y mg", "stop at Z mg", or any variation. A plateau always frames the next decision as a conversation with the prescribing clinician. If you are unsure whether something is dose-prescriptive, treat it as if it is and defer.`,
+ de: `GLP-1-DOSIS-SICHERHEIT (Vertrag, kein Stil)
+Verschreibe, empfiehl oder ändere NIEMALS Medikamenten-Dosen, auch wenn der Snapshot einen GLP-1-Rezeptoragonisten benennt (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Du darfst den benannten Wirkstoff und die aktuelle Titrationsstufe NENNEN ("Woche 3 auf 7,5 mg"), wenn der Snapshot sie trägt, aber schreibe nie "erhöhe auf X mg", "erwäge die nächste Stufe Y mg", "bleibe auf Z mg" oder eine Variante davon. Ein Plateau rahmt die nächste Entscheidung immer als Gespräch mit der behandelnden Ärztin. Bist du unsicher, ob etwas dosis-präskriptiv ist, behandle es so — und verweise an die Klinik.`,
+};
+
+/**
+ * Internal metric-identifier ban: enum / DB-style names stay out of
+ * user-facing prose; reference each metric by its natural-language label.
+ */
+export const metricIdentifierBan: SharedContract = {
+ en: `METRIC IDENTIFIERS STAY OUT OF PROSE
+Never write database / enum-style names like "BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE", "MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY", "RESTING_HEART_RATE", "ACTIVE_ENERGY_BURNED", "FLIGHTS_CLIMBED", "WALKING_RUNNING_DISTANCE", "VO2_MAX", "BODY_TEMPERATURE" or "SLEEP_DURATION" in any user-facing string, and never write the literal "metric:" chart-token in prose. Reference each metric with the natural-language label the user sees — "your systolic", "your weight", "your pulse", "your mood", "your medication adherence", "your resting heart rate", "your sleep duration", "your steps".`,
+ de: `METRIK-IDENTIFIER GEHÖREN NICHT IN DEN FLIESSTEXT
+Schreibe niemals Datenbank- bzw. Enum-Namen wie "BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE", "MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY", "RESTING_HEART_RATE", "ACTIVE_ENERGY_BURNED", "FLIGHTS_CLIMBED", "WALKING_RUNNING_DISTANCE", "VO2_MAX", "BODY_TEMPERATURE" oder "SLEEP_DURATION" in nutzersichtbare Strings, und schreibe nie das wörtliche "metric:"-Chart-Token in den Fließtext. Verweise auf jede Metrik mit der natürlichsprachlichen Bezeichnung, die der Nutzer sieht — "deine Systole", "dein Gewicht", "dein Puls", "deine Stimmung", "deine Medikamentenadhärenz", "dein Ruhepuls", "deine Schlafdauer", "deine Schritte".`,
+};
+
+/**
+ * Forbidden-filler list: the ungrounded platitudes that signal filler.
+ */
+export const forbiddenFiller: SharedContract = {
+ en: `FORBIDDEN FILLER (signals ungrounded platitudes — never emit, except in a disclaimer)
+"make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor".`,
+ de: `VERBOTENE FLOSKELN (signalisieren ungegroundeten Fülltext — nie ausgeben, außer im Disclaimer)
+"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".`,
+};
+
+/**
+ * The full set, keyed by name — consumed by surfaces and by the
+ * cross-surface coverage test.
+ */
+export const SHARED_CONTRACTS = {
+ grounding,
+ toneContract,
+ safetyGlp1,
+ metricIdentifierBan,
+ forbiddenFiller,
+} as const satisfies Record;
+
+export type SharedContractName = keyof typeof SHARED_CONTRACTS;
+
+/**
+ * Compose the named contract fragments for a locale, joined by a blank line.
+ * Used by the template-literal surfaces (Coach, period narrative) to append
+ * the shared block; the section-array surfaces (base-system,
+ * insight-generator) splice the fragments in directly.
+ */
+export function composeSharedContracts(
+ locale: ContractLocale,
+ names: readonly SharedContractName[],
+): string {
+ return names.map((name) => SHARED_CONTRACTS[name][locale]).join("\n\n");
+}
diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts
index 55c63c73c..9e1efaa8e 100644
--- a/src/lib/insights/narrative/period-narrative-generate.ts
+++ b/src/lib/insights/narrative/period-narrative-generate.ts
@@ -40,6 +40,7 @@ import {
buildDeterministicNarrative,
DETERMINISTIC_PROVIDER_TYPE,
} from "@/lib/insights/narrative/period-narrative-deterministic";
+import { composeSharedContracts } from "@/lib/ai/prompts/shared-contracts";
/**
* Stable identifier for the narrative prompt revision. Bumped whenever the
@@ -101,7 +102,9 @@ Hard rules:
- The listed drivers already survived statistical multiple-comparison control; restate them only as associations and keep their conservative meaning.
- No diagnosis, no medical advice, no alarm. Calm, factual, second person ("your").
- 2 to 4 short sentences. Plain text only — no markdown, no headings, no bullet points, no emojis.
-- If the context is thin, say plainly that there is little to report this period rather than inventing detail.`;
+- If the context is thin, say plainly that there is little to report this period rather than inventing detail.
+
+${composeSharedContracts("en", ["grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`;
const SYSTEM_PROMPT_DE = `Du fasst den Gesundheits-Tracking-ZEITRAUM einer Person (eine Woche oder einen Monat) für diese Person zusammen.
Prompt-Version: ${NARRATIVE_PROMPT_VERSION}.
@@ -112,7 +115,19 @@ Feste Regeln:
- Die genannten Zusammenhänge haben bereits die statistische Mehrfachvergleichskorrektur überstanden; gib sie nur als Assoziationen wieder und bewahre ihre vorsichtige Bedeutung.
- Keine Diagnose, kein medizinischer Rat, keine Panik. Ruhig, sachlich, in der zweiten Person ("dein").
- 2 bis 4 kurze Sätze. Nur Klartext — kein Markdown, keine Überschriften, keine Aufzählungen, keine Emojis.
-- Wenn der Kontext dünn ist, sage klar, dass es in diesem Zeitraum wenig zu berichten gibt, statt Details zu erfinden.`;
+- Wenn der Kontext dünn ist, sage klar, dass es in diesem Zeitraum wenig zu berichten gibt, statt Details zu erfinden.
+
+${composeSharedContracts("de", ["grounding", "safetyGlp1", "metricIdentifierBan", "forbiddenFiller"])}`;
+
+/**
+ * Test-only view of the composed system prompts (incl. the appended shared
+ * contracts), so the cross-surface coverage test can assert fragment presence
+ * without re-deriving the composition.
+ */
+export const SYSTEM_PROMPTS_FOR_TEST: Record<"de" | "en", string> = {
+ de: SYSTEM_PROMPT_DE,
+ en: SYSTEM_PROMPT_EN,
+};
/** Render the typed context into a compact, model-readable block. */
export function buildNarrativeUserPrompt(
From e7d14a07e81d1c174812b665a0d678665d5e479b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:11:51 +0200
Subject: [PATCH 37/71] docs(ai): record the last-working-provider coordination
decision
The per-worker in-process "last working provider" hint stays volatile
and is deliberately NOT folded into the durable Postgres health ledger.
The ledger already shares the load-bearing skip signal across workers;
the only uncoordinated piece is a soft reorder whose worst case is one
redundant first-hop per worker per hour. Promoting it to a per-request
ledger write would add hot-path write amplification for a negligible
saving. Documented inline so the trade-off is explicit.
---
src/lib/ai/provider-runner.ts | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts
index 16a4c4bd8..cfb8e5795 100644
--- a/src/lib/ai/provider-runner.ts
+++ b/src/lib/ai/provider-runner.ts
@@ -53,6 +53,18 @@ const NEVER_SKIP: ReadonlySet = new Set(["local"]);
* across workers — when scaling out we'd move this to Redis, but for
* v1.4.16 the per-worker cache is enough to absorb the typical
* "polling reload" pattern.
+ *
+ * v1.18.7 (LOW-8) — DECISION: the volatile per-worker hint is left
+ * in-process and NOT folded into the durable Postgres health ledger. The
+ * ledger already shares the load-bearing signal across workers (the
+ * skip-hint reorder in `resolveChainOrder` pushes a dead-credential /
+ * backoff provider to the chain tail for every worker), so the only thing
+ * uncoordinated is a soft "tried this one last, put it first" reorder whose
+ * worst case is a single redundant first-hop per worker per hour — exactly
+ * the bound the v1.18.7 AI audit deemed acceptable at single-maintainer
+ * scale. Promoting the hint to a per-request ledger write would add write
+ * amplification on the hot path for a negligible saving; revisit only if a
+ * multi-worker deploy shows measurable redundant-hop cost.
*/
const LAST_WORKING_TTL_MS = 60 * 60 * 1000; // 1h
From e31f9690e28d06c2b848b62a746567779d8713c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:14:57 +0200
Subject: [PATCH 38/71] refactor(settings): compose single-card sections from
SettingsCard
Swap the hand-rolled bg-card border-border rounded-xl border p-4 sm:p-6
container for SettingsCard across the single-card sections (integration
cards, notification channel cards, coach prefs, cycle, modules, mood
reminder, thresholds, dashboard-layout, about-me, low-stock, and the
health-record export panel). Drops the sm: padding split and the
primitive bypass; landmark sections keep their element via as="section".
---
src/components/settings/about-me-section.tsx | 8 ++--
.../settings/ai/ai-insights-card.tsx | 5 +-
.../settings/ai/disable-coach-card.tsx | 7 +--
.../settings/coach-memory-section.tsx | 8 ++--
src/components/settings/coach-nudge-card.tsx | 7 +--
.../settings/coach-prefs-section.tsx | 6 +--
.../settings/cycle-tracking-card.tsx | 5 +-
.../settings/dashboard-layout-section.tsx | 7 +--
src/components/settings/email-card.tsx | 5 +-
.../settings/health-record-export-panel.tsx | 12 ++---
.../insights-overview-arrange-section.tsx | 7 +--
.../settings/insights-pill-order-section.tsx | 8 ++--
.../settings/integrations/fitbit-card.tsx | 6 +--
.../settings/integrations/nightscout-card.tsx | 6 +--
.../integrations/oauth-provider-card.tsx | 6 +--
.../settings/integrations/whoop-card.tsx | 5 +-
.../settings/integrations/withings-card.tsx | 5 +-
src/components/settings/low-stock-card.tsx | 8 ++--
src/components/settings/modules-section.tsx | 5 +-
.../settings/mood-reminder-card.tsx | 7 +--
src/components/settings/ntfy-card.tsx | 5 +-
src/components/settings/settings-card.tsx | 46 ++++++++++++++-----
src/components/settings/sources-section.tsx | 5 +-
src/components/settings/telegram-card.tsx | 5 +-
.../settings/thresholds-editor-section.tsx | 7 +--
src/components/settings/web-push-card.tsx | 5 +-
src/components/settings/webhook-card.tsx | 5 +-
src/generated | 1 +
28 files changed, 129 insertions(+), 83 deletions(-)
create mode 120000 src/generated
diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx
index aeb554416..b92cace67 100644
--- a/src/components/settings/about-me-section.tsx
+++ b/src/components/settings/about-me-section.tsx
@@ -33,6 +33,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -181,10 +182,11 @@ export function AboutMeSection({
}
return (
-
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
-
+
);
}
diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx
index 0bd064e26..d761bc7da 100644
--- a/src/components/settings/ai/ai-insights-card.tsx
+++ b/src/components/settings/ai/ai-insights-card.tsx
@@ -21,6 +21,7 @@ import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
@@ -133,7 +134,7 @@ export function AiInsightsCard({
}
return (
-
+
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
@@ -192,7 +193,7 @@ export function AiInsightsCard({
}
/>
-
+
);
}
diff --git a/src/components/settings/ai/disable-coach-card.tsx b/src/components/settings/ai/disable-coach-card.tsx
index 9e1339768..73852c7d8 100644
--- a/src/components/settings/ai/disable-coach-card.tsx
+++ b/src/components/settings/ai/disable-coach-card.tsx
@@ -33,6 +33,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { MessageCircleHeart } from "lucide-react";
import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiPatch } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
@@ -140,10 +141,10 @@ export function DisableCoachCard({
}
return (
-
+
);
}
diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx
index d687c8901..f2a1c3670 100644
--- a/src/components/settings/coach-memory-section.tsx
+++ b/src/components/settings/coach-memory-section.tsx
@@ -35,6 +35,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { formatDateOrRelative } from "@/lib/format";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -134,10 +135,11 @@ export function CoachMemorySection({
}, [facts]);
return (
-
{t("settings.ai.coachMemory.summaryNote")}
-
+
);
}
diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx
index 132f3ed87..61e2fc8d1 100644
--- a/src/components/settings/coach-nudge-card.tsx
+++ b/src/components/settings/coach-nudge-card.tsx
@@ -21,6 +21,7 @@ import { MessageCircleHeart } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { NativeSelect } from "@/components/ui/native-select";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -170,9 +171,9 @@ export function CoachNudgeCard({
}
return (
-
+
);
}
diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx
index 22b580069..b0ab641ea 100644
--- a/src/components/settings/coach-prefs-section.tsx
+++ b/src/components/settings/coach-prefs-section.tsx
@@ -34,6 +34,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
@@ -106,8 +107,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
const isLoading = isAuthenticated && !persisted;
return (
-
-
+
);
}
diff --git a/src/components/settings/cycle-tracking-card.tsx b/src/components/settings/cycle-tracking-card.tsx
index 2a7cbf6af..d5c3d4a13 100644
--- a/src/components/settings/cycle-tracking-card.tsx
+++ b/src/components/settings/cycle-tracking-card.tsx
@@ -7,6 +7,7 @@ import { CalendarHeart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -59,7 +60,7 @@ export function CycleTrackingCard({
const enabled = data?.cycleTrackingEnabled ?? false;
return (
-
+
) : null}
-
+
);
}
diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx
index 581050345..b302eeabb 100644
--- a/src/components/settings/dashboard-layout-section.tsx
+++ b/src/components/settings/dashboard-layout-section.tsx
@@ -51,6 +51,7 @@ import {
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules";
+import { SettingsCard } from "@/components/settings/settings-card";
/**
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
@@ -316,9 +317,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const dirty = draft !== null && layout !== null;
return (
-
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
right-align-on-desktop contract as Account → Password +
@@ -577,7 +578,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
: t("dashboard.layoutCustomized")}
)}
-
+
);
}
diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx
index f0b5b5a90..0a2801501 100644
--- a/src/components/settings/email-card.tsx
+++ b/src/components/settings/email-card.tsx
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
@@ -74,7 +75,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
if (settings && !settings.smtpConfigured) return null;
return (
-
+
-
+
);
}
diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx
index 213b4e1e5..fb46b085c 100644
--- a/src/components/settings/health-record-export-panel.tsx
+++ b/src/components/settings/health-record-export-panel.tsx
@@ -23,6 +23,7 @@
import { useId, useState } from "react";
import { ChevronDown, Download, FileText, Loader2 } from "lucide-react";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -191,15 +192,10 @@ export function HealthRecordExportPanel() {
}
return (
-
-
+
);
}
diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx
index 35b3fd4b9..e73d5ec09 100644
--- a/src/components/settings/insights-overview-arrange-section.tsx
+++ b/src/components/settings/insights-overview-arrange-section.tsx
@@ -9,6 +9,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
+import { SettingsCard } from "@/components/settings/settings-card";
import { type InsightsSectionId } from "@/lib/insights-layout";
/**
@@ -67,12 +68,12 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
defaults over the user's real saved layout (the same QA-L1 gate the
mother page applies to its "Anpassen" toggle). */}
{isLoading ? (
-
-
+
) : (
@@ -286,7 +288,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
))}
)}
-
+
);
}
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx
index 1b280ad7d..666bd4607 100644
--- a/src/components/settings/integrations/fitbit-card.tsx
+++ b/src/components/settings/integrations/fitbit-card.tsx
@@ -34,6 +34,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
@@ -188,9 +189,8 @@ export function FitbitCard({
: null;
return (
-
)}
-
+
);
}
diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx
index c0ad7785a..8d13e06b0 100644
--- a/src/components/settings/integrations/nightscout-card.tsx
+++ b/src/components/settings/integrations/nightscout-card.tsx
@@ -28,6 +28,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
@@ -158,9 +159,8 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
: null;
return (
-
)}
-
+
);
}
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index f107a1772..c5564c783 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -28,6 +28,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
@@ -201,9 +202,8 @@ export function OAuthProviderCard({
}
return (
-
)}
-
+
);
}
diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx
index 4d7b4797a..1669ee9df 100644
--- a/src/components/settings/integrations/whoop-card.tsx
+++ b/src/components/settings/integrations/whoop-card.tsx
@@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
@@ -182,7 +183,7 @@ export function WhoopCard({
: null;
return (
-
+
)}
-
+
);
}
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx
index 2560322c3..1920c17f7 100644
--- a/src/components/settings/integrations/withings-card.tsx
+++ b/src/components/settings/integrations/withings-card.tsx
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
@@ -190,7 +191,7 @@ export function WithingsCard({
: null;
return (
-
+
)}
-
+
);
}
diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx
index d25019d79..763c0a16d 100644
--- a/src/components/settings/low-stock-card.tsx
+++ b/src/components/settings/low-stock-card.tsx
@@ -7,6 +7,7 @@ import { PackageOpen } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -190,12 +191,13 @@ export function LowStockCard({
}
return (
-
-
+
);
}
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index d52ed2507..72db906b7 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -42,6 +42,7 @@ import {
type LucideIcon,
} from "lucide-react";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
import { useAuth } from "@/hooks/use-auth";
@@ -106,7 +107,7 @@ export function ModulesSection() {
// frame's standard subtitle). The "Module immer aktiv" core-domains card
// was removed: a domain that can't be turned off doesn't need to be listed.
return (
-
+
-
+
);
}
diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx
index 95ee03ae5..71510fafd 100644
--- a/src/components/settings/mood-reminder-card.tsx
+++ b/src/components/settings/mood-reminder-card.tsx
@@ -6,6 +6,7 @@ import { SmilePlus } from "lucide-react";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -155,9 +156,9 @@ export function MoodReminderCard({
}
return (
-
+
);
}
diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx
index 28cdcaed0..7f2022d2e 100644
--- a/src/components/settings/ntfy-card.tsx
+++ b/src/components/settings/ntfy-card.tsx
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
@@ -82,7 +83,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
});
return (
-
+
-
+
);
}
diff --git a/src/components/settings/settings-card.tsx b/src/components/settings/settings-card.tsx
index 237ae497f..9537ca7a1 100644
--- a/src/components/settings/settings-card.tsx
+++ b/src/components/settings/settings-card.tsx
@@ -14,26 +14,50 @@ import { cn } from "@/lib/utils";
* `sm:` (640 px) instead of `md:` (768 px), so on a ~700 px tablet Settings
* cards read denser than every other surface.
*
- * This wraps the ui `` so the whole surface composes from one shape.
+ * The default renders the ui `` so the surface composes from one shape.
* Unlike the bare `` it does NOT impose the `flex flex-col gap` layout:
- * Settings cards manage their own internal rhythm (`SettingsCardHeader` +
+ * Settings card bodies own their internal rhythm (`SettingsCardHeader` +
* `mt-4`/`space-y-*` bodies), so the container only owns the border, radius,
* background, and padding. Pass `className` for per-card extras
* (`scroll-mt-28`, `space-y-4`, `flex h-full flex-col`, …).
+ *
+ * `as="section"` keeps a card's semantic landmark element (a `` with
+ * an `aria-labelledby`) while painting the same shape and padding contract.
*/
-export function SettingsCard({
+const SETTINGS_CARD_SHELL = "block gap-0 p-4 md:p-6";
+
+type SettingsCardOwnProps = { className?: string };
+
+type SettingsCardProps = SettingsCardOwnProps & {
+ as?: E;
+} & Omit, keyof SettingsCardOwnProps | "as">;
+
+export function SettingsCard({
+ as,
className,
...props
-}: React.ComponentProps) {
+}: SettingsCardProps) {
+ if (as) {
+ const Component = as as React.ElementType;
+ return (
+ -backed one.
+ className={cn(
+ "bg-card text-card-foreground rounded-xl border p-4 shadow-sm md:p-6",
+ className,
+ )}
+ {...props}
+ />
+ );
+ }
return (
)}
/>
);
}
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index 9a714b156..237b019c7 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -42,6 +42,7 @@ import {
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -285,7 +286,7 @@ export function SourcesSection() {
// card, below the ladder.
return (
);
}
diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx
index 40d42f681..2813f4f2c 100644
--- a/src/components/settings/telegram-card.tsx
+++ b/src/components/settings/telegram-card.tsx
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
@@ -87,7 +88,7 @@ export function TelegramCard({
}
return (
-
+
-
+
);
}
diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx
index 092429e2f..e1cf9aa80 100644
--- a/src/components/settings/thresholds-editor-section.tsx
+++ b/src/components/settings/thresholds-editor-section.tsx
@@ -19,6 +19,7 @@ import {
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
+import { SettingsCard } from "@/components/settings/settings-card";
interface ThresholdsApiResponse {
effective: Record;
@@ -115,9 +116,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
});
return (
-
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
already provides the title + description for this route, so the
@@ -159,7 +160,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
))}
)}
-
+
);
}
diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx
index f3204b4dc..fc44fe612 100644
--- a/src/components/settings/web-push-card.tsx
+++ b/src/components/settings/web-push-card.tsx
@@ -5,6 +5,7 @@ import { BellRing, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -204,7 +205,7 @@ export function WebPushCard() {
);
return (
-
+
) : null}
-
+
);
}
diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx
index bf2a17b69..e43e0e40d 100644
--- a/src/components/settings/webhook-card.tsx
+++ b/src/components/settings/webhook-card.tsx
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { useTranslations } from "@/lib/i18n/context";
@@ -80,7 +81,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
});
return (
-
+
-
+
);
}
diff --git a/src/generated b/src/generated
new file mode 120000
index 000000000..a5d735124
--- /dev/null
+++ b/src/generated
@@ -0,0 +1 @@
+/Users/marc/Projects/HealthLog/src/generated
\ No newline at end of file
From 96f2b4754567c81066bf0e49a68d1a84a81f957a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:16:10 +0200
Subject: [PATCH 39/71] refactor(settings): compose multi-card sections from
SettingsCard
Migrate the remaining hand-rolled card containers in the account,
about, medications, api, mood, notification-status, and advanced
sections to SettingsCard, preserving each card's heading, body, and
test hooks. Leaves the passkey list-row (rounded-lg) for the list-row
token track.
---
src/components/settings/about-section.tsx | 9 +++++----
src/components/settings/account-section.tsx | 17 +++++++++--------
src/components/settings/advanced-section.tsx | 16 +++++++---------
src/components/settings/api-section.tsx | 9 +++++----
.../settings/medications-section.tsx | 13 +++++++------
src/components/settings/mood-section.tsx | 19 ++++++++++---------
.../settings/notification-status-card.tsx | 14 +++++++-------
7 files changed, 50 insertions(+), 47 deletions(-)
diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx
index 787c5b46a..4f746ea83 100644
--- a/src/components/settings/about-section.tsx
+++ b/src/components/settings/about-section.tsx
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -191,7 +192,7 @@ export function AboutSection() {
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
on the same row. */}
-
+
{isLoading || !version ? (
@@ -255,13 +256,13 @@ export function AboutSection() {
)}
)}
-
+
{/* Sources & docs — every link card was untitled in the v1.4.2
About surface, so the buttons floated alone with no heading
telling the user what the section was for. */}
{version && (
-
+
-
+
)}
{/* v1.18.6 — the tour-replay card consolidated onto a single home in
diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx
index bddb12312..393370666 100644
--- a/src/components/settings/account-section.tsx
+++ b/src/components/settings/account-section.tsx
@@ -48,6 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { AboutMeSection } from "@/components/settings/about-me-section";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
@@ -375,7 +376,7 @@ export function AccountSection() {
{/* Profile photo card */}
-
+
-
+
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
Medikamente settings section, where every medication-specific
@@ -599,7 +600,7 @@ export function AccountSection() {
{/* Passkeys card */}
-
+
)}
-
+
{/* Password card. v1.4.19 A6: action-button placement contract —
on mobile (`
+
@@ -665,7 +666,7 @@ export function AccountSection() {
{t("settings.changePassword")}
-
+
{/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings →
Erweitert. It is a maintenance / reset action, not a profile or
@@ -1096,7 +1097,7 @@ function AvatarSection() {
const busy = upload.isPending || remove.isPending;
return (
-
+
)}
-
+
);
}
diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx
index 7650cefee..52cb08886 100644
--- a/src/components/settings/advanced-section.tsx
+++ b/src/components/settings/advanced-section.tsx
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
import { formatDateTime } from "@/lib/format";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -139,8 +140,7 @@ function ResearchModeCard() {
}
return (
-
-
+
);
}
@@ -254,8 +254,7 @@ function DataResetCard() {
}
return (
-
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
@@ -338,7 +337,7 @@ function DataResetCard() {
{msg}
)}
-
+
);
}
@@ -402,8 +401,7 @@ function AccountDeleteCard() {
}
return (
-
@@ -482,6 +480,6 @@ function AccountDeleteCard() {
{msg}
)}
-
+
);
}
diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx
index 417b081de..41636a6a7 100644
--- a/src/components/settings/api-section.tsx
+++ b/src/components/settings/api-section.tsx
@@ -19,6 +19,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
@@ -66,7 +67,7 @@ function ApiEndpointsCard() {
];
return (
-
+
))}
-
+
);
}
@@ -229,7 +230,7 @@ function ApiTokensCard() {
}, null);
return (
-
+
)}
-
+
);
}
diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx
index a1421aba9..9d488a10d 100644
--- a/src/components/settings/medications-section.tsx
+++ b/src/components/settings/medications-section.tsx
@@ -8,6 +8,7 @@ import {
type ReorderMedication,
} from "@/components/medications/medication-order-editor";
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { useAuth } from "@/hooks/use-auth";
@@ -79,9 +80,9 @@ export function MedicationsSection() {
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
-
)}
-
+
{/* Manual order — applies to both list views. */}
-
)}
-
+
{/* v1.18.0 (S5) — injection-site exclusions are a medication setting;
they moved here from the account profile so all medication-specific
diff --git a/src/components/settings/mood-section.tsx b/src/components/settings/mood-section.tsx
index dff7c7019..f05539029 100644
--- a/src/components/settings/mood-section.tsx
+++ b/src/components/settings/mood-section.tsx
@@ -2,6 +2,7 @@
import { Archive, Folders, Loader2, Tags } from "lucide-react";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card";
import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card";
@@ -45,30 +46,30 @@ export function MoodSection() {
return (
{/* Groups — picker group order + own groups. */}
-
{isLoading || !catalog ? loading : }
-
+
{/* Tags — visibility, order, edit, move, create. */}
-
{isLoading || !catalog ? loading : }
-
+
{/* Archived custom tags — restore / hard delete. */}
-
)}
-
+
);
}
diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx
index eea9c5763..f13ac3c96 100644
--- a/src/components/settings/notification-status-card.tsx
+++ b/src/components/settings/notification-status-card.tsx
@@ -32,6 +32,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -114,9 +115,9 @@ export function NotificationStatusCard() {
if (isLoading) {
return (
-
+
-
+
);
}
@@ -128,8 +129,7 @@ export function NotificationStatusCard() {
const channels = Array.isArray(data) ? data : [];
if (channels.length === 0) {
return (
-
-
+
);
}
return (
-
+
))}
-
+
);
}
From 5f2a76ddbcd2a4da72a53ffb62b5c3f30ebbbbd3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:16:43 +0200
Subject: [PATCH 40/71] fix(coach): close the budget-gate TOCTOU and count
burned tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The daily token cap was read before the provider call and recorded only
after a successful reply, so concurrent requests could all read
`spent < cap` and all hit the provider, and an empty/sentinel reply
recorded zero spend despite burning upstream tokens.
Reserve the budget atomically before the call: a single upsert-increment
returns the new total, so concurrent requests serialise on the row and
cannot both pass the cap; over-cap refunds the reservation and surfaces
`coach.budget.exceeded` as an SSE error frame. Reconcile the reservation
to the actual token count immediately after the provider returns —
before any empty/sentinel short-circuit — so burned tokens are always
recorded. An integration test proves two concurrent requests cannot both
exceed the cap and that an empty reply still records its cost.
---
src/app/api/insights/chat/route.ts | 61 ++++++++--
src/lib/ai/coach/budget.ts | 113 ++++++++++++++++++
.../coach-budget-reservation.test.ts | 109 +++++++++++++++++
tests/integration/coach-chat.test.ts | 19 ++-
4 files changed, 288 insertions(+), 14 deletions(-)
create mode 100644 tests/integration/coach-budget-reservation.test.ts
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 09ecf7d5d..89ead0497 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -62,8 +62,8 @@ import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import {
buildDateKey,
- enforceBudget,
- recordSpend,
+ reserveBudget,
+ reconcileSpend,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
@@ -215,7 +215,10 @@ async function handleChatRequest(request: NextRequest): Promise {
return apiError("Too many Coach requests, please wait a moment", 429);
}
- await enforceBudget(userId);
+ // v1.18.7 (SENIOR-DEV HIGH) — the daily token cap is enforced atomically
+ // by reserving budget BEFORE the provider call (see the reservation right
+ // before `runRawCompletionWithFallback`), not by a read-then-write here.
+ // The old read-before-call gate let concurrent requests all pass the cap.
const locale = await resolveServerLocale({
request,
@@ -431,6 +434,27 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
await assertConsentForChain({ userId, chain, surface: "coach" });
+ // v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
+ // the provider call. The reservation increments the day's total by the
+ // per-call ceiling (`maxTokens`) in one upsert and returns the new total;
+ // concurrent requests serialise on the row so they cannot all pass the cap.
+ // Over-cap → 429 refusal frame, reservation already refunded. The actual
+ // token count is reconciled against this reservation after the call,
+ // including on empty / sentinel replies (their tokens were still burned).
+ const reqDateKey = buildDateKey();
+ const reservation = await reserveBudget(
+ userId,
+ AI_BUDGETS.coach.maxTokens,
+ reqDateKey,
+ );
+ if (!reservation.allowed) {
+ annotate({
+ action: { name: "coach.budget.exceeded" },
+ meta: { totalAfter: reservation.totalAfter },
+ });
+ return streamProviderError({ code: "coach.budget.exceeded" });
+ }
+
let result: CompletionResult;
let workingProviderType: string;
try {
@@ -447,6 +471,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
} catch (err) {
+ // The provider chain failed outright — no tokens were billed, so refund
+ // the full reservation before surfacing the error frame.
+ await reconcileSpend(userId, reservation.reserved, 0, reqDateKey).catch(
+ () => {},
+ );
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "insights.coach.providerFailed" },
@@ -484,6 +513,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
throw err;
}
+ // v1.18.7 (SENIOR-DEV MEDIUM) — the provider call returned, so its tokens
+ // were billed regardless of reply quality. Reconcile the reservation
+ // against the actual count NOW, before any empty/sentinel short-circuit, so
+ // an empty or sentinel-only reply still records its burned cost (the old
+ // post-hoc `recordSpend` ran only on the happy path, undercounting these).
+ await reconcileSpend(
+ userId,
+ reservation.reserved,
+ result.tokensUsed ?? 0,
+ reqDateKey,
+ ).catch(() => {
+ // Ledger reconcile is best-effort; a failure leaves the conservative
+ // reservation in place (never an undercount) and never breaks the turn.
+ });
+
const rawReply = (result.content ?? "").trim();
if (!rawReply) {
return streamProviderError({ code: "coach.provider.empty" });
@@ -611,13 +655,10 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
promptVersion: PROMPT_VERSION,
});
- // Bump the day's spend ledger AFTER persistence so a retried request
- // doesn't double-count when the persistence layer rolled back.
- await recordSpend({
- userId,
- tokens: result.tokensUsed ?? 0,
- dateKey: buildDateKey(),
- });
+ // v1.18.7 — the day's spend was already reconciled against the
+ // reservation immediately after the provider returned (above), so there is
+ // no post-persistence ledger bump here. The reservation guarantees the
+ // tokens are counted even if persistence or streaming fails afterwards.
annotate({
action: { name: "insights.coach.replied" },
diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts
index 897056628..02df866e7 100644
--- a/src/lib/ai/coach/budget.ts
+++ b/src/lib/ai/coach/budget.ts
@@ -61,6 +61,119 @@ export async function enforceBudget(
}
}
+/**
+ * v1.18.7 (SENIOR-DEV HIGH) — atomically reserve budget BEFORE the provider
+ * call, closing the read-then-write TOCTOU window.
+ *
+ * `enforceBudget` reads spend, then `recordSpend` bumps it only AFTER a
+ * successful reply, so up to `rate-limit` (20/min) concurrent requests could
+ * each read `spent < cap` before any spend landed and all hit the provider.
+ * This instead does a single atomic SQL upsert that increments the day's
+ * total by an ESTIMATED reservation and returns the new total. The caller
+ * checks the returned total against the cap: if the reservation pushed it
+ * over, the request is refused and the reservation refunded; otherwise the
+ * call proceeds and the actual token count is reconciled afterwards. Mirrors
+ * the `rate-limit.ts` atomic-upsert pattern — multi-instance correctness is
+ * structural, not a hopeful read-then-write.
+ *
+ * The first request of a fresh day still lands cheaply via the upsert
+ * `create` branch; `messageCount` is bumped at reservation time so a
+ * reserved-but-failed turn still counts as an attempt.
+ */
+export interface ReserveBudgetResult {
+ /** True when the reservation kept the day's total within the cap. */
+ allowed: boolean;
+ /** Tokens reserved by this call (refunded/reconciled by the caller). */
+ reserved: number;
+ /** The day's total AFTER this reservation (for observability). */
+ totalAfter: number;
+}
+
+export async function reserveBudget(
+ userId: string,
+ estimatedTokens: number,
+ dateKey: string = buildDateKey(),
+ cap: number = MAX_TOKENS_PER_USER_PER_DAY,
+): Promise {
+ const reserved =
+ Number.isFinite(estimatedTokens) && estimatedTokens > 0
+ ? Math.floor(estimatedTokens)
+ : 0;
+
+ // Single atomic upsert-increment returning the new total. Two concurrent
+ // requests serialise on the row's unique (user_id, date_key) constraint, so
+ // each observes a distinct post-increment total — they cannot both read a
+ // sub-cap value and both proceed.
+ const rows = await prisma.$queryRaw<{ total_tokens: number }[]>`
+ INSERT INTO coach_usage (id, user_id, date_key, total_tokens, message_count, created_at, updated_at)
+ VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, 1, NOW(), NOW())
+ ON CONFLICT (user_id, date_key) DO UPDATE SET
+ total_tokens = coach_usage.total_tokens + ${reserved},
+ message_count = coach_usage.message_count + 1,
+ updated_at = NOW()
+ RETURNING total_tokens
+ `;
+ const totalAfter = Number(rows[0]?.total_tokens ?? reserved);
+
+ // The cap is a ceiling on tokens already spent BEFORE this request, matching
+ // the prior `spent >= cap` semantics: a request is allowed when the spend
+ // PRIOR to its reservation was under the cap. So compare `totalAfter -
+ // reserved` (the prior total) against the cap.
+ const priorTotal = totalAfter - reserved;
+ if (priorTotal >= cap) {
+ // Already over before this request — refund the reservation + the
+ // message-count bump and refuse.
+ await refundReservation(userId, reserved, dateKey);
+ return { allowed: false, reserved, totalAfter: priorTotal };
+ }
+
+ return { allowed: true, reserved, totalAfter };
+}
+
+/**
+ * Reconcile a reservation against the actual tokens the provider reported.
+ * `actual - reserved` is applied as a signed delta (clamped so the row never
+ * goes negative). Called after every provider call — including empty /
+ * sentinel / refusal replies, whose upstream tokens were still burned
+ * (SENIOR-DEV MEDIUM: spend undercount).
+ */
+export async function reconcileSpend(
+ userId: string,
+ reserved: number,
+ actualTokens: number,
+ dateKey: string = buildDateKey(),
+): Promise {
+ const actual =
+ Number.isFinite(actualTokens) && actualTokens > 0
+ ? Math.floor(actualTokens)
+ : 0;
+ const delta = actual - reserved;
+ if (delta === 0) return;
+ // Clamp at zero so a smaller-than-reserved actual can't drive the row
+ // negative under a racing reconcile.
+ await prisma.$executeRaw`
+ UPDATE coach_usage
+ SET total_tokens = GREATEST(0, total_tokens + ${delta}),
+ updated_at = NOW()
+ WHERE user_id = ${userId} AND date_key = ${dateKey}
+ `;
+}
+
+/** Refund a reservation (tokens + the message-count bump) on a refusal. */
+async function refundReservation(
+ userId: string,
+ reserved: number,
+ dateKey: string,
+): Promise {
+ await prisma.$executeRaw`
+ UPDATE coach_usage
+ SET total_tokens = GREATEST(0, total_tokens - ${reserved}),
+ message_count = GREATEST(0, message_count - 1),
+ updated_at = NOW()
+ WHERE user_id = ${userId} AND date_key = ${dateKey}
+ `;
+}
+
export interface RecordSpendParams {
userId: string;
tokens: number;
diff --git a/tests/integration/coach-budget-reservation.test.ts b/tests/integration/coach-budget-reservation.test.ts
new file mode 100644
index 000000000..86a7dcbd5
--- /dev/null
+++ b/tests/integration/coach-budget-reservation.test.ts
@@ -0,0 +1,109 @@
+/**
+ * v1.18.7 (SENIOR-DEV HIGH + MEDIUM) — atomic Coach budget reservation.
+ *
+ * `reserveBudget` does a single atomic upsert-increment so concurrent
+ * requests cannot all pass the daily cap (the old read-before-call gate
+ * could). `reconcileSpend` then adjusts the reservation to the actual token
+ * count — including on empty / sentinel replies whose tokens were still
+ * burned (the prior post-hoc `recordSpend` undercounted these). These tests
+ * prove both contracts against a real Postgres; a mocked client could not
+ * detect a missing atomic increment.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+async function seedUser(): Promise {
+ const user = await getPrismaClient().user.create({
+ data: { username: "budget-user", email: "budget@example.test", role: "USER" },
+ });
+ return user.id;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+});
+
+describe("reserveBudget (real Postgres)", () => {
+ it("lets at most one of two concurrent requests push past the cap", async () => {
+ const { reserveBudget } = await import("@/lib/ai/coach/budget");
+ const userId = await seedUser();
+ const dateKey = "2026-06-19";
+ const cap = 1000;
+
+ // Pre-spend 900 so a single 600-token reservation stays under the cap
+ // but two concurrent ones cannot both be admitted.
+ await getPrismaClient().coachUsage.create({
+ data: { userId, dateKey, totalTokens: 900, messageCount: 1 },
+ });
+
+ const [a, b] = await Promise.all([
+ reserveBudget(userId, 600, dateKey, cap),
+ reserveBudget(userId, 600, dateKey, cap),
+ ]);
+
+ // Exactly one is admitted: the first to commit sees prior 900 (< cap) and
+ // reserves to 1500; the second sees prior 1500 (>= cap) and is refused +
+ // refunded. Order is non-deterministic, so assert the SET of outcomes.
+ const allowed = [a, b].filter((r) => r.allowed).length;
+ expect(allowed).toBe(1);
+
+ // The refused reservation was refunded, so the row reflects exactly one
+ // admitted 600-token reservation on top of the 900 baseline.
+ const row = await getPrismaClient().coachUsage.findUnique({
+ where: { userId_dateKey: { userId, dateKey } },
+ });
+ expect(row?.totalTokens).toBe(1500);
+ });
+
+ it("admits the first request of a fresh day via the upsert create branch", async () => {
+ const { reserveBudget } = await import("@/lib/ai/coach/budget");
+ const userId = await seedUser();
+ const dateKey = "2026-06-20";
+
+ const res = await reserveBudget(userId, 600, dateKey, 25_000);
+ expect(res.allowed).toBe(true);
+
+ const row = await getPrismaClient().coachUsage.findUnique({
+ where: { userId_dateKey: { userId, dateKey } },
+ });
+ expect(row?.totalTokens).toBe(600);
+ expect(row?.messageCount).toBe(1);
+ });
+
+ it("reconciles the reservation down to a smaller actual count", async () => {
+ const { reserveBudget, reconcileSpend } = await import(
+ "@/lib/ai/coach/budget"
+ );
+ const userId = await seedUser();
+ const dateKey = "2026-06-21";
+
+ const res = await reserveBudget(userId, 600, dateKey, 25_000);
+ await reconcileSpend(userId, res.reserved, 120, dateKey);
+
+ const row = await getPrismaClient().coachUsage.findUnique({
+ where: { userId_dateKey: { userId, dateKey } },
+ });
+ // 600 reserved → reconciled to the actual 120 burned.
+ expect(row?.totalTokens).toBe(120);
+ });
+
+ it("still records burned tokens on an empty/sentinel reply (no undercount)", async () => {
+ const { reserveBudget, reconcileSpend } = await import(
+ "@/lib/ai/coach/budget"
+ );
+ const userId = await seedUser();
+ const dateKey = "2026-06-22";
+
+ // Provider returned 450 tokens but the reply was empty/sentinel: the
+ // reconcile must still record the 450 burned, not zero.
+ const res = await reserveBudget(userId, 600, dateKey, 25_000);
+ await reconcileSpend(userId, res.reserved, 450, dateKey);
+
+ const row = await getPrismaClient().coachUsage.findUnique({
+ where: { userId_dateKey: { userId, dateKey } },
+ });
+ expect(row?.totalTokens).toBe(450);
+ expect(row?.totalTokens).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts
index 8ea8e62e5..508b4e78d 100644
--- a/tests/integration/coach-chat.test.ts
+++ b/tests/integration/coach-chat.test.ts
@@ -214,7 +214,12 @@ describe("POST /api/insights/chat — integration", () => {
expect(usage?.messageCount).toBe(1);
});
- it("returns 429 when daily token budget is exhausted", async () => {
+ it("refuses with a budget-exceeded error frame when the daily cap is spent", async () => {
+ // v1.18.7 — the cap is now enforced by an atomic reservation right
+ // before the provider call. An over-budget request surfaces the
+ // structured `coach.budget.exceeded` code as an SSE `error` frame
+ // (HTTP 200, like every other coach error) rather than a JSON 429, and
+ // the reservation is refunded so the ledger stays at the cap.
const { userId } = await seedUserWithSession();
const prisma = getPrismaClient();
const dateKey = new Date().toISOString().slice(0, 10);
@@ -237,10 +242,16 @@ describe("POST /api/insights/chat — integration", () => {
}),
}),
);
- expect(res.status).toBe(429);
- const env = (await res.json()) as { error: string };
- expect(env.error).toBe("coach.budget.exceeded");
+ expect(res.status).toBe(200);
+ const body = await readStream(res);
+ expect(body).toContain("coach.budget.exceeded");
expect(runProviderMock).not.toHaveBeenCalled();
+
+ // The refused reservation was refunded — the ledger holds at the cap.
+ const usage = await prisma.coachUsage.findUnique({
+ where: { userId_dateKey: { userId, dateKey } },
+ });
+ expect(usage?.totalTokens).toBe(MAX_TOKENS_PER_USER_PER_DAY);
});
it("returns 404 (not 403) when conversationId belongs to another user", async () => {
From 76bc0f2e1fbe6cc27d8af475216077bd8f2e3bcd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:17:35 +0200
Subject: [PATCH 41/71] refactor(settings): compose import/export card shells
from SettingsCard
Migrate the ImportCardShell and ExportCardShell containers to
SettingsCard, threading their dynamic outerClassName through cn. The
layout hub-link row keeps its element (a full-row Link, not a content
card).
---
src/components/settings/export-section.tsx | 8 +++++---
src/components/settings/import-panel.tsx | 7 ++++---
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx
index 4116dd584..2e020214e 100644
--- a/src/components/settings/export-section.tsx
+++ b/src/components/settings/export-section.tsx
@@ -39,7 +39,9 @@ import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { cn } from "@/lib/utils";
import { ImportPanel } from "@/components/settings/import-panel";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
@@ -135,9 +137,9 @@ function ExportCardShell({
outerClassName,
}: ExportCardShellProps) {
return (
-
{children &&
{children}
}
{footer}
-
+
);
}
diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx
index 879237ceb..cab5ed23c 100644
--- a/src/components/settings/import-panel.tsx
+++ b/src/components/settings/import-panel.tsx
@@ -41,6 +41,7 @@ import {
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
+import { SettingsCard } from "@/components/settings/settings-card";
import { Textarea } from "@/components/ui/textarea";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
@@ -102,9 +103,9 @@ function ImportCardShell({
children,
}: ImportCardShellProps) {
return (
-
{description}
{children}
-
+
);
}
From d2be72d57a686317b3182742e7ca4666e1d52eb2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:18:05 +0200
Subject: [PATCH 42/71] feat(mobile): add scroll-touch momentum utility for
horizontal strips
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a `.scroll-touch` utility that restores iOS WebKit momentum
scrolling (`-webkit-overflow-scrolling: touch`) and contains the
overscroll chain so a flick at the end of a horizontal row does not
rubber-band the whole page. Apply it to the insights tab strip and the
cycle tab list — the two in-app horizontal scrollers that lost inertial
flick under the default `overflow-x-auto`.
---
src/app/globals.css | 15 +++++++++++++++
src/components/cycle/cycle-view.tsx | 2 +-
src/components/insights/insights-tab-strip.tsx | 2 +-
3 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/app/globals.css b/src/app/globals.css
index 4696df20e..20db09ff5 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -448,6 +448,21 @@
display: none;
}
+/*
+ * `.scroll-touch` — momentum scrolling for horizontal scrollers on iOS
+ * WebKit. `-webkit-overflow-scrolling: touch` restores the inertial
+ * flick the default `overflow-x-auto` row loses on touch; `overscroll-
+ * behavior: contain` keeps a flick that reaches the end of the row from
+ * chaining into the document scroll (the "rubber-band drags the whole
+ * page" paper-cut). Applied to the in-app horizontal pill / tab strips
+ * (insights tab strip, cycle tab list) so the gesture matches the
+ * native feel the bottom-sheet scrollers already get for free.
+ */
+.scroll-touch {
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+}
+
/* ── Insight card animations ─────────────────────── */
@keyframes insight-fade-in {
from {
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 74e6b9bea..37562ae52 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -255,7 +255,7 @@ export function CycleView() {
triggers size to their content (`text-xs`, no `flex-1`) and the
strip scrolls cleanly if needed; from `sm` up they reclaim the
equal-width `flex-1` layout. */}
-
+
{tabs.map((tab) => {
if (tab.kind === "link") {
From 54a2d19833bf71bcad0a2896954f3355931c5f29 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:18:11 +0200
Subject: [PATCH 43/71] feat(ui): add shared ListRow primitive and adopt it for
mini-cards
The bordered list-row "mini-card" appeared three times with three
shapes: the Vorsorge dashboard row (`rounded-md p-2.5`), the daily-
briefing rows (`rounded-md p-3`), and the measurement-list mobile row
(bespoke `rounded-lg p-3` div). Introduce `ListRow` (`rounded-lg border
p-3`, `asChild` per the shadcn convention) and migrate all three to it.
The primitive owns only the box shape; each caller keeps its layout,
colour, and interaction classes, so this is structural normalization
with no content or behaviour change.
---
src/components/insights/daily-briefing.tsx | 17 ++++----
.../vorsorge-dashboard-card.tsx | 14 ++++---
.../measurements/measurement-list.tsx | 7 ++--
src/components/ui/list-row.tsx | 42 +++++++++++++++++++
4 files changed, 64 insertions(+), 16 deletions(-)
create mode 100644 src/components/ui/list-row.tsx
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index 27bc08591..a3f349d75 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -22,6 +22,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
+import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations } from "@/lib/i18n/context";
@@ -230,28 +231,28 @@ function BriefingRow({
);
if (href) {
return (
-
- {rowContent}
-
+ {rowContent}
+
);
}
return (
-
{rowContent}
-
+
);
}
diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
index 0c03f88ef..3d5396c8b 100644
--- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
+++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { ListRow } from "@/components/ui/list-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
@@ -119,10 +120,12 @@ export function VorsorgeDashboardCard() {
const due = relativeDueKey(reminder.nextDueAt, now);
const isLinked = reminder.measurementType != null;
return (
-
+
{resolveLabel(reminder, t)}
@@ -139,8 +142,8 @@ export function VorsorgeDashboardCard() {
onPrimaryAction(reminder)}
disabled={satisfy.isPending}
>
@@ -156,7 +159,8 @@ export function VorsorgeDashboardCard() {
>
)}
-
+
+
);
})}
diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx
index 51765bbdb..fd53625e6 100644
--- a/src/components/measurements/measurement-list.tsx
+++ b/src/components/measurements/measurement-list.tsx
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
+import { ListRow } from "@/components/ui/list-row";
import {
FilterBar,
FilterBarDateRange,
@@ -1080,10 +1081,10 @@ export function MeasurementList({
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
const isSelected = selectedIds.has(m.id);
return (
-
@@ -1243,7 +1244,7 @@ export function MeasurementList({
/>
)}
-
+
);
})}
diff --git a/src/components/ui/list-row.tsx b/src/components/ui/list-row.tsx
new file mode 100644
index 000000000..0a60bbc80
--- /dev/null
+++ b/src/components/ui/list-row.tsx
@@ -0,0 +1,42 @@
+import * as React from "react";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.18.7 — shared list-row primitive.
+ *
+ * Before this, every list-row "mini-card" (a bordered row sitting inside
+ * a section or a ``) invented its own padding + radius: the
+ * Vorsorge dashboard card used `rounded-md p-2.5`, the daily-briefing
+ * rows `rounded-md p-3`, the measurement-list mobile row a bespoke
+ * `rounded-lg p-3` div. Three surfaces, three shapes for one visual
+ * concept. `ListRow` pins the canonical shape — `rounded-lg border p-3`
+ * — so the rows read as one language across the app.
+ *
+ * It owns ONLY the box shape (radius + border + padding). Layout
+ * (flex direction, alignment, gap), colour overrides, and interaction
+ * state stay with the caller via `className`, so migrating a bespoke row
+ * is purely dropping the radius/border/padding literals and keeping the
+ * rest.
+ *
+ * `asChild` renders the row as its single child (e.g. a ` ` or
+ * ``) while still applying the shared classes, matching the shadcn
+ * primitive convention used elsewhere in `ui/`.
+ */
+function ListRow({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "div";
+ return (
+
+ );
+}
+
+export { ListRow };
From e3071d4f0a5e8e00d0bc406aa1e194f47a7ce601 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:18:19 +0200
Subject: [PATCH 44/71] fix(mobile): raise primary touch controls to the 44px
floor
Sweep the primary touch controls that sat below the 44px tap-target
floor on phones onto the canonical `min-h-11 sm:min-h-9` (text) /
`size-11 sm:size-9` (icon) pattern, condensing back at `sm:` upward:
- Coach composer mic / stop / send icon buttons (were `size-9`).
- Dashboard hero CTA, link and action variants (were `size="sm"`).
- Vorsorge list-view "mark done" button (was `min-h-9`) and its kebab,
which now uses `size="icon-lg"` so the rendered control is 44px
rather than the icon variant's 40px.
- Illness list-view "log day" button (was `min-h-9`).
- Medication wizard Step 3 injection-sites disclosure (was `h-9`).
The Vorsorge dashboard-card capture action was raised alongside the
ListRow migration.
---
src/components/dashboard/hero/dashboard-hero.tsx | 6 ++++--
src/components/illness/illness-view.tsx | 2 +-
src/components/insights/coach-panel/coach-input.tsx | 10 +++++-----
.../measurement-reminders/vorsorge-section.tsx | 6 +++---
src/components/medications/wizard/steps/Step3Dose.tsx | 2 +-
5 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx
index 02154f6de..95a975e9e 100644
--- a/src/components/dashboard/hero/dashboard-hero.tsx
+++ b/src/components/dashboard/hero/dashboard-hero.tsx
@@ -258,15 +258,17 @@ export function DashboardHero({
cta.kind === "link" ? (
{t(ctaLabelKey)}
) : (
onQuickEntry(cta.target)}
>
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index d3722301e..278f9363a 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -109,7 +109,7 @@ function EpisodeCard({
onLogDay(episode.id)}
>
{t("illness.logDay")}
diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx
index c6bd15534..918b81470 100644
--- a/src/components/insights/coach-panel/coach-input.tsx
+++ b/src/components/insights/coach-panel/coach-input.tsx
@@ -352,7 +352,7 @@ export function CoachInput({
: t("insights.coach.dictate")
}
className={cn(
- "size-9 shrink-0 rounded-xl transition-colors",
+ "size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
@@ -396,8 +396,8 @@ export function CoachInput({
// Desktop shrinks back to `text-sm` for the compact
// composer.
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
- // Centre the single-line state against the 36 px send
- // button so the placeholder and the icon share a baseline.
+ // Centre the single-line state against the send button so
+ // the placeholder and the icon share a baseline.
"px-2 py-1.5",
"max-h-[9.5rem] overflow-auto",
// v1.18.7 — calm, thin scrollbar inside the composer when
@@ -420,7 +420,7 @@ export function CoachInput({
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
- className="size-9 shrink-0 rounded-xl"
+ className="size-11 shrink-0 rounded-xl sm:size-9"
>
@@ -432,7 +432,7 @@ export function CoachInput({
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
- className="size-9 shrink-0 rounded-xl"
+ className="size-11 shrink-0 rounded-xl sm:size-9"
>
{isStreaming ? (
@@ -909,7 +909,7 @@ function VorsorgeCard({
type="button"
size="sm"
className={cn(
- "min-h-9",
+ "min-h-11 sm:min-h-9",
isDue && "bg-success text-success-foreground hover:bg-success/90",
)}
onClick={onPrimaryAction}
diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/Step3Dose.tsx
index 265ba910d..a14db3400 100644
--- a/src/components/medications/wizard/steps/Step3Dose.tsx
+++ b/src/components/medications/wizard/steps/Step3Dose.tsx
@@ -243,7 +243,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
setAllowedOpen((prev) => !prev)}
>
From a371678460fead11d7bac96a6a268d413123fe55 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:18:27 +0200
Subject: [PATCH 45/71] test(coach): guard composer controls against the
sub-44px floor
Assert the send and stop controls render the `size-11 sm:size-9` tap-
target pattern so a future restyle cannot quietly drop them back to the
36px size the audit flagged. Renders the real component and matches the
emitted class string, the same model as the bottom-nav size test.
---
.../__tests__/coach-input.test.tsx | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
index b59b383c0..ea73deed7 100644
--- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
@@ -175,6 +175,39 @@ describe("", () => {
expect(html).toContain("Stopp");
});
+ it("sizes the send control to the 44px tap-target floor on phones", () => {
+ // v1.18.7 — the composer controls (send / stop / mic) must clear the
+ // 44px WCAG 2.5.5 floor on phones and condense at `sm:` upward. Guard
+ // the `size-11 sm:size-9` pattern so a future restyle can't quietly
+ // drop the send button back to 36px.
+ const html = render(
+ {}} onSubmit={() => {}} />,
+ );
+ const sendTag = html.match(
+ /]*data-slot="coach-input-send"[^>]*>/,
+ );
+ expect(sendTag?.[0]).toMatch(/\bsize-11\b/);
+ expect(sendTag?.[0]).toMatch(/\bsm:size-9\b/);
+ });
+
+ it("sizes the stop control to the 44px tap-target floor on phones", () => {
+ const html = render(
+ {}}
+ onSubmit={() => {}}
+ onCancel={() => {}}
+ disabled
+ isStreaming
+ />,
+ );
+ const stopTag = html.match(
+ /]*data-slot="coach-input-stop"[^>]*>/,
+ );
+ expect(stopTag?.[0]).toMatch(/\bsize-11\b/);
+ expect(stopTag?.[0]).toMatch(/\bsm:size-9\b/);
+ });
+
it("invokes onChange when the parent passes a controlled handler", () => {
// SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly.
From ea9582641502e7b74bcd458a0550efb05ba73434 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:20:01 +0200
Subject: [PATCH 46/71] feat(ai): make the local-host SSRF escape hatch a host
allowlist
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` was a binary flag that, when `true`,
opened every private/internal host at once — including the cloud
metadata endpoint and any internal panel. Replace the binary check at
all three sites (write-time validation, local client, test resolver)
with a shared `isLocalAiHostAllowed` helper: a comma-separated host list
permits only those exact hostnames, while the legacy `true` still
permits any private host for backward compatibility. The safeFetch /
isPublicUrl floor is untouched; the allowlist only widens what the
operator deliberately permits. Docs and the SSRF-pin inventory test are
updated accordingly.
---
docs/integrations/ai-providers.md | 21 +++--
src/app/api/user/ai-provider/route.ts | 11 ++-
src/lib/__tests__/ssrf-pin-inventory.test.ts | 13 +++-
.../ai/__tests__/local-host-allowlist.test.ts | 60 +++++++++++++++
src/lib/ai/local-client.ts | 9 ++-
src/lib/ai/local-host-allowlist.ts | 76 +++++++++++++++++++
src/lib/ai/provider.ts | 5 +-
7 files changed, 176 insertions(+), 19 deletions(-)
create mode 100644 src/lib/ai/__tests__/local-host-allowlist.test.ts
create mode 100644 src/lib/ai/local-host-allowlist.ts
diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md
index 547364392..2aca1fda8 100644
--- a/docs/integrations/ai-providers.md
+++ b/docs/integrations/ai-providers.md
@@ -134,11 +134,14 @@ API key at rest like every other provider credential.
**SSRF guard.** Local endpoints route through the same validation
as every other outbound URL HealthLog hits. The guard rejects
-private-IP-range targets by default — set
-`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment to
-allow `localhost`, RFC1918, and link-local destinations
-(`src/lib/ai/provider.ts:456-462`). Leave it unset on public-facing
-instances unless you specifically run an internal endpoint.
+private-IP-range targets by default. `ALLOW_LOCAL_AI_PRIVATE_HOSTS`
+opens the escape hatch and accepts two forms: a comma-separated host
+allowlist (e.g. `ALLOW_LOCAL_AI_PRIVATE_HOSTS=ollama.lan,10.0.0.5`),
+which permits only those exact hostnames, or the legacy
+`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which permits any private host —
+including the cloud-metadata endpoint, so prefer the host list. Leave
+it unset on public-facing instances unless you specifically run an
+internal endpoint.
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
@@ -232,9 +235,11 @@ error.
provider in `/settings/ai`, or configure an admin-shared OpenAI
key in the admin panel.
- **Local endpoint rejected as "internal/private host".** Set
- `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment.
- Leave it unset on internet-facing instances unless you specifically
- want to allow private-range targets.
+ `ALLOW_LOCAL_AI_PRIVATE_HOSTS` to the endpoint's exact hostname
+ (e.g. `ollama.lan`; comma-separate several) in the container
+ environment, or to `true` to allow any private host. Prefer the host
+ list; leave it unset on internet-facing instances unless you
+ specifically want to allow private-range targets.
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
refresh tokens lapse after extended idle periods. Reconnect via
the **Connect ChatGPT** button — HealthLog re-runs the device-code
diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts
index 5e3c0d197..885a66a32 100644
--- a/src/app/api/user/ai-provider/route.ts
+++ b/src/app/api/user/ai-provider/route.ts
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { isPublicUrl } from "@/lib/validations/notifications";
+import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
import { encrypt, decrypt } from "@/lib/crypto";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { annotate } from "@/lib/logging/context";
@@ -92,12 +93,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const trimmed = body.baseUrl.trim();
// SSRF guard: by default reject private/internal hostnames so a
// compromised user account cannot point the server at the cloud
- // metadata endpoint or internal admin panels. Ops can enable local
- // Ollama / LM Studio on this instance via env flag.
- const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
+ // metadata endpoint or internal admin panels. v1.18.7 (SECURITY LOW) —
+ // ops opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`, now a host allowlist:
+ // `true` permits any private host (legacy), a comma-separated host list
+ // permits only those exact hostnames.
+ const allowPrivate = isLocalAiHostAllowed(trimmed);
if (!allowPrivate && !isPublicUrl(trimmed)) {
return apiError(
- "Base URL points to an internal/private host. Ops must set ALLOW_LOCAL_AI_PRIVATE_HOSTS=true on this instance to allow it (intended for self-hosted Ollama / LM Studio).",
+ "Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. ollama.lan) or to true for any private host (intended for self-hosted Ollama / LM Studio).",
422,
);
}
diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts
index edc8498c4..d4abdc1a6 100644
--- a/src/lib/__tests__/ssrf-pin-inventory.test.ts
+++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts
@@ -10,9 +10,12 @@
*
* The LOCAL AI client is the deliberate exception: it stays CONDITIONAL
* (`requirePublicHost: !allowPrivate`) so an operator can opt into LAN hosts
- * via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`. We assert it keeps that conditional
- * shape — never an unconditional `true` (which would break LAN local models)
- * and never absent (which would re-open it for the default case).
+ * via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`. v1.18.7 (SECURITY LOW) — that flag is
+ * now a host ALLOWLIST (`true` = any private host; a comma-separated list =
+ * only those), resolved by `isLocalAiHostAllowed`. We assert the client keeps
+ * the conditional shape AND derives `allowPrivate` from the allowlist helper
+ * (not a raw `=== "true"` binary), never an unconditional `true` (which would
+ * break LAN local models) and never absent (which would re-open the default).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -59,5 +62,9 @@ describe("SSRF requirePublicHost pin inventory", () => {
// … and must NOT be hardened to an unconditional true (that would break
// a deliberately-private LAN local model).
expect(src).not.toMatch(/requirePublicHost:\s*true/);
+ // … and `allowPrivate` must come from the host-allowlist helper, not a
+ // raw binary `=== "true"` (v1.18.7 — the flag became an allowlist).
+ expect(src).toMatch(/allowPrivate\s*=\s*isLocalAiHostAllowed\(/);
+ expect(src).not.toMatch(/ALLOW_LOCAL_AI_PRIVATE_HOSTS\s*===\s*"true"/);
});
});
diff --git a/src/lib/ai/__tests__/local-host-allowlist.test.ts b/src/lib/ai/__tests__/local-host-allowlist.test.ts
new file mode 100644
index 000000000..b989306f8
--- /dev/null
+++ b/src/lib/ai/__tests__/local-host-allowlist.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ isLocalAiHostAllowed,
+ requirePublicHostFor,
+} from "../local-host-allowlist";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("isLocalAiHostAllowed", () => {
+ it("denies every private host when unset (secure default)", () => {
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
+ expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(false);
+ expect(isLocalAiHostAllowed("http://ollama.lan/v1")).toBe(false);
+ });
+
+ it('treats "false" as denying every private host', () => {
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "false");
+ expect(isLocalAiHostAllowed("http://10.0.0.5/v1")).toBe(false);
+ });
+
+ it('keeps the legacy "true" = any private host', () => {
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
+ expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
+ expect(isLocalAiHostAllowed("http://anything.lan/v1")).toBe(true);
+ });
+
+ it("permits ONLY the listed hosts when given a comma-separated allowlist", () => {
+ vi.stubEnv(
+ "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
+ "ollama.lan, 10.0.0.5",
+ );
+ expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
+ expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
+ // A different private host is still rejected — the metadata endpoint and
+ // internal panels no longer ride the binary flag.
+ expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
+ expect(isLocalAiHostAllowed("http://10.0.0.6/v1")).toBe(false);
+ });
+
+ it("matches hostnames case-insensitively and ignores blank entries", () => {
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", " , Ollama.LAN , ");
+ expect(isLocalAiHostAllowed("http://ollama.lan/v1")).toBe(true);
+ });
+
+ it("never permits an unparseable URL", () => {
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
+ expect(isLocalAiHostAllowed("not a url")).toBe(true); // host-agnostic "any"
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
+ expect(isLocalAiHostAllowed("not a url")).toBe(false);
+ });
+
+ it("requirePublicHostFor is the inverse of the allowlist", () => {
+ vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
+ expect(requirePublicHostFor("http://ollama.lan/v1")).toBe(false);
+ expect(requirePublicHostFor("http://10.0.0.6/v1")).toBe(true);
+ });
+});
diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts
index 905e4e62b..19abec405 100644
--- a/src/lib/ai/local-client.ts
+++ b/src/lib/ai/local-client.ts
@@ -1,4 +1,5 @@
import { safeFetch } from "@/lib/safe-fetch";
+import { isLocalAiHostAllowed } from "./local-host-allowlist";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
interface LocalClientConfig {
@@ -49,9 +50,11 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// pin (issue #217) extends the same flag with a connect-time
// resolved-IP check to also defeat DNS rebinding. Operators who
// legitimately point at a self-hosted Ollama / LM Studio on an
- // RFC1918 address opt out via `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`
- // (also enforced at write-time in /api/user/ai-provider).
- const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
+ // RFC1918 address opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` — either the
+ // legacy `=true` (any private host) or a comma-separated host allowlist
+ // (only those hostnames). Enforced at write-time in /api/user/ai-provider
+ // too. v1.18.7 (SECURITY LOW) — the binary flag became a host allowlist.
+ const allowPrivate = isLocalAiHostAllowed(url);
const res = await safeFetch(
url,
{
diff --git a/src/lib/ai/local-host-allowlist.ts b/src/lib/ai/local-host-allowlist.ts
new file mode 100644
index 000000000..bf26845f0
--- /dev/null
+++ b/src/lib/ai/local-host-allowlist.ts
@@ -0,0 +1,76 @@
+/**
+ * v1.18.7 (SECURITY LOW) — host allowlist for local/private AI endpoints.
+ *
+ * The operator escape-hatch for pointing the server at a self-hosted
+ * Ollama / LM Studio on an RFC1918 address used to be a single binary flag,
+ * `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which opened EVERY private/internal
+ * host at once — including the cloud-metadata endpoint and any internal admin
+ * panel. This narrows it to an explicit allowlist while keeping the binary
+ * `true` for backward compatibility:
+ *
+ * - unset / "" / "false" → no private host allowed (the secure default).
+ * - "true" → ANY private host allowed (legacy behaviour).
+ * - "ollama.lan, 10.0.0.5" → ONLY those exact hostnames allowed; every
+ * other private host is still rejected.
+ *
+ * This is layered ON TOP of the `safeFetch` / `isPublicUrl` floor: a public
+ * URL never needs the allowlist, and a private host not on the list is
+ * rejected exactly as before. The allowlist only ever WIDENS what the
+ * operator deliberately permits, never narrows the public-host floor.
+ */
+
+const ENV_VAR = "ALLOW_LOCAL_AI_PRIVATE_HOSTS";
+
+/** Parsed allowlist policy from the env var. */
+type LocalHostPolicy =
+ | { kind: "none" }
+ | { kind: "any" }
+ | { kind: "hosts"; hosts: ReadonlySet };
+
+function parsePolicy(): LocalHostPolicy {
+ const raw = process.env[ENV_VAR]?.trim();
+ if (!raw || raw.toLowerCase() === "false") return { kind: "none" };
+ if (raw.toLowerCase() === "true") return { kind: "any" };
+ const hosts = raw
+ .split(",")
+ .map((h) => h.trim().toLowerCase())
+ .filter((h) => h.length > 0);
+ if (hosts.length === 0) return { kind: "none" };
+ return { kind: "hosts", hosts: new Set(hosts) };
+}
+
+/** Extract the lowercased hostname from a URL string, or null if unparseable. */
+function hostnameOf(url: string): string | null {
+ try {
+ return new URL(url).hostname.toLowerCase();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * True when the operator has explicitly permitted this (private) host for a
+ * local AI endpoint. `"true"` permits any host (legacy); a host list permits
+ * only listed hostnames. An unparseable URL is never permitted.
+ *
+ * NOTE: this answers "did the operator opt this host in?", NOT "is this host
+ * public?". Callers still run the `isPublicUrl` / `safeFetch` floor; this is
+ * the private-host override on top of it.
+ */
+export function isLocalAiHostAllowed(url: string): boolean {
+ const policy = parsePolicy();
+ if (policy.kind === "none") return false;
+ if (policy.kind === "any") return true;
+ const host = hostnameOf(url);
+ return host !== null && policy.hosts.has(host);
+}
+
+/**
+ * Whether `safeFetch` must require a public host for this URL: true unless the
+ * operator has opted this private host into the allowlist. Kept as a thin
+ * wrapper so the call sites read `requirePublicHost: requirePublicHostFor(url)`
+ * and the conditional intent stays obvious.
+ */
+export function requirePublicHostFor(url: string): boolean {
+ return !isLocalAiHostAllowed(url);
+}
diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts
index 93e5e2df2..f4c9884f1 100644
--- a/src/lib/ai/provider.ts
+++ b/src/lib/ai/provider.ts
@@ -11,6 +11,7 @@ import {
decryptCodexCreds,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
+import { isLocalAiHostAllowed } from "./local-host-allowlist";
import { parseProviderChain, type ProviderChainType } from "./provider-chain";
import type { ProviderChainResolved } from "./provider-runner";
@@ -668,7 +669,9 @@ export async function resolveProviderForTest(
if (!baseUrl) {
throw new AITestConfigError(422, "Local provider requires a base URL");
}
- const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
+ // v1.18.7 (SECURITY LOW) — host allowlist (`true` = any private host;
+ // a comma-separated host list = only those) replaces the binary flag.
+ const allowPrivate = isLocalAiHostAllowed(baseUrl);
if (!allowPrivate && !isPublicUrl(baseUrl)) {
throw new AITestConfigError(
422,
From 8b3b1e1985b47eefa611247eaf1fc0081e50b426 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:25:22 +0200
Subject: [PATCH 47/71] chore(db): renumber the briefing-reroll migration to
0179
Two parallel changes both claimed 0178; the session-expiry indexes keep
0178 and the briefing-reroll column moves to 0179 so the sequence stays
unique and ordered.
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename prisma/migrations/{0178_v1187_briefing_reroll_date => 0179_v1187_briefing_reroll_date}/migration.sql (100%)
diff --git a/prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql b/prisma/migrations/0179_v1187_briefing_reroll_date/migration.sql
similarity index 100%
rename from prisma/migrations/0178_v1187_briefing_reroll_date/migration.sql
rename to prisma/migrations/0179_v1187_briefing_reroll_date/migration.sql
From d9393a2148b48ab627547c2f488796fa61676203 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 19 Jun 2026 21:27:05 +0200
Subject: [PATCH 48/71] fix(settings): raise primary controls to the 44px touch
floor
Apply the canonical min-h-11 sm:min-h-9 (and min-w-11 for icon-only
controls) to the primary Settings controls that sat at 32-40 px on
phones: the AI fallback-chain reorder/remove/save/add icons, provider
form save/test/connect actions, coach-memory forget, avatar replace and
remove, section save/cancel/reset CTAs, the export and import action
rows, the About external links, and the thresholds editor row. Collapse
the injection-sites checkbox grid to one column on phones and stack the
password-change grid below lg.
---
src/components/settings/about-me-section.tsx | 2 ++
src/components/settings/about-section.tsx | 21 ++++++++++++++++---
src/components/settings/account-section.tsx | 5 +++--
src/components/settings/advanced-section.tsx | 6 +++---
.../settings/ai/anthropic-provider-form.tsx | 1 +
.../settings/ai/codex-provider-form.tsx | 4 ++--
.../settings/ai/fallback-chain-card.tsx | 12 +++++++++--
.../settings/ai/local-provider-form.tsx | 1 +
.../settings/ai/openai-provider-form.tsx | 1 +
.../settings/ai/runtime-actions-row.tsx | 2 ++
.../settings/coach-memory-section.tsx | 2 ++
.../settings/coach-prefs-section.tsx | 1 +
.../settings/cycle-tracking-card.tsx | 7 ++++++-
.../settings/dashboard-layout-section.tsx | 4 +++-
src/components/settings/export-section.tsx | 4 ++++
src/components/settings/import-panel.tsx | 8 +++++++
.../settings/injection-sites-card.tsx | 2 +-
.../settings/insights-pill-order-section.tsx | 2 +-
.../settings/thresholds-editor-section.tsx | 2 ++
19 files changed, 71 insertions(+), 16 deletions(-)
diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx
index b92cace67..6683d45a4 100644
--- a/src/components/settings/about-me-section.tsx
+++ b/src/components/settings/about-me-section.tsx
@@ -274,6 +274,7 @@ export function AboutMeSection({
type="button"
variant="outline"
size="sm"
+ className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-clear"
disabled={!isAuthenticated || save.isPending}
onClick={() =>
@@ -292,6 +293,7 @@ export function AboutMeSection({
save.mutate(value)}
diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx
index 4f746ea83..de0bae81d 100644
--- a/src/components/settings/about-section.tsx
+++ b/src/components/settings/about-section.tsx
@@ -269,7 +269,12 @@ export function AboutSection() {
className="mb-4"
/>