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 = ( + <> +