From aa3d7fcdf7ecd72f85e14ae933a5511b7e1c3c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 20:54:33 +0200 Subject: [PATCH 01/12] docs(sleep): correct the sleep-rhythm peer-card window comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the average-per-night, sleep-debt, and chronotype cards share one window and cannot disagree. They each read their own sub-window — the average over the full scorable span, sleep-debt over its trailing 14 nights, chronotype over CHRONOTYPE_WINDOW_NIGHTS — so the figures are each internally honest rather than identical, and every card's caption discloses its own nightsCounted. State that plainly; no computation changes. --- src/lib/insights/derived/sleep-rhythm.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts index ec3cbbaff..e9a2eade5 100644 --- a/src/lib/insights/derived/sleep-rhythm.ts +++ b/src/lib/insights/derived/sleep-rhythm.ts @@ -75,10 +75,14 @@ export interface SleepDebtDto { * Wire DTO for the average sleep per night over the rhythm window. * * v1.19.1 — a third peer card next to sleep-debt + chronotype. The mean of the - * canonical per-night asleep totals over the same scorable-night set the debt - * and chronotype read, so the three cards can never contradict one another. - * Carries the same calm `partial` state under a minimum-night threshold so it - * never asserts an average off one or two thin nights. + * canonical per-night asleep totals over the full scorable span (up to + * `DEFAULT_WINDOW_DAYS`). Each of the three cards reads its OWN sub-window — + * sleep-debt caps to 14 nights, chronotype to `CHRONOTYPE_WINDOW_NIGHTS`, this + * average over every scorable night — so the figures are each internally honest + * rather than identical; the card caption discloses its `nightsCounted` so the + * reader sees exactly which span produced the number. Carries the same calm + * `partial` state under a minimum-night threshold so it never asserts an average + * off one or two thin nights. */ export interface AverageSleepDto { state: "partial" | "ready"; @@ -261,8 +265,11 @@ export function computeSleepRhythmFromNights( })); const chronotype = computeChronotype(chronoNights); - // Average sleep per night over the same scorable set the debt + chronotype - // read, so the three peer cards share one window and can't disagree. + // Average sleep per night over the full scorable span. Each of the three peer + // cards reads its OWN window — debt slices its trailing 14 nights, chronotype + // its trailing CHRONOTYPE_WINDOW_NIGHTS, this average every scorable night — + // so the figures are each internally honest rather than identical; each card's + // caption shows its own nightsCounted so the reader sees the span behind it. const averagePerNight = computeAverageSleep(scorable); return { From 1bf8f3d6619e2aa141ff257febfd313488579db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 20:54:39 +0200 Subject: [PATCH 02/12] refactor(coach): drop the unread streaming startedAt and reasoning state The thinking disclosure was removed, so the coach streaming bubble no longer reads startedAt or reasoning. Both fields stayed populated by the send hook but nothing consumed them. Remove them from CoachStreamingMessage and every writer, accept the additive reasoning SSE frame as an explicit no-op so the wire type stays recognised, and update the thread-test fixture. Streaming is otherwise unchanged. --- .../__tests__/message-thread.test.tsx | 10 ++-- .../insights/coach-panel/use-coach.ts | 53 +++---------------- 2 files changed, 10 insertions(+), 53 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index aa20016d2..dd9356ed3 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -62,10 +62,10 @@ import { import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; import type { CoachStreamingMessage } from "../use-coach"; -// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` / -// `reasoning` (per-message token footer + thinking disclosure). The -// fixtures below predate those fields; this builder fills the defaults so -// each test only spells out the fields it cares about. +// v1.18.9 — `CoachStreamingMessage` gained a `usage` field for the +// per-message token footer. The fixtures below predate it; this builder +// fills the defaults so each test only spells out the fields it cares +// about. function streaming( partial: Partial, ): CoachStreamingMessage { @@ -77,8 +77,6 @@ function streaming( messageId: null, errorCode: null, usage: null, - startedAt: null, - reasoning: "", ...partial, }; } diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 5517d5237..db101a0d6 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -199,19 +199,6 @@ export interface CoachStreamingMessage { * bubble; persisted bubbles read `CoachMessageDTO.tokensUsed` instead. */ usage: CoachUsage | null; - /** - * v1.18.9 — epoch-ms timestamp the send fired, so the thinking - * disclosure can show a live "thinking for N s" counter that freezes - * to a past-tense summary once the first token lands. Null before a - * turn starts. - */ - startedAt: number | null; - /** - * v1.18.9 — optional reasoning-summary text from the additive - * `reasoning` frame, rendered inside the thinking disclosure when a - * reasoning-capable provider emits it. Empty string when none. - */ - reasoning: string; } /** @@ -253,8 +240,6 @@ const EMPTY_STREAMING: CoachStreamingMessage = { messageId: null, errorCode: null, usage: null, - startedAt: null, - reasoning: "", }; export interface SendCoachMessageParams { @@ -349,10 +334,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: null, usage: null, - // v1.18.9 — anchor the thinking-disclosure elapsed timer to the - // moment the send fired. - startedAt: Date.now(), - reasoning: "", }); // v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode @@ -367,8 +348,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: "coach.network", usage: null, - startedAt: null, - reasoning: "", }); return null; } @@ -403,8 +382,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: "coach.network", usage: null, - startedAt: null, - reasoning: "", }); return null; } @@ -418,8 +395,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: `coach.http.${response.status}`, usage: null, - startedAt: null, - reasoning: "", }); return null; } @@ -458,8 +433,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: structured ?? `coach.http.${response.status}`, usage: null, - startedAt: null, - reasoning: "", }); // v1.16.4 — KEEP the optimistic user bubble: a rejected turn // (budget gate, 4xx) has no persisted twin coming, and dropping @@ -479,7 +452,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { let lastError: string | null = null; let messageId: string | null = null; let collectedUsage: CoachUsage | null = null; - let collectedReasoning = ""; try { while (true) { @@ -512,16 +484,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { })); break; case "reasoning": - // v1.18.9 — additive reasoning-summary frame. Append so - // multiple chunks accumulate; the disclosure renders it - // when present, else falls back to the elapsed-time label. - // Guard the text: a `reasoning` frame with no `text` must not - // append a literal "undefined". - collectedReasoning += evt.text ?? ""; - setStreaming((prev) => ({ - ...prev, - reasoning: prev.reasoning + (evt.text ?? ""), - })); + // v1.19.1 — the thinking disclosure was removed, so the + // additive `reasoning` frame is accepted off the wire and + // dropped. Kept as an explicit no-op case so the wire frame + // stays a recognised type rather than slipping to a default. break; case "done": resolvedConversationId = evt.conversationId; @@ -544,8 +510,6 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { collectedUsage = evt.usage ?? null; } else if (evt.type === "error") { lastError = evt.code; - } else if (evt.type === "reasoning") { - collectedReasoning += evt.text ?? ""; } } } catch (err) { @@ -554,7 +518,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { } } - setStreaming((prev) => ({ + setStreaming({ content: collectedContent, metricSource: collectedProvenance, suggestion: collectedSuggestion, @@ -562,12 +526,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId, errorCode: lastError, usage: collectedUsage, - // v1.18.9 — keep the send-anchored timestamp so the disclosure can - // freeze the elapsed time to a past-tense "thought for N s" once - // the turn settles. - startedAt: prev.startedAt, - reasoning: collectedReasoning, - })); + }); if (resolvedConversationId) { // Invalidate the freshly-persisted conversation + the rail so From 7f4086089111bfef070c4936f0dc63d14811ddd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 20:57:30 +0200 Subject: [PATCH 03/12] feat(insights): surface the Oura resilience band on the recovery page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.19.0 ingests Oura's daily resilience level end-to-end (RESILIENCE, ordinal-encoded limited=1 … exceptional=5) but no surface rendered it. Add a calm resilience tile to the recovery page: it names the latest band, gives a one-line description, and a quiet "vs the recent average" cue once a few days exist, with a learning note while the series is still sparse. Resilience is a categorical band, not a continuous score, so it reads as a dedicated readout rather than a misleading line chart. Server-authoritative — the tile reads the stored summaries slice (latest + mean, both computed server-side) and never re-derives the value. Calm posture: one neutral card, no green-when-good / red-when-low tint. The band label and trend cue carry the read in muted text only. Self-gating: renders nothing without a reading, and counts toward the recovery-page data gate so an Oura-only account no longer sees the empty note. Copy lands in all six locales. --- messages/de.json | 16 +++ messages/en.json | 16 +++ messages/es.json | 16 +++ messages/fr.json | 16 +++ messages/it.json | 16 +++ messages/pl.json | 16 +++ .../__tests__/device-score-surfaces.test.tsx | 57 ++++++++ .../insights/recovery/recovery-section.tsx | 16 ++- .../insights/recovery/resilience-tile.tsx | 135 ++++++++++++++++++ 9 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 src/components/insights/recovery/resilience-tile.tsx diff --git a/messages/de.json b/messages/de.json index c15520593..b8b30f2b0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Körpertemperatur-Abweichung", "subtitle": "Nächtliche Abweichung von deiner Basislinie" + }, + "resilience": { + "explainer": "Wie gut dein Körper die tägliche Belastung mit der Erholung ausbalanciert — aus deinen nächtlichen Signalen der letzten Tage abgelesen.", + "learning": "Deine Resilienz wird noch erlernt — ein paar Tage mehr festigen das Niveau.", + "band": { + "limited": "Eingeschränkt", + "adequate": "Ausreichend", + "solid": "Solide", + "strong": "Stark", + "exceptional": "Außergewöhnlich" + }, + "trend": { + "above": "Über deinem jüngsten Durchschnitt.", + "steady": "Im Einklang mit deinem jüngsten Durchschnitt.", + "below": "Unter deinem jüngsten Durchschnitt." + } } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 4ad161b92..06ccb722f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Body temperature deviation", "subtitle": "Overnight delta from your baseline" + }, + "resilience": { + "explainer": "How well your body is balancing daily strain with recovery, read from your overnight signals across recent days.", + "learning": "Still learning your resilience — a few more days will settle the band.", + "band": { + "limited": "Limited", + "adequate": "Adequate", + "solid": "Solid", + "strong": "Strong", + "exceptional": "Exceptional" + }, + "trend": { + "above": "Above your recent average.", + "steady": "In line with your recent average.", + "below": "Below your recent average." + } } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 66d9d2421..1fcc09ad7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Desviación de la temperatura corporal", "subtitle": "Variación nocturna respecto a tu línea base" + }, + "resilience": { + "explainer": "Cómo equilibra tu cuerpo la carga diaria con la recuperación, leído a partir de tus señales nocturnas de los últimos días.", + "learning": "Aún estamos aprendiendo tu resiliencia — unos días más asentarán el nivel.", + "band": { + "limited": "Limitada", + "adequate": "Adecuada", + "solid": "Sólida", + "strong": "Fuerte", + "exceptional": "Excepcional" + }, + "trend": { + "above": "Por encima de tu media reciente.", + "steady": "En línea con tu media reciente.", + "below": "Por debajo de tu media reciente." + } } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 608aef1a6..fdd7744d3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Écart de température corporelle", "subtitle": "Écart nocturne par rapport à votre référence" + }, + "resilience": { + "explainer": "Comment votre corps équilibre la charge quotidienne et la récupération, d'après vos signaux nocturnes des derniers jours.", + "learning": "Votre résilience est encore en cours d'apprentissage — quelques jours de plus stabiliseront le niveau.", + "band": { + "limited": "Limitée", + "adequate": "Adéquate", + "solid": "Solide", + "strong": "Forte", + "exceptional": "Exceptionnelle" + }, + "trend": { + "above": "Au-dessus de votre moyenne récente.", + "steady": "Conforme à votre moyenne récente.", + "below": "En dessous de votre moyenne récente." + } } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 226eaebb0..310561160 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Deviazione della temperatura corporea", "subtitle": "Scostamento notturno dalla tua linea di base" + }, + "resilience": { + "explainer": "Quanto bene il tuo corpo bilancia il carico quotidiano con il recupero, letto dai tuoi segnali notturni degli ultimi giorni.", + "learning": "Stiamo ancora imparando la tua resilienza — qualche giorno in più stabilizzerà il livello.", + "band": { + "limited": "Limitata", + "adequate": "Adeguata", + "solid": "Solida", + "strong": "Forte", + "exceptional": "Eccezionale" + }, + "trend": { + "above": "Sopra la tua media recente.", + "steady": "In linea con la tua media recente.", + "below": "Sotto la tua media recente." + } } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 4853b2972..de8e33e11 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Odchylenie temperatury ciała", "subtitle": "Nocne odchylenie od Twojej linii bazowej" + }, + "resilience": { + "explainer": "Jak dobrze Twój organizm równoważy codzienne obciążenie z regeneracją — odczytane z nocnych sygnałów z ostatnich dni.", + "learning": "Wciąż poznajemy Twoją odporność — kilka kolejnych dni ustali poziom.", + "band": { + "limited": "Ograniczona", + "adequate": "Wystarczająca", + "solid": "Solidna", + "strong": "Silna", + "exceptional": "Wyjątkowa" + }, + "trend": { + "above": "Powyżej Twojej ostatniej średniej.", + "steady": "Zgodnie z Twoją ostatnią średnią.", + "below": "Poniżej Twojej ostatniej średniej." + } } }, "thresholds": { diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 0018b2cab..4aa8994ed 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -234,3 +234,60 @@ describe(" data-gating", () => { expect(html).not.toContain('data-slot="recovery-empty"'); }); }); + +describe(" on the recovery surface", () => { + it("stays hidden when there is no resilience reading", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }), + RESILIENCE: summary({ count: 0 }), + }), + ); + const html = render(); + expect(html).not.toContain('data-slot="resilience-tile"'); + }); + + it("names the latest band and explains it once enough days exist", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + // latest ordinal 4 → "strong"; mean 3 → above the recent average. + RESILIENCE: summary({ count: 9, latest: 4, mean: 3 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="resilience-tile"'); + expect(html).toContain('data-slot="resilience-band"'); + expect(html).toContain("Strong"); + expect(html).toContain('data-slot="resilience-trend"'); + expect(html).toContain("Above your recent average."); + // Calm by construction — no learning gate once past the threshold. + expect(html).not.toContain('data-slot="resilience-learning"'); + }); + + it("shows the calm learning note while the series is still sparse", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + RESILIENCE: summary({ count: 2, latest: 3, mean: 3 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="resilience-tile"'); + expect(html).toContain('data-slot="resilience-learning"'); + // No trend cue until the band has settled. + expect(html).not.toContain('data-slot="resilience-trend"'); + }); + + it("keeps the recovery surface non-empty when resilience is the only signal", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + SLEEP_DURATION: summary({ count: 10 }), + RESILIENCE: summary({ count: 9, latest: 2, mean: 2 }), + }), + ); + const html = render(); + expect(html).not.toContain('data-slot="recovery-empty"'); + expect(html).toContain('data-slot="resilience-tile"'); + // "adequate" band label is surfaced. + expect(html).toContain("Adequate"); + }); +}); diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 6fdf36cf6..908d96795 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -20,6 +20,7 @@ import type { RestModeAnnotation } from "@/lib/analytics/health-score"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block"; +import { ResilienceTile } from "@/components/insights/recovery/resilience-tile"; interface BlockSpec { type: string; @@ -168,11 +169,17 @@ export function RecoverySection() { }, ]; + // v1.19.2 — resilience is an ordinal band (limited … exceptional), surfaced + // as a calm dedicated tile rather than a chart block, so it counts toward the + // data gate independently of `groups` (a chart-block list). + const hasResilience = (summaries?.["RESILIENCE"]?.count ?? 0) > 0; + const hasAny = summaries != null && - groups.some((group) => - group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), - ); + (hasResilience || + groups.some((group) => + group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), + )); if (isLoading || summaries == null) { return ( @@ -208,6 +215,9 @@ export function RecoverySection() { {/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface. Self-gating: renders nothing unless an episode is active. */} + {/* v1.19.2 — Oura resilience band, self-gating (renders nothing without + a reading). A calm ordinal readout, not a chart block. */} + {presentBlocks.map((block) => ( ` rather than a one-off trend. + */ + +/** Min readings before the "vs recent average" cue is shown. */ +const MIN_TREND_READINGS = 3; + +/** + * The ordinal → static i18n key, in lock-step with `RESILIENCE_LEVELS` + * (limited=1 … exceptional=5). Index 0 is unused so the ordinal indexes + * directly. The keys are written out in full (not `band.${ordinal}`) so the + * i18n call-site coverage guard verifies every one resolves. The band + * vocabulary is Oura's own — not invented here. + */ +const BAND_KEYS = [ + null, + "insights.resilience.band.limited", + "insights.resilience.band.adequate", + "insights.resilience.band.solid", + "insights.resilience.band.strong", + "insights.resilience.band.exceptional", +] as const; + +/** Static trend keys, written out so the i18n coverage guard resolves them. */ +const TREND_KEYS = { + above: "insights.resilience.trend.above", + steady: "insights.resilience.trend.steady", + below: "insights.resilience.trend.below", +} as const; + +export function ResilienceTile() { + const { t } = useTranslations(); + const { data } = useInsightsAnalytics("SLEEP_DURATION"); + const summary = data?.summaries?.["RESILIENCE"]; + + const count = summary?.count ?? 0; + if (count === 0) return null; + + const latest = summary?.latest ?? null; + const mean = summary?.mean ?? null; + // The ordinal lands as a number; clamp into the known 1–5 band range so a + // future Oura level the ingest does not yet recognise can never index out + // of the label table (ingest already drops unknown levels, this is belt). + const ordinal = + latest != null ? Math.min(5, Math.max(1, Math.round(latest))) : null; + const bandKey = ordinal != null ? BAND_KEYS[ordinal] : null; + + // Quiet trend: today's level against the rounded trailing-window mean. Calm + // wording (above / steady / below the recent average), never a colour. Both + // operands are server-computed; nothing is recomputed from raw rows here. + let trendKey: "above" | "steady" | "below" | null = null; + if (count >= MIN_TREND_READINGS && ordinal != null && mean != null) { + const meanOrdinal = Math.round(mean); + if (ordinal > meanOrdinal) trendKey = "above"; + else if (ordinal < meanOrdinal) trendKey = "below"; + else trendKey = "steady"; + } + + return ( + + + + + {bandKey != null ? ( + + {t(bandKey)} + + ) : null} + + +

+ {t("insights.resilience.explainer")} +

+ {count < MIN_TREND_READINGS ? ( + + ) : trendKey != null ? ( +

+ {t(TREND_KEYS[trendKey])} +

+ ) : null} +
+
+ ); +} From 14ba0b013e4c2a2d249a41cb76c77fa8a13c017e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:01:04 +0200 Subject: [PATCH 04/12] fix(charts): step the rollup tier up for multi-year ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-year "All" range read its series through the DAY-only daily reader, which caps the result at BUCKET_CAP.daily (365) buckets. The older history was silently dropped — the range collapsed to roughly the most recent year instead of the whole span. Add readTieredRollupSeries, which reads the bucket tier matching the charts own display downsampler (WEEK for 1-2 years, MONTH beyond) and returns whole-history coverage at that tier. readDailySeries now routes any window wider than the DAY cap through it, falling finer on a coverage miss and logging the miss rather than truncating silently. Short and normal ranges never enter the branch, so the common path is unchanged. --- .../daily-series-read-tiered.test.ts | 157 ++++++++++++++++++ src/lib/measurements/daily-series-read.ts | 40 +++++ .../__tests__/measurement-read-wmy.test.ts | 124 ++++++++++++++ src/lib/rollups/measurement-read-wmy.ts | 134 +++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 src/lib/measurements/__tests__/daily-series-read-tiered.test.ts diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts new file mode 100644 index 000000000..dca7b0525 --- /dev/null +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -0,0 +1,157 @@ +/** + * v1.19.2 W-CHARTS — `readDailySeries` long-range tier step-up. + * + * Pins that a window WIDER than the DAY bucket cap routes through the + * tiered rollup reader (whole-history coverage, downsampled by tier) + * instead of the DAY path that would `LIMIT`/`slice` to the cap and + * silently drop the older history. Short / normal windows must NOT touch + * the tiered reader, so the common case stays byte-identical with the + * pre-v1.19.2 daily path (no perf regression). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + queryRaw: vi.fn(), + readTieredRollupSeries: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurementRollup: { findMany: mocks.findMany }, + $queryRaw: mocks.queryRaw, + }, +})); + +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readTieredRollupSeries: mocks.readTieredRollupSeries, +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeUserRollups: vi.fn(), +})); + +import { readDailySeries } from "../daily-series-read"; + +const DAY_MS = 86_400_000; + +beforeEach(() => { + mocks.findMany.mockReset(); + mocks.queryRaw.mockReset(); + mocks.readTieredRollupSeries.mockReset(); + // Default DAY-rollup read returns enough rows (≥ SUSPICIOUS_ROW_FLOOR) + // so the short-window path resolves without the coverage probe / live + // query firing. + mocks.findMany.mockResolvedValue([ + { + type: "WEIGHT", + source: "MANUAL", + bucketStart: new Date("2026-06-01T00:00:00.000Z"), + mean: 81, + count: 1, + sumValue: null, + minValue: 81, + maxValue: 81, + }, + { + type: "WEIGHT", + source: "MANUAL", + bucketStart: new Date("2026-06-02T00:00:00.000Z"), + mean: 82, + count: 1, + sumValue: null, + minValue: 82, + maxValue: 82, + }, + { + type: "WEIGHT", + source: "MANUAL", + bucketStart: new Date("2026-06-03T00:00:00.000Z"), + mean: 80, + count: 1, + sumValue: null, + minValue: 80, + maxValue: 80, + }, + ]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("readDailySeries — long-range tier step-up", () => { + it("routes a multi-year window through the tiered reader and returns its whole-history rows", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + const tieredRows = [ + { + type: "WEIGHT", + value: 80, + measuredAt: "2017-01-01T00:00:00.000Z", + count: 12, + }, + { + type: "WEIGHT", + value: 79, + measuredAt: "2026-01-01T00:00:00.000Z", + count: 12, + }, + ]; + mocks.readTieredRollupSeries.mockResolvedValueOnce({ + granularity: "MONTH", + rows: tieredRows, + }); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1); + expect(mocks.readTieredRollupSeries.mock.calls[0][0].windowDays).toBe(3650); + // Whole-history coverage: the earliest 2017 bucket survives. + expect(result[0].measuredAt).toBe("2017-01-01T00:00:00.000Z"); + expect(result).toHaveLength(2); + // The DAY-rollup path was NOT consulted for the long window. + expect(mocks.findMany).not.toHaveBeenCalled(); + }); + + it("falls through to the daily path on a tiered coverage miss (no silent empty)", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + mocks.readTieredRollupSeries.mockResolvedValueOnce(null); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1); + // Daily DAY-rollup read still ran as the fallback. + expect(mocks.findMany).toHaveBeenCalled(); + expect(result).toHaveLength(3); + }); + + it("does NOT touch the tiered reader for a normal 90-day window", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 90 * DAY_MS); + + await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + expect(mocks.readTieredRollupSeries).not.toHaveBeenCalled(); + expect(mocks.findMany).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 969c8f140..8759c4e72 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -20,6 +20,7 @@ import { prisma } from "@/lib/db"; import { BUCKET_CAP } from "@/lib/measurements/range-aggregation"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read"; +import { readTieredRollupSeries } from "@/lib/rollups/measurement-read-wmy"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; import { annotate } from "@/lib/logging/context"; @@ -59,6 +60,45 @@ export async function readDailySeries(opts: { const { userId, type, from, to, priorityJson } = opts; const cap = Math.min(opts.limit ?? BUCKET_CAP.daily, BUCKET_CAP.daily); + // v1.19.2 — whole-history step-up for very long ranges. When the + // requested window holds more days than the DAY bucket cap can carry, + // the daily path below would `LIMIT`/`slice` to `cap` (365) buckets and + // silently drop the older history — a multi-year "Alle" range collapsed + // to roughly the most recent year. Step UP the bucket tier + // (DAY → WEEK → MONTH → YEAR) so the series spans the whole window + // inside a sane point budget instead. The chart's own `bucketTimeSeries` + // downsampler already renders week / month points for long ranges, so a + // coarser server tier is the resolution it would have collapsed to + // anyway — minus the truncation. Short / normal ranges (≤ cap days) + // never enter this branch and stay byte-identical with the prior daily + // path, so there is no perf regression for the common case. + const windowDaysFull = Math.ceil( + (to.getTime() - from.getTime()) / 86_400_000, + ); + if (windowDaysFull > cap) { + const tiered = await readTieredRollupSeries({ + userId, + type, + windowDays: windowDaysFull, + priorityJson, + }); + if (tiered && tiered.rows.length > 0) { + return tiered.rows; + } + // Coverage miss at every tier — fall through to the daily path, which + // probes the live table and folds an under-converged window. The + // daily fallback still caps at `cap`; the annotate below records that + // the long-range read could not step up so a truncated daily slice is + // an explicit, logged outcome rather than a silent one. + annotate({ + meta: { + tiered_series_coverage_miss: true, + window_days: windowDaysFull, + type, + }, + }); + } + const readRollup = async () => collapseRollupRowsBySource( await prisma.measurementRollup.findMany({ diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index f47c6dffa..733937bb6 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -44,6 +44,7 @@ vi.mock("@/lib/db", () => ({ import { aggregateWmyBuckets, readBestGranularityRollups, + readTieredRollupSeries, type RollupBucketRow, } from "../measurement-read-wmy"; @@ -286,3 +287,126 @@ describe("readBestGranularityRollups — cross-consumer routing parity", () => { expect(monthAgg.mean).toBeCloseTo(dayAgg.mean ?? Number.NaN, 5); }); }); + +/** + * v1.19.2 W-CHARTS — whole-history series reader. Pins that a very long + * "Alle" window reads the display-matching coarse tier (WEEK for 1–2 y, + * MONTH beyond) and returns coverage spanning the FULL span rather than a + * truncated recent slice; that the finer fallback rescues a coverage + * miss; and that the wire shape mirrors the daily reader. + */ +describe("readTieredRollupSeries", () => { + it("returns null on a non-positive window without touching the db", async () => { + expect( + await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 0, + }), + ).toBeNull(); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("reads the MONTH tier for a multi-year window and spans the whole history", async () => { + // 10-year window → MONTH tier. The fixture carries buckets from the + // OLDEST month through the most recent — the result must keep the + // oldest bucket, proving no recent-slice truncation. + const months: RollupBucketRow[] = []; + for (let y = 2017; y <= 2026; y++) { + months.push(bucket(`${y}-01-01T00:00:00.000Z`, { count: 12, mean: 80 })); + } + findMany.mockResolvedValueOnce(months); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + + expect(result).not.toBeNull(); + expect(result?.granularity).toBe("MONTH"); + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + // Whole-history coverage: the earliest 2017 bucket survives. + expect(result?.rows[0].measuredAt).toBe("2017-01-01T00:00:00.000Z"); + expect(result?.rows.at(-1)?.measuredAt).toBe("2026-01-01T00:00:00.000Z"); + expect(result?.rows.length).toBe(10); + }); + + it("reads the WEEK tier for a 1–2 year window", async () => { + findMany.mockResolvedValueOnce([bucket("2025-08-04T00:00:00.000Z")]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 540, + }); + expect(result?.granularity).toBe("WEEK"); + expect(findMany.mock.calls[0][0].where.granularity).toBe("WEEK"); + }); + + it("falls FINER (MONTH → WEEK → DAY) when the target tier has no coverage", async () => { + findMany + .mockResolvedValueOnce([]) // MONTH miss + .mockResolvedValueOnce([]) // WEEK miss + .mockResolvedValueOnce([bucket("2024-03-01T00:00:00.000Z")]); // DAY hit + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + expect(result?.granularity).toBe("DAY"); + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK"); + expect(findMany.mock.calls[2][0].where.granularity).toBe("DAY"); + }); + + it("returns null when no tier at or below the target carries coverage", async () => { + findMany.mockResolvedValue([]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + expect(result).toBeNull(); + // MONTH, WEEK, DAY all probed (never coarser than the target). + expect(findMany).toHaveBeenCalledTimes(3); + }); + + it("surfaces the cumulative summed total for SUM metrics and drops the spread", async () => { + findMany.mockResolvedValueOnce([ + bucket("2025-01-01T00:00:00.000Z", { + count: 30, + mean: 8000, + sumValue: 240_000, + minValue: 0, + maxValue: 20_000, + }), + ]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "ACTIVITY_STEPS", + windowDays: 3650, + }); + expect(result?.rows[0].value).toBe(240_000); + expect(result?.rows[0].minValue).toBeUndefined(); + expect(result?.rows[0].maxValue).toBeUndefined(); + }); + + it("carries the count-weighted mean + spread for spot metrics", async () => { + findMany.mockResolvedValueOnce([ + bucket("2025-01-01T00:00:00.000Z", { + count: 12, + mean: 81.5, + minValue: 78, + maxValue: 85, + }), + ]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + windowDays: 3650, + }); + expect(result?.rows[0].value).toBe(81.5); + expect(result?.rows[0].minValue).toBe(78); + expect(result?.rows[0].maxValue).toBe(85); + }); +}); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 7d9222ec7..46eb1235d 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -61,6 +61,8 @@ import type { } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { annotate } from "@/lib/logging/context"; import { collapseRollupRowsBySource, loadUserSourcePriority, @@ -272,3 +274,135 @@ async function readGranularity( maxValue: r.maxValue, })); } + +/** + * Wire-row shape the chart-data client consumes — mirrors + * `DailySeriesRow` in `daily-series-read.ts` so a tier-stepped series + * interleaves with the daily path without the caller branching on the + * source granularity. `measuredAt` is the bucket-start ISO string at the + * resolved tier (one row per DAY / WEEK / MONTH / YEAR bucket). + */ +export interface TieredSeriesRow { + type: string; + value: number; + measuredAt: string; + count: number; + minValue?: number | null; + maxValue?: number | null; +} + +/** + * Pick the rollup granularity that matches the chart's own client-side + * `bucketTimeSeries` downsampler (`src/lib/charts/bucket-time-series.ts`): + * + * - > 730 days → MONTH (the chart renders month points) + * - 366–730 → WEEK (the chart renders week points) + * - ≤ 365 → DAY (daily resolution; the DAY cap covers it) + * + * Mirroring the display tier means the server returns exactly the + * resolution the chart will paint — no finer (which would truncate at the + * DAY cap) and no coarser (which would drop visible detail). The finer + * fallback in `readTieredRollupSeries` still rescues a tenant whose + * coarse buckets the worker has not minted yet. + */ +function pickRollupGranularityForWindow(windowDays: number): RollupGranularity { + if (windowDays > 730) return "MONTH"; + if (windowDays > 365) return "WEEK"; + return "DAY"; +} + +/** DAY → WEEK → MONTH → YEAR ordering for the finer-fallback walk. */ +const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; + +/** + * v1.19.2 — whole-history series reader for very long chart ranges. + * + * The DAY-only `readDailySeries` reader caps its result at + * `BUCKET_CAP.daily` (365) buckets. A multi-year "Alle" range therefore + * silently truncated to roughly the most recent year — the older history + * never reached the client even though the chart's own + * `bucketTimeSeries` downsampler would have rendered it as week / month + * points. This reader closes that gap by reading the bucket tier that + * matches the chart's display granularity (WEEK for 1–2 years, MONTH + * beyond), so the returned series spans the WHOLE requested window inside + * a sane point budget instead of being chopped to a recent slice. + * + * The tier is purely a downsampling choice: `count` and the + * count-weighted `mean` compose identically across any granularity (see + * the compositional contract above), so a 5-year window rendered as ~60 + * MONTH points carries the same trend as the ~1 800 DAY points would have + * — minus the truncation. `minValue` / `maxValue` ride through for the + * range band on spot metrics; cumulative metrics (steps, energy, + * distance) surface the bucket's summed total and drop the spread. + * + * Coverage handling: the target tier is read first; on a coverage miss + * (the boot backfill / worker has not minted the coarse buckets yet) the + * reader walks FINER (MONTH → WEEK → DAY) so a tenant still gets a usable + * series rather than an empty chart. Returns `null` only when no tier at + * or below the target carries any buckets for the window — the caller + * then falls through to its live-SQL path. + * + * The result is NOT capped — the tier selection bounds the row count + * (≤ ~104 weeks for the WEEK tier, ≤ ~12 months/year for MONTH). If a + * future tier ever risks an unbounded count it must step coarser rather + * than truncate; no silent cap lives on this path. + */ +export async function readTieredRollupSeries(opts: { + userId: string; + type: MeasurementType; + windowDays: number; + priorityJson?: unknown; +}): Promise<{ + granularity: RollupGranularity; + rows: TieredSeriesRow[]; +} | null> { + const { userId, type, windowDays, priorityJson } = opts; + if (!Number.isFinite(windowDays) || windowDays <= 0) return null; + + const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); + const priority = + priorityJson !== undefined + ? priorityJson + : await loadUserSourcePriority(userId); + + const target = pickRollupGranularityForWindow(windowDays); + // Walk the target tier first, then finer (never coarser — coarser would + // drop detail the chart can render). Stop at the first tier with coverage. + const targetIdx = TIER_ORDER.indexOf(target); + for (let i = targetIdx; i >= 0; i--) { + const granularity = TIER_ORDER[i]; + const rows = await readGranularity( + userId, + type, + granularity, + since, + priority, + ); + if (rows && rows.length > 0) { + const useSum = CUMULATIVE_HK_TYPES.has(type); + annotate({ + action: { name: "measurement.list" }, + meta: { + total: rows.length, + type, + aggregate: "tiered", + granularity, + target_granularity: target, + source: "rollup", + }, + }); + return { + granularity, + rows: rows.map((r) => ({ + type, + value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean, + measuredAt: r.bucketStart.toISOString(), + count: r.count, + minValue: useSum ? undefined : r.minValue, + maxValue: useSum ? undefined : r.maxValue, + })), + }; + } + } + return null; +} From 9ea258556d30a77ab939fb7ed0008a8e3f530787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:01:10 +0200 Subject: [PATCH 05/12] docs(charts): note the multi-year tier step-up in the chart data path The chart still requests aggregate=daily for windows over 7 days, but the server now steps the rollup tier up to WEEK / MONTH for ranges past the DAY cap and returns whole-history coverage. Update the stale comments that claimed the "All" range was still capped at ~365 daily buckets; the client fold + bucketTimeSeries downsample the coarse buckets to the visible range as before. --- src/components/charts/health-chart.tsx | 29 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 38b37e72f..dcc1c6454 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -89,9 +89,14 @@ const TIME_RANGES_KEYS = [ * A generous fixed bound (~10 years) that is clearly larger than any * other range tab, so "All" means "all of my history" rather than the * old silent 365-day truncation. Fixed (not per-account derived) so the - * fetch-window cache key stays stable across the session. The server's - * daily-aggregate reader still caps the returned bucket count, so this - * only widens the requested window, never the render cost. + * fetch-window cache key stays stable across the session. + * + * v1.19.2 — the server's daily-aggregate reader now steps UP the bucket + * tier (DAY → WEEK → MONTH) for windows wider than the DAY cap, so a + * multi-year "All" range returns whole-history coverage downsampled to + * the tier the chart's `bucketTimeSeries` would render anyway, instead of + * truncating to the most recent ~365 daily buckets. The render cost stays + * flat — the coarse tier bounds the point count. */ const ALL_RANGE_DAYS = 3650; @@ -650,10 +655,13 @@ export function HealthChart({ // (~10 years) rather than the old 365-day cap, which silently truncated // any account holding more than a year of history. The window is a // generous fixed bound (not derived per-account) so the cache key stays - // stable across the session; the daily-aggregate reader caps the - // RETURNED bucket count at `BUCKET_CAP.daily` (365), so the chart still - // paints at most ~365 daily buckets — see the report's note on raising - // that server-side ceiling for true multi-year coverage. + // stable across the session. + // + // v1.19.2 — the server reader steps the bucket tier up to WEEK / MONTH + // for windows past the DAY cap, so the "All" tab now returns coverage + // across the whole history (downsampled by tier) rather than the most + // recent ~365 daily buckets. The client folds those coarse buckets and + // `bucketTimeSeries` re-buckets to the visible range as before. const fetchWindow = useMemo(() => { const to = new Date(); const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS; @@ -845,6 +853,13 @@ export function HealthChart({ // falls back to live SQL when the rollup is empty for the // requested window so brand-new accounts still see a chart // on their first render. + // + // v1.19.2 — the `aggregate=daily` ask still holds, but for windows + // wider than the DAY bucket cap the server steps the rollup tier + // up to WEEK / MONTH and returns whole-history coverage at that + // tier. Each returned row is one coarse bucket; the daily fold + + // `bucketTimeSeries` below downsample it to the visible range, so + // the request contract is unchanged. if (fetchWindow.windowDays > 7) { typeParams.set("aggregate", "daily"); typeParams.set("source", "rollup"); From fc76c39aeae6fd0e0d489bf62cd1fde8f167ebd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:06 +0200 Subject: [PATCH 06/12] feat(illness): fold the logged symptom curve into the recovery gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery-gap engine reasoned only over passive vitals and ignored the journal's own per-day symptom series. Add the functional-impact (symptom-burden) return track: read functionalImpact per episode day (falling back to the strongest linked symptom severity) and treat it as one more return against the known-constant baseline (healthy = 0). The symptom track needs none of the MAD-band / contamination machinery the passive vitals carry — the band is the constant 0 — so it is more reliable to fold in than any passive vital. It is adverse by construction, so it contributes to adverseCoverageDays and the headline gap median, giving the qualifying-days floor a genuinely illness-relevant contributor where a weight-only episode had none. Stability is logged-days-only: a sparse journal that logs impact-0 once and stops withholds the return rather than fabricating an all-clear from the absence of further logs. gapDriverType names the dominant return track, with the symptom curve winning ties as the most illness-specific signal. --- src/lib/illness/__tests__/correlation.test.ts | 195 ++++++++++++++++++ src/lib/illness/correlation-read.ts | 58 +++++- src/lib/illness/correlation.ts | 180 +++++++++++++++- src/lib/openapi/routes/illness.ts | 6 +- 4 files changed, 435 insertions(+), 4 deletions(-) diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 27bc955b3..fd5a855b3 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -16,10 +16,12 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { computeIllnessCorrelation, dayDiff, + FUNCTIONAL_IMPACT_RETURN_KEY, isAdverseDeviation, MIN_BASELINE_DAYS, MIN_EPISODE_COVERAGE_DAYS, type IllnessCorrelationInput, + type SymptomBurdenPoint, type VitalDayPoint, } from "../correlation"; @@ -615,3 +617,196 @@ describe("computeIllnessCorrelation — return anchor", () => { expect(out.value.recoveryGapDays).toBeGreaterThan(0); }); }); + +describe("computeIllnessCorrelation — symptom-burden return track", () => { + // A banded RHR vital present in EVERY case below clears the engine's + // episode-coverage floor (the gate counts banded-vital days, not symptom + // days). Its band stays IN throughout so it produces no adverse signal and + // never competes with the symptom track for the driver — it is scaffolding. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02"); + const rhrFlat: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 55 }, + { day: "2026-01-11", mean: 55 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, + ]; + + it("folds a functional-impact return into the gap with a constant-0 baseline", () => { + // Onset 01-10, felt better 01-14. Logged impact: bedbound early, eases to 0 + // on 01-16 and HOLDS for 3 logged days → symptom return 01-16, gap +2. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-14", impact: 1 }, // felt-better day, still symptomatic + { day: "2026-01-16", impact: 0 }, // return start + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, // 3 logged in-band days → stable + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym).toBeDefined(); + expect(sym?.returnedDay).toBe("2026-01-16"); + expect(sym?.adverse).toBe(true); + // Gap = 01-16 − 01-14 = +2. RHR stayed flat (no gap), so the symptom track + // is the only contributor → headline gap = +2. + expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-16")); + expect(out.value.recoveryGapDays).toBe(2); + // It contributes to the adverse-coverage floor (3 logged adverse days). + expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(3); + expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY); + }); + + it("WITHHOLDS the symptom return when logging is too sparse to stabilise", () => { + // The user logs while sick, then logs impact-0 ONCE and stops — the + // 3-logged-day stability run can never fire. The symptom track must + // contribute NO gap (honest withholding), never fabricate recovery from the + // absence of further logs. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, // single trailing in-band log + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym).toBeDefined(); + expect(sym?.returnedDay).toBeNull(); + expect(sym?.gapDays).toBeNull(); + // No vital returned either → no headline gap fabricated. + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.gapDriverType).toBeNull(); + // The logged adverse days still count toward coverage (honest). + expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(2); + }); + + it("lets the symptom track win the driver over a co-returning vital", () => { + // Both the symptom curve AND an adverse RHR spike return and produce gaps; + // the functional-impact track must win `gapDriverType` on a tie (it is the + // most illness-specific signal). + const rhrSpike: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // adverse up + { day: "2026-01-11", mean: 74 }, + { day: "2026-01-16", mean: 55 }, // back in band + { day: "2026-01-17", mean: 55 }, + { day: "2026-01-18", mean: 55 }, // 3 stable + ]; + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, // same return day → same gap as RHR + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrSpike }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + const rhrReturn = out.value.returns.find((r) => r.type === rhr); + expect(sym?.gapDays).toBe(2); + expect(rhrReturn?.gapDays).toBe(2); + expect(out.value.recoveryGapDays).toBe(2); + // Tie on |gap − median| → functional-impact wins. + expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY); + }); + + it("ignores a symptom curve with no adverse day (nothing to return from)", () => { + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 0 }, + { day: "2026-01-12", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect( + out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY), + ).toBeUndefined(); + }); + + it("never produces a symptom return for CHRONIC_ONGOING", () => { + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-16", impact: 0 }, + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "CHRONIC_ONGOING", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.gapDriverType).toBeNull(); + expect( + out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts index b9e1c0dfd..ca05de559 100644 --- a/src/lib/illness/correlation-read.ts +++ b/src/lib/illness/correlation-read.ts @@ -39,6 +39,7 @@ import { computeIllnessCorrelation, type DayLogFeverPoint, type IllnessCorrelationValue, + type SymptomBurdenPoint, type VitalSeries, } from "./correlation"; @@ -103,7 +104,7 @@ export async function computeEpisodeCorrelation( // run paired rather than serially (halving the per-type round-trips). // The day-log fever read shares the same fan-out budget. const limit = pLimit(VITAL_SCAN_CONCURRENCY); - const [perType, dayLogFever] = await Promise.all([ + const [perType, dayLogFever, symptomBurden] = await Promise.all([ Promise.all( ILLNESS_SCAN_TYPES.map((type) => limit(async () => { @@ -132,6 +133,7 @@ export async function computeEpisodeCorrelation( ), ), limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)), + limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)), ]); const series: VitalSeries[] = []; @@ -167,6 +169,7 @@ export async function computeEpisodeCorrelation( window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle }, series, dayLogFever, + symptomBurden, source, now, }); @@ -275,3 +278,56 @@ async function readDayLogFever( .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([day, feverC]) => ({ day, feverC })); } + +/** + * Read the illness day-log symptom-burden series over [from, to) — the user's + * own logged daily-impact curve, keyed by the stored `date` (already the + * user-tz day string, so NO tz conversion). Mirrors `readDayLogFever`: one + * `findMany`, present days only, no zero-fill. + * + * Per day the burden is `functionalImpact` (0–3, the deliberate daily slider) + * when logged; else the day's MAX linked `IllnessSymptomLink.severity` (0–3) as + * a fallback corroborator. A day with neither is dropped (absent from the + * series — the engine treats a missing day as "not logged", never an + * implicit 0). + */ +async function readDayLogSymptomBurden( + episodeId: string, + from: Date, + to: Date, + tz: string, +): Promise { + const fromKey = dayKeyForUserTz(from, tz); + const toKey = dayKeyForUserTz(to, tz); + const rows = await prisma.illnessDayLog.findMany({ + where: { + episodeId, + deletedAt: null, + date: { gte: fromKey, lte: toKey }, + }, + select: { + date: true, + functionalImpact: true, + symptomLinks: { select: { severity: true } }, + }, + }); + const points: SymptomBurdenPoint[] = []; + for (const row of rows) { + if (row.functionalImpact != null) { + points.push({ day: row.date, impact: row.functionalImpact }); + continue; + } + // Fallback corroborator: the day's strongest linked symptom severity. + let maxSeverity: number | null = null; + for (const link of row.symptomLinks) { + if (link.severity == null) continue; + maxSeverity = + maxSeverity === null + ? link.severity + : Math.max(maxSeverity, link.severity); + } + if (maxSeverity != null) + points.push({ day: row.date, impact: maxSeverity }); + } + return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index e220b0f14..7756c7c33 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -66,6 +66,21 @@ export const NOTABLE_SD = 2; * for the recovery-gap (one lucky in-band day is not a recovery). */ export const RETURN_STABILITY_DAYS = 3; +/** + * Sentinel `type` for the functional-impact return track. The symptom curve is + * NOT a `MeasurementType` — it is the user's own logged daily-impact slider — + * so it rides `returns[]` under a stable string key the surface keys copy off + * (the typed `VitalReturnFinding.type` is widened to accept it). Tallied like + * any vital type in `gapReturnTypes` so it can win `gapDriverType`. + */ +export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const; +/** + * Functional-impact threshold (0–3 slider) at/above which a logged day counts + * as ADVERSE (symptomatic). The baseline is the known constant 0 (healthy), so + * the symptom curve needs none of the MAD-band / contamination machinery the + * passive vitals carry — "return" is simply impact back to below this floor. + */ +export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1; /** Minimum episode days with ANY vital coverage before the gap is asserted. */ export const MIN_EPISODE_COVERAGE_DAYS = 4; @@ -153,6 +168,22 @@ export interface DayLogFeverPoint { feverC: number; } +/** + * A user-logged symptom-burden reading from the illness day-log, keyed by the + * stored `date` (already the user-tz local day). `impact` is the day's + * `functionalImpact` slider (0–3, 0 = fully functional … 3 = bedbound); when + * NULL it is the day's max linked `IllnessSymptomLink.severity` (0–3) as a + * fallback corroborator, or null when neither was logged. Present days only — + * never zero-filled (mirrors the fever read), so sparse logging WITHHOLDS the + * symptom return rather than fabricating an all-clear from absence of rows. + */ +export interface SymptomBurdenPoint { + /** Local day key, `YYYY-MM-DD`. */ + day: string; + /** The day's symptom burden (0–3): functionalImpact, else max severity. */ + impact: number; +} + /** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */ export interface EpisodeWindow { /** Onset day. */ @@ -174,6 +205,17 @@ export interface IllnessCorrelationInput { * escalation even with no passive BODY_TEMPERATURE rows. Optional. */ dayLogFever?: DayLogFeverPoint[]; + /** + * Per-day symptom-burden readings from the illness day-log (`functionalImpact` + * primary, max linked symptom `severity` as fallback). Folded into the + * recovery-gap as ONE more return track against the known-constant baseline + * (healthy = 0): a "symptom return" is the burden back below the adverse floor + * and held for `RETURN_STABILITY_DAYS` LOGGED days. Always `adverse:true` (it + * is illness-relevant by construction), so it contributes to + * `adverseCoverageDays` and can drive the headline gap even on a + * passive-vital-thin episode. Optional. + */ + symptomBurden?: SymptomBurdenPoint[]; /** Provenance source the dominant read resolved against. */ source: "DAY" | "live" | "none"; /** Compute time (injected for deterministic tests). */ @@ -201,7 +243,11 @@ export interface VitalDeviationFinding { /** A vital's recovery timing within the episode. */ export interface VitalReturnFinding { - type: MeasurementType; + /** + * The metric the return describes — a `MeasurementType` for a passive vital, + * or `FUNCTIONAL_IMPACT_RETURN_KEY` for the user-logged symptom-burden track. + */ + type: MeasurementType | typeof FUNCTIONAL_IMPACT_RETURN_KEY; /** First day the vital re-entered its band AND stayed in for the window. */ returnedDay: string | null; /** Days from felt-better to physiological return (signed); null if N/A. */ @@ -255,6 +301,16 @@ export interface IllnessCorrelationValue { adverseCoverageDays: number; /** Felt-better marker echoed for the surface. */ feltBetterDay: string | null; + /** + * The metric whose physiological return dominates the headline gap — the + * adverse return-track whose own gap is closest to the median, with the + * functional-impact symptom track winning ties (it is the most + * illness-specific signal). A `MeasurementType`, + * `FUNCTIONAL_IMPACT_RETURN_KEY`, or null when no adverse return drove a gap. + * The card names it ("…before your symptoms eased" vs "…before your resting + * heart rate settled"). Retrospective only. + */ + gapDriverType: string | null; /** * Red-flag escalation — a sustained adverse SpO2 drop or sustained fever * during the episode. Retrospective, but the copy must escalate ("if this @@ -471,6 +527,21 @@ export function computeIllnessCorrelation( } } + // Symptom-burden return track — the user's OWN logged daily-impact curve, + // folded in as one more return against the KNOWN-CONSTANT baseline (healthy + // = 0). No MAD band, no contamination guard, no min-baseline floor: the band + // IS the constant 0, so it sidesteps every reliability caveat the passive + // vitals carry. Always adverse by construction (a logged impact is illness- + // relevant), so it contributes to `adverseCoverageDays` and the headline gap + // even when the passive vitals are thin — the genuine illness-relevant + // contributor the WEIGHT-only floor lacked. Logged-days-only stability: + // sparse logging WITHHOLDS the return rather than fabricating an all-clear. + const symptomReturn = computeSymptomReturn(input.symptomBurden ?? [], window); + if (symptomReturn) { + returns.push(symptomReturn.finding); + for (const day of symptomReturn.adverseDays) adverseDayKeys.add(day); + } + // Red-flag escalation runs DECOUPLED from the banded loop: it scans the raw // episode days for SpO2 + temperature against ABSOLUTE clinical floors, so a // rock-steady (MAD=0) vital that drops the band filter still escalates — the @@ -489,6 +560,31 @@ export function computeIllnessCorrelation( ? null : Math.round(median(gaps)); + // Name the per-episode driver: among the ADVERSE return tracks that produced + // a real gap, the one whose own gap sits closest to the headline median (it + // is the return that "explains" the headline). The functional-impact symptom + // track wins ties — it is the most illness-specific signal the engine has, so + // when it co-explains the gap it gets to name it ("…before your symptoms + // eased"). Null when no adverse return drove a gap. + const gapDriverType = + recoveryGapDays === null + ? null + : (returns + .filter( + (r): r is VitalReturnFinding & { gapDays: number } => + r.gapDays !== null && r.adverse, + ) + .sort((a, b) => { + const da = Math.abs(a.gapDays - recoveryGapDays); + const db = Math.abs(b.gapDays - recoveryGapDays); + if (da !== db) return da - db; + // Tie: prefer the functional-impact symptom track, then by name. + const fa = a.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1; + const fb = b.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1; + if (fa !== fb) return fa - fb; + return String(a.type) < String(b.type) ? -1 : 1; + })[0]?.type ?? null); + const { coverage, confidence } = deriveCoverage({ requiredInputs: 1, presentInputs: banded.length, @@ -510,6 +606,7 @@ export function computeIllnessCorrelation( recoveryGapDays, adverseCoverageDays: adverseDayKeys.size, feltBetterDay: window.feltBetterDay, + gapDriverType: gapDriverType === null ? null : String(gapDriverType), redFlags, }, coverage, @@ -544,6 +641,87 @@ function firstStableReturn( return null; } +/** + * Compute the functional-impact (symptom-burden) return track for one episode. + * + * The symptom curve's baseline is the KNOWN CONSTANT 0 (healthy), so this needs + * none of the passive vitals' MAD-band / contamination machinery — "adverse" is + * simply `impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR`, "in band" is below it. + * + * - Only LOGGED days (present in `burden`) participate — never zero-filled. + * - The anchor is the first logged adverse day (same role as the vital + * `firstDeviationIndex`): a return search starts AFTER it, so a run-up is + * never read as a return. + * - A "return" is the first logged day at/after the anchor that is in-band AND + * stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days + * stability (not calendar days) is the honest-withholding handler for sparse + * journals: a single trailing impact-0 log never satisfies the run, so the + * track WITHHOLDS rather than fabricating recovery from absence of rows. + * - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic. + * - Always `adverse:true` (a logged symptom curve is illness-relevant), so it + * feeds `adverseCoverageDays` and can drive the headline gap. + * + * Returns null (contributes nothing) when no adverse day was logged or the + * episode is CHRONIC_ONGOING. `adverseDays` is the set of logged adverse days in + * the active span, for the coverage tally. + */ +function computeSymptomReturn( + burden: SymptomBurdenPoint[], + window: EpisodeWindow, +): { finding: VitalReturnFinding; adverseDays: string[] } | null { + if (window.lifecycle === "CHRONIC_ONGOING") return null; + // Active span only, chronological. (Logged days are sparse and arbitrary, so + // a stable sort by the day key is the canonical order.) + const active = burden + .filter((p) => p.day >= window.onsetDay) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + if (active.length === 0) return null; + + const adverseDays = active + .filter((p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) + .map((p) => p.day); + // No adverse day logged → there is nothing to "return" from. + if (adverseDays.length === 0) return null; + + // Anchor: first logged adverse day. The return search starts there so an + // early in-band log (impact 0 before symptoms appeared) is never a return. + const firstAdverseIndex = active.findIndex( + (p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR, + ); + let returnedDay: string | null = null; + for (let i = firstAdverseIndex; i < active.length; i++) { + if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue; + // Count consecutive in-band LOGGED days from here. + let run = 0; + for ( + let j = i; + j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR; + j++ + ) { + run++; + } + if (run >= RETURN_STABILITY_DAYS) { + returnedDay = active[i].day; + break; + } + } + + const gapDays = + returnedDay && window.feltBetterDay + ? dayDiff(window.feltBetterDay, returnedDay) + : null; + + return { + finding: { + type: FUNCTIONAL_IMPACT_RETURN_KEY, + returnedDay, + gapDays, + adverse: true, + }, + adverseDays, + }; +} + /** * Detect the retrospective red flags — DECOUPLED from the banded loop and the * own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 900f49119..4b8131e8a 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -181,11 +181,12 @@ const illnessVitalReturn = z type: z.string(), returnedDay: z.string().nullable(), gapDays: z.number().nullable(), + adverse: z.boolean(), }) .meta({ id: "IllnessVitalReturn", description: - "A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) from the felt-better marker (positive = the body lagged the feeling).", + "A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0).", }); const illnessRedFlag = z @@ -210,12 +211,13 @@ const illnessCorrelationValue = z recoveryGapDays: z.number().nullable(), adverseCoverageDays: z.number().int(), feltBetterDay: z.string().nullable(), + gapDriverType: z.string().nullable(), redFlags: z.array(illnessRedFlag), }) .meta({ id: "IllnessCorrelationValue", description: - "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags.", + "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags.", }); const illnessCorrelationResponse = z From 868dfb72bd797a8bb1dde7d52a6aaab202da06a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:09 +0200 Subject: [PATCH 07/12] feat(measurements): add per-bucket heart-rate min/max to the hourly contract The v1.19.0 hourly heart-rate bucket (`stats:HKQuantityTypeIdentifierHeartRate:`) shipped avg-only: one PULSE row carrying the hour's mean bpm. Add the hour's min/max so a client can render the intra-hour range without re-uploading raw samples. - Two nullable columns on `measurements` (`value_min` / `value_max`), migration 0188. Set only on a well-formed hourly HR bucket row; null for per-sample readings, per-day cumulative totals, and manual entries. - Batch ingest threads the spread through the same unit conversion as the average and persists / overwrites it alongside `value` on the bucket row, pinning it null everywhere else. - The series reader returns the spread for `kind=pulse` (null on a per-sample PULSE row); the OpenAPI Zod contract documents both fields on the batch entry and the series point. The avg path is unchanged. --- .../migration.sql | 33 +++++++++ prisma/schema.prisma | 10 +++ .../batch/__tests__/hourly-hr-wire.test.ts | 70 ++++++++++++++++++- src/app/api/measurements/batch/route.ts | 34 +++++++++ .../series/__tests__/route.test.ts | 45 ++++++++++++ src/app/api/measurements/series/route.ts | 39 ++++++++++- src/lib/measurements/apple-health-mapping.ts | 28 ++++++++ src/lib/openapi/routes/measurements.ts | 30 +++++++- 8 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql diff --git a/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql new file mode 100644 index 000000000..e143e9af6 --- /dev/null +++ b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql @@ -0,0 +1,33 @@ +-- v1.19.2 (iOS #34 extension) — per-bucket heart-rate spread. +-- +-- v1.19.0 shipped the hourly heart-rate bucket +-- (`stats:HKQuantityTypeIdentifierHeartRate:`) carrying the +-- hour's AVERAGE bpm as one PULSE row. This adds the hour's MIN / MAX bpm +-- as two companion columns so a client can render the intra-hour range +-- (low/high band around the mean) without iOS re-uploading raw samples. +-- +-- Two additive, nullable columns on `measurements`: +-- +-- * `value_min` — the bucket's minimum bpm. +-- * `value_max` — the bucket's maximum bpm. +-- +-- Set ONLY on the hourly heart-rate bucket rows; NULL for every other row +-- (per-sample readings, per-day cumulative `stats:` totals, manual +-- entries). The series reader hands them through only for the heart-rate +-- kind, and the batch ingest persists / overwrites them alongside `value`. +-- +-- Additive; no existing row touched. The columns default to NULL so every +-- legacy row and every non-HR-bucket write stays unchanged. Idempotent +-- guards (`ADD COLUMN IF NOT EXISTS`) so a rerun is safe on prod. +-- +-- Reversibility (down): +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_min"; +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_max"; +-- The columns are read-additive — dropping them reverts to the avg-only +-- v1.19.0 contract. + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "value_min" DOUBLE PRECISION; + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "value_max" DOUBLE PRECISION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1ffce14c..54d7485f1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1026,6 +1026,16 @@ model Measurement { userId String @map("user_id") type MeasurementType value Float + // v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row. + // Set ONLY on the hourly heart-rate bucket + // (`stats:HKQuantityTypeIdentifierHeartRate:`), where `value` + // is the hour's AVERAGE bpm and these carry the hour's MIN / MAX bpm so a + // client can render the intra-hour range without re-uploading raw + // samples. NULL for every other row (per-sample readings, per-day + // cumulative `stats:` totals, manual entries) — the series reader hands + // them through only for the heart-rate kind. + valueMin Float? @map("value_min") + valueMax Float? @map("value_max") unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius source MeasurementSource @default(MANUAL) measuredAt DateTime @map("measured_at") diff --git a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts index 09f6ceaad..839e85d2f 100644 --- a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts @@ -85,7 +85,11 @@ function makeRequest(body: unknown): NextRequest { }); } -function hrBucketEntry(externalId: string, value: number) { +function hrBucketEntry( + externalId: string, + value: number, + spread?: { valueMin: number; valueMax: number }, +) { return { hkIdentifier: "HKQuantityTypeIdentifierHeartRate", value, @@ -93,6 +97,7 @@ function hrBucketEntry(externalId: string, value: number) { startDate: "2026-06-21T14:00:00.000Z", endDate: "2026-06-21T14:59:59.000Z", externalId, + ...(spread ?? {}), }; } @@ -146,6 +151,69 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", ( expect(rows[0].value).toBe(72); }); + it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry(HR_BUCKET_ID, 72, { valueMin: 58, valueMax: 96 }), + ], + }), + ); + expect(res.status).toBe(200); + const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0]; + const rows = ( + createArg as { + data: { value: number; valueMin: number; valueMax: number }[]; + } + ).data; + expect(rows[0].value).toBe(72); + expect(rows[0].valueMin).toBe(58); + expect(rows[0].valueMax).toBe(96); + }); + + it("overwrites min/max alongside the average on a re-post", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID }, + ] as never); + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry(HR_BUCKET_ID, 78, { valueMin: 55, valueMax: 110 }), + ], + }), + ); + expect(res.status).toBe(200); + const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0]; + const data = ( + updateArg as { + data: { value: number; valueMin: number; valueMax: number }; + } + ).data; + expect(data.value).toBe(78); + expect(data.valueMin).toBe(55); + expect(data.valueMax).toBe(110); + }); + + it("leaves min/max null on a per-sample PULSE row even if sent", async () => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000"; + const res = await POST( + makeRequest({ + entries: [hrBucketEntry(SAMPLE_ID, 64, { valueMin: 50, valueMax: 80 })], + }), + ); + expect(res.status).toBe(200); + const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0]; + const rows = ( + createArg as { + data: { valueMin: number | null; valueMax: number | null }[]; + } + ).data; + expect(rows[0].valueMin).toBeNull(); + expect(rows[0].valueMax).toBeNull(); + }); + it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => { // The bucket already exists from an earlier within-hour post. vi.mocked(prisma.measurement.findMany).mockResolvedValue([ diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index d56a1d236..bb9e9b1d6 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -124,6 +124,15 @@ const batchEntrySchema = z.object({ // device's own verdict / severity; resolved to a `rhythmClassification` // by `mapAppleHealthEntry`. Ignored for every non-event identifier. categoryValue: z.number().int().min(0).max(20).optional(), + // v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly + // heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry + // the hour's MIN / MAX so a client renders the intra-hour band without + // re-uploading raw samples. Persisted ONLY on a well-formed + // `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored + // NULL) for every other entry. Optional + backward-compatible: a pre-W192 + // iOS build omits them and the bucket keeps the avg-only contract. + valueMin: z.number().finite().optional(), + valueMax: z.number().finite().optional(), externalId: z.string().min(1).max(120), externalSourceVersion: z.string().min(1).max(120).optional(), // v1.8.6 W6 — optional per-entry source tag. Defaults to @@ -255,6 +264,8 @@ async function postBatch(request: NextRequest): Promise { endDate: entry.endDate, sleepStage: entry.sleepStage, categoryValue: entry.categoryValue, + valueMin: entry.valueMin, + valueMax: entry.valueMax, }); if (!mapped) { @@ -301,6 +312,21 @@ async function postBatch(request: NextRequest): Promise { continue; } + // v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted + // ONLY on a well-formed hourly HR bucket row. A per-sample reading, a + // per-day cumulative `stats:` total, or a manual entry never carries a + // spread, so we pin them to null there even if a client mis-sends the + // fields — `value` stays the single source of truth for those rows. + const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId); + const valueMin = + isHrBucket && typeof mapped.valueMin === "number" + ? mapped.valueMin + : null; + const valueMax = + isHrBucket && typeof mapped.valueMax === "number" + ? mapped.valueMax + : null; + prepared.push({ index, entry, @@ -308,6 +334,8 @@ async function postBatch(request: NextRequest): Promise { userId: user.id, type: mapped.type, value: mapped.value, + valueMin, + valueMax, unit: mapped.unit, // v1.8.6 W6 — honour the per-entry source tag, defaulting to // `APPLE_HEALTH` when absent so legacy callers are unchanged. @@ -505,6 +533,12 @@ async function postBatch(request: NextRequest): Promise { }, data: { value: p.row.value as number, + // v1.19.2 (iOS #34 extension) — overwrite the per-bucket + // spread alongside `value` so a within-hour re-post (the + // running mean + range shift as more samples land) replaces + // both. Null for every non-HR-bucket `stats:` overwrite. + valueMin: p.row.valueMin as number | null, + valueMax: p.row.valueMax as number | null, unit: p.row.unit as string, measuredAt: p.row.measuredAt as Date, externalSourceVersion: p.row.externalSourceVersion as diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 7e8c4d0d4..e4070e049 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -134,6 +134,51 @@ describe("GET /api/measurements/series", () => { expect(body.data.points[0].secondary).toBeNull(); }); + it("surfaces per-point min/max for kind=pulse (iOS #34 ext)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + // hourly HR bucket — average + spread + { + id: "p1", + value: 72, + measuredAt: new Date("2026-06-21T14:00:00.000Z"), + valueMin: 58, + valueMax: 96, + }, + // per-sample PULSE row — no spread + { + id: "p2", + value: 64, + measuredAt: new Date("2026-06-21T15:00:00.000Z"), + valueMin: null, + valueMax: null, + }, + ] as never); + const res = await GET(req("kind=pulse&days=7")); + const body = (await res.json()) as { + data: { + points: Array<{ + value: number; + valueMin: number | null; + valueMax: number | null; + }>; + }; + }; + expect(body.data.points[0].valueMin).toBe(58); + expect(body.data.points[0].valueMax).toBe(96); + expect(body.data.points[1].valueMin).toBeNull(); + expect(body.data.points[1].valueMax).toBeNull(); + + // The pulse read selects the spread columns. + const selectArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]; + expect( + (selectArg as { select: Record }).select.valueMin, + ).toBe(true); + expect( + (selectArg as { select: Record }).select.valueMax, + ).toBe(true); + }); + it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => { // SLEEP_DURATION is stored one row per STAGE per night (minutes). The // series must return ONE point per night carrying the TIME-ASLEEP diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 505bbc6a6..23f16bc75 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -124,6 +124,15 @@ interface SeriesPoint { * the night's TIME-ASLEEP total. */ sleepStages?: Partial> | null; + /** + * v1.19.2 (iOS #34 extension) — per-point spread for `kind=pulse`. On an + * aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm + * and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row + * (no bucket spread) and absent for every non-pulse kind, so a client can + * render a low/high band only where the data supports it. + */ + valueMin?: number | null; + valueMax?: number | null; } function stdDev(values: number[]): number { @@ -317,6 +326,11 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } else { const type = KIND_TO_TYPE[kind]; + // v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the + // heart-rate kind so the chart can render a low/high band around the + // hourly average. Only PULSE rows ever carry a non-null spread (the + // hourly HR bucket); every other kind selects the bare value shape. + const includeSpread = kind === "pulse"; const rows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -325,7 +339,12 @@ export const GET = apiHandler(async (request: NextRequest) => { deletedAt: null, }, orderBy: { measuredAt: "asc" }, - select: { id: true, value: true, measuredAt: true }, + select: { + id: true, + value: true, + measuredAt: true, + ...(includeSpread ? { valueMin: true, valueMax: true } : {}), + }, }); if (kind === "glucose") { // v1.16.16 — glucose is stored canonical mg/dL; convert each point to @@ -379,6 +398,24 @@ export const GET = apiHandler(async (request: NextRequest) => { count: rawSummary.count, }; } + } else if (includeSpread) { + // v1.19.2 (iOS #34 extension) — pulse points carry the per-bucket + // spread. A per-sample PULSE row has null min/max; an hourly HR bucket + // carries the hour's range around the average. + points = rows.map((r) => { + const row = r as typeof r & { + valueMin: number | null; + valueMax: number | null; + }; + return { + id: row.id, + at: row.measuredAt.toISOString(), + value: row.value, + secondary: null, + valueMin: row.valueMin ?? null, + valueMax: row.valueMax ?? null, + }; + }); } else { points = rows.map((r) => ({ id: r.id, diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 866ecc5c6..d6be7265a 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -1082,6 +1082,16 @@ export interface AppleHealthEntryInput { * existing category-value slot still round-trips. */ categoryValue?: number; + /** + * v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated + * heart-rate row. The hour's MIN / MAX bpm in Apple's native unit + * (pre-conversion), companions to `value` (the hour's AVERAGE). The + * mapper applies the SAME `convertToDbUnit` it applies to `value`. + * Ignored for every non-quantity / non-HR-bucket entry; the caller + * persists them only on the hourly HR bucket row. + */ + valueMin?: number; + valueMax?: number; } /** Output of `mapAppleHealthEntry()`. */ @@ -1090,6 +1100,13 @@ export interface AppleHealthEntryOutput { value: number; unit: string; takenAt: Date; + /** + * v1.19.2 (iOS #34 extension) — converted per-bucket MIN / MAX, set + * only when the input carried them (the hourly HR bucket). NULL/absent + * for every other row. + */ + valueMin?: number; + valueMax?: number; /** Set only for sleep entries. */ sleepStage?: SleepStage; /** @@ -1132,6 +1149,17 @@ export function mapAppleHealthEntry( takenAt, }; + // v1.19.2 (iOS #34 extension) — carry the per-bucket MIN / MAX through + // the SAME unit conversion as `value` when the caller supplied them (the + // hourly HR bucket). A finite check guards a malformed payload; the + // caller only persists these on the HR-bucket row. + if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) { + out.valueMin = mapping.convertToDbUnit(input.valueMin); + } + if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) { + out.valueMax = mapping.convertToDbUnit(input.valueMax); + } + if (mapping.sleepStageMap) { if (input.sleepStage === undefined) return null; const stage = mapping.sleepStageMap[input.sleepStage]; diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9af53ec26..0c2a70172 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -30,7 +30,21 @@ const batchEntrySchema = z .number() .finite() .describe( - "Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max are a documented fast-follow.", + "Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides `valueMin` / `valueMax`.", + ), + valueMin: z + .number() + .finite() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", + ), + valueMax: z + .number() + .finite() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", ), unit: z.string().min(1).max(60), startDate: z.iso.datetime({ offset: true }), @@ -407,6 +421,20 @@ const seriesPointSchema = z.object({ .describe( "Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.", ), + valueMin: z + .number() + .nullable() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and absent for every other kind.", + ), + valueMax: z + .number() + .nullable() + .optional() + .describe( + "v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.", + ), }); const seriesResponse = z From 1352ba2aa65eda447d92fd437df4e48edca50130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:14 +0200 Subject: [PATCH 08/12] feat(illness): name the symptom curve in the recovery-gap card When the logged symptom curve drives the recovery gap, the card names it ('you felt better N days before your logged symptoms eased') instead of the vital-driven phrasing. Mirror the new gapDriverType and the return track's adverse flag into the client DTO, and add the driver-aware copy across all six locales. --- messages/de.json | 2 ++ messages/en.json | 2 ++ messages/es.json | 2 ++ messages/fr.json | 2 ++ messages/it.json | 2 ++ messages/pl.json | 2 ++ .../illness/illness-correlation-card.tsx | 20 +++++++++++++++---- src/components/illness/types.ts | 8 ++++++++ 8 files changed, 36 insertions(+), 4 deletions(-) diff --git a/messages/de.json b/messages/de.json index c15520593..91889f5ad 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Erholungslücke", "recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.", "recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.", + "recoveryGapLagSymptom": "Dir ging es {days} Tage besser, bevor deine erfassten Symptome abklangen.", + "recoveryGapLeadSymptom": "Deine erfassten Symptome klangen {days} Tage ab, bevor du Genesung vermerktest.", "recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.", "redFlagTitle": "Genauer ansehen", "redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.", diff --git a/messages/en.json b/messages/en.json index 4ad161b92..32cac8164 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Recovery gap", "recoveryGapLag": "Your numbers settled {days} days after you felt better.", "recoveryGapLead": "Your numbers settled {days} days before you marked recovery.", + "recoveryGapLagSymptom": "You felt better {days} days before your logged symptoms eased.", + "recoveryGapLeadSymptom": "Your logged symptoms eased {days} days before you marked recovery.", "recoveryGapSame": "Your numbers settled the same day you felt better.", "redFlagTitle": "Worth a closer look", "redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.", diff --git a/messages/es.json b/messages/es.json index 66d9d2421..ba7e9e577 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Brecha de recuperación", "recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.", "recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.", + "recoveryGapLagSymptom": "Te sentiste mejor {days} días antes de que cedieran tus síntomas registrados.", + "recoveryGapLeadSymptom": "Tus síntomas registrados cedieron {days} días antes de marcar la recuperación.", "recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.", "redFlagTitle": "Conviene revisarlo", "redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.", diff --git a/messages/fr.json b/messages/fr.json index 608aef1a6..b90b9f7e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Écart de récupération", "recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.", "recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.", + "recoveryGapLagSymptom": "Tu t'es senti mieux {days} jours avant que tes symptômes consignés s'apaisent.", + "recoveryGapLeadSymptom": "Tes symptômes consignés se sont apaisés {days} jours avant que tu notes la guérison.", "recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.", "redFlagTitle": "À examiner de plus près", "redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.", diff --git a/messages/it.json b/messages/it.json index 226eaebb0..9f3c112f3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Divario di recupero", "recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.", "recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.", + "recoveryGapLagSymptom": "Ti sei sentito meglio {days} giorni prima che i sintomi registrati si attenuassero.", + "recoveryGapLeadSymptom": "I tuoi sintomi registrati si sono attenuati {days} giorni prima di segnare la guarigione.", "recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.", "redFlagTitle": "Da guardare più da vicino", "redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.", diff --git a/messages/pl.json b/messages/pl.json index 4853b2972..2704ab145 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6866,6 +6866,8 @@ "recoveryGapTitle": "Luka rekonwalescencji", "recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.", "recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.", + "recoveryGapLagSymptom": "Poczułeś się lepiej {days} dni przed ustąpieniem zapisanych objawów.", + "recoveryGapLeadSymptom": "Twoje zapisane objawy ustąpiły {days} dni przed oznaczeniem wyzdrowienia.", "recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.", "redFlagTitle": "Warto przyjrzeć się bliżej", "redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.", diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx index 000bac25f..57e937154 100644 --- a/src/components/illness/illness-correlation-card.tsx +++ b/src/components/illness/illness-correlation-card.tsx @@ -44,6 +44,10 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) { function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { const { t } = useTranslations(); const gap = value.recoveryGapDays; + // Driver-aware copy: when the user's logged symptom curve drove the gap, name + // the symptoms ("…before your symptoms eased"); otherwise the existing + // vital-driven phrasing. Clinically calm — observation, never prediction. + const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT"; return (
@@ -71,11 +75,19 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {

{gap > 0 - ? t("illness.correlation.recoveryGapLag", { days: gap }) + ? t( + symptomDriven + ? "illness.correlation.recoveryGapLagSymptom" + : "illness.correlation.recoveryGapLag", + { days: gap }, + ) : gap < 0 - ? t("illness.correlation.recoveryGapLead", { - days: Math.abs(gap), - }) + ? t( + symptomDriven + ? "illness.correlation.recoveryGapLeadSymptom" + : "illness.correlation.recoveryGapLead", + { days: Math.abs(gap) }, + ) : t("illness.correlation.recoveryGapSame")}

diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 6bda26316..581dcd3fd 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -97,9 +97,11 @@ export interface IllnessVitalDeviation { } export interface IllnessVitalReturn { + /** A `MeasurementType`, or `"FUNCTIONAL_IMPACT"` for the symptom-burden track. */ type: string; returnedDay: string | null; gapDays: number | null; + adverse: boolean; } export interface IllnessRedFlag { @@ -117,6 +119,12 @@ export interface IllnessCorrelationValue { recoveryGapDays: number | null; adverseCoverageDays: number; feltBetterDay: string | null; + /** + * The metric that drove the headline gap — a `MeasurementType`, + * `"FUNCTIONAL_IMPACT"` (the symptom-burden track), or null. Drives the + * driver-aware recovery-gap copy. + */ + gapDriverType: string | null; redFlags: IllnessRedFlag[]; } From 5c0583133dc2715dfe69c49188a4b99ae2a48538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:17 +0200 Subject: [PATCH 09/12] chore(db): make the 0187 telegram-prompt-context migration rerun-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard the CREATE TABLE + both CREATE INDEX statements with IF NOT EXISTS so a re-run on a database that already has the table is a no-op, matching the guard convention every other recent migration ships with. Semantics are unchanged — same table, same columns, same indexes. --- .../0187_v1190_telegram_prompt_context/migration.sql | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql index aab9f45f5..3f8c027f2 100644 --- a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql +++ b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql @@ -1,6 +1,10 @@ -- v1.19.0 — force-reply correlation for the interactive mood reminder. -- Maps a bot prompt message to the entry a free-text reply attaches to. -CREATE TABLE "telegram_prompt_contexts" ( +-- +-- Idempotent guards (`IF NOT EXISTS`) so a rerun is safe on prod. The +-- semantics are unchanged from the original cut (same table, same columns, +-- same indexes) — v1.19.2 added only the guards. +CREATE TABLE IF NOT EXISTS "telegram_prompt_contexts" ( "id" TEXT NOT NULL, "user_id" TEXT NOT NULL, "chat_id" TEXT NOT NULL, @@ -14,7 +18,7 @@ CREATE TABLE "telegram_prompt_contexts" ( ); -- CreateIndex -CREATE UNIQUE INDEX "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id"); -- CreateIndex -CREATE INDEX "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at"); +CREATE INDEX IF NOT EXISTS "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at"); From 9c8fb8c7c88722517165ac7f52fafb2c409bd9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:06:42 +0200 Subject: [PATCH 10/12] feat(telegram): unique chat binding and numeric measurement capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes to the Telegram bot's data path. One chat ⇄ one account. `User.telegramChatId` is now UNIQUE (migration 0189, defensive pre-clean of any pre-existing duplicate binding). The inbound webhook resolves the user from the chat id, so a chat shared across two accounts would route a reply or button tap ambiguously. The index is over a nullable column, so an unlinked account (null chat id) never collides — only two accounts binding the same non-null chat id are rejected. Numeric measurement capture. A numeric reply to a measurement reminder is now captured as a Measurement (`source = TELEGRAM`, added to the enum in 0189). On "Erledigt" for a reminder that names a single-value metric, the bot offers an optional value prompt via force_reply; a numeric reply is written through the shared `logTelegramMeasurement` helper — same range guard, canonical unit, cache + rollup refresh as the manual route. The reply binds to the reminder and user through a TelegramPromptContext keyed on the prompt message id (the same strict chat/message binding the mood-note path uses); the userId is never read from the payload. Comma decimals are tolerated; non-numeric and out-of-range replies get a calm hint and write nothing; the reply self-cleans on the ~30-min sweep. Blood pressure (two values) is out of scope for the single-numeric path and keeps its satisfy-only flow. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- .../migration.sql | 55 ++++++ prisma/schema.prisma | 15 +- src/app/api/telegram/webhook/route.ts | 105 ++++++++++- .../__tests__/create-from-telegram.test.ts | 143 +++++++++++++++ .../__tests__/telegram-chat-id-unique.test.ts | 36 ++++ src/lib/measurements/create-from-telegram.ts | 164 ++++++++++++++++++ src/lib/validations/measurement.ts | 7 + 13 files changed, 562 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql create mode 100644 src/lib/measurements/__tests__/create-from-telegram.test.ts create mode 100644 src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts create mode 100644 src/lib/measurements/create-from-telegram.ts diff --git a/messages/de.json b/messages/de.json index c15520593..56b3efe85 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Bitte Medikament angeben, z.B. genommen Ramipril.", "medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", "helpHeader": "Verfügbare Befehle:", - "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen" + "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen", + "measureValuePrompt": "Antworte mit dem Wert, um ihn zu speichern (optional).", + "buttonMeasureValue": "Wert…", + "measureValueSaved": "Messwert gespeichert.", + "measureValueInvalid": "Antworte mit einer Zahl, um einen Messwert zu speichern.", + "measureValueOutOfRange": "Dieser Wert liegt außerhalb des plausiblen Bereichs.", + "measureValueExpired": "Diese Aufforderung ist abgelaufen." }, "medicationReminders": { "phaseGreenTitle": "🟢 Erinnerung: {medName}", diff --git a/messages/en.json b/messages/en.json index 4ad161b92..a0ffb77bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Please specify a medication, e.g. taken Ramipril.", "medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.", "helpHeader": "Available commands:", - "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake" + "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake", + "measureValuePrompt": "Reply with the value to log it (optional).", + "buttonMeasureValue": "Value…", + "measureValueSaved": "Reading saved.", + "measureValueInvalid": "Reply with a number to log a reading.", + "measureValueOutOfRange": "That value is outside the plausible range.", + "measureValueExpired": "That prompt has expired." }, "medicationReminders": { "phaseGreenTitle": "🟢 Reminder: {medName}", diff --git a/messages/es.json b/messages/es.json index 66d9d2421..6ca4e968a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Indica el medicamento, p. ej. tomado Ramipril.", "medicationNotFoundExact": "Medicamento no encontrado. Envía el nombre exactamente como aparece en HealthLog.", "helpHeader": "Comandos disponibles:", - "helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\nComandos de texto:\ntaken <nombre> — confirmar una toma\n\nBotones en los recordatorios:\n• Tomado — confirmar la toma\n• 🕐 1h / 🕐 3h — posponer el recordatorio\n• ⏭ Omitir — omitir la toma\n• ✓ Confirmar — confirmar una toma omitida" + "helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\nComandos de texto:\ntaken <nombre> — confirmar una toma\n\nBotones en los recordatorios:\n• Tomado — confirmar la toma\n• 🕐 1h / 🕐 3h — posponer el recordatorio\n• ⏭ Omitir — omitir la toma\n• ✓ Confirmar — confirmar una toma omitida", + "measureValuePrompt": "Responde con el valor para registrarlo (opcional).", + "buttonMeasureValue": "Valor…", + "measureValueSaved": "Lectura guardada.", + "measureValueInvalid": "Responde con un número para registrar una lectura.", + "measureValueOutOfRange": "Ese valor está fuera del rango plausible.", + "measureValueExpired": "Esa solicitud ha caducado." }, "medicationReminders": { "phaseGreenTitle": "🟢 Recordatorio: {medName}", diff --git a/messages/fr.json b/messages/fr.json index 608aef1a6..e1cc7a8c5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Indique le médicament, p. ex. pris Ramipril.", "medicationNotFoundExact": "Médicament introuvable. Envoie le nom exactement tel qu’il apparaît dans HealthLog.", "helpHeader": "Commandes disponibles :", - "helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\nCommandes texte :\ntaken <nom> — confirmer une prise\n\nBoutons dans les rappels :\n• Pris — confirmer la prise\n• 🕐 1h / 🕐 3h — reporter le rappel\n• ⏭ Ignorer — ignorer la prise\n• ✓ Confirmer — confirmer une prise manquée" + "helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\nCommandes texte :\ntaken <nom> — confirmer une prise\n\nBoutons dans les rappels :\n• Pris — confirmer la prise\n• 🕐 1h / 🕐 3h — reporter le rappel\n• ⏭ Ignorer — ignorer la prise\n• ✓ Confirmer — confirmer une prise manquée", + "measureValuePrompt": "Répondez avec la valeur pour l’enregistrer (facultatif).", + "buttonMeasureValue": "Valeur…", + "measureValueSaved": "Mesure enregistrée.", + "measureValueInvalid": "Répondez avec un nombre pour enregistrer une mesure.", + "measureValueOutOfRange": "Cette valeur est en dehors de la plage plausible.", + "measureValueExpired": "Cette invite a expiré." }, "medicationReminders": { "phaseGreenTitle": "🟢 Rappel : {medName}", diff --git a/messages/it.json b/messages/it.json index 226eaebb0..58777009b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Indica il farmaco, es. preso Ramipril.", "medicationNotFoundExact": "Farmaco non trovato. Invia il nome esattamente come appare in HealthLog.", "helpHeader": "Comandi disponibili:", - "helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\nComandi testuali:\ntaken <nome> — conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto — conferma l'assunzione\n• 🕐 1h / 🕐 3h — posticipa il promemoria\n• ⏭ Salta — salta l'assunzione\n• ✓ Conferma — conferma un'assunzione saltata" + "helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\nComandi testuali:\ntaken <nome> — conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto — conferma l'assunzione\n• 🕐 1h / 🕐 3h — posticipa il promemoria\n• ⏭ Salta — salta l'assunzione\n• ✓ Conferma — conferma un'assunzione saltata", + "measureValuePrompt": "Rispondi con il valore per registrarlo (facoltativo).", + "buttonMeasureValue": "Valore…", + "measureValueSaved": "Misurazione salvata.", + "measureValueInvalid": "Rispondi con un numero per registrare una misurazione.", + "measureValueOutOfRange": "Questo valore è fuori dall’intervallo plausibile.", + "measureValueExpired": "Questo messaggio è scaduto." }, "medicationReminders": { "phaseGreenTitle": "🟢 Promemoria: {medName}", diff --git a/messages/pl.json b/messages/pl.json index 4853b2972..ce746d194 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5939,7 +5939,13 @@ "askMedicationName": "Podaj lek, np. przyjęty Ramipril.", "medicationNotFoundExact": "Nie znaleziono leku. Wyślij nazwę dokładnie tak, jak w HealthLog.", "helpHeader": "Dostępne polecenia:", - "helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa> — potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto — potwierdź przyjęcie\n• 🕐 1h / 🕐 3h — odłóż przypomnienie\n• ⏭ Pomiń — pomiń przyjęcie\n• ✓ Potwierdź — potwierdź pominięte przyjęcie" + "helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa> — potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto — potwierdź przyjęcie\n• 🕐 1h / 🕐 3h — odłóż przypomnienie\n• ⏭ Pomiń — pomiń przyjęcie\n• ✓ Potwierdź — potwierdź pominięte przyjęcie", + "measureValuePrompt": "Odpowiedz wartością, aby ją zapisać (opcjonalnie).", + "buttonMeasureValue": "Wartość…", + "measureValueSaved": "Pomiar zapisany.", + "measureValueInvalid": "Odpowiedz liczbą, aby zapisać pomiar.", + "measureValueOutOfRange": "Ta wartość jest poza wiarygodnym zakresem.", + "measureValueExpired": "Ten monit wygasł." }, "medicationReminders": { "phaseGreenTitle": "🟢 Przypomnienie: {medName}", diff --git a/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql b/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql new file mode 100644 index 000000000..44cc79f90 --- /dev/null +++ b/prisma/migrations/0189_v1192_telegram_chat_unique_and_source/migration.sql @@ -0,0 +1,55 @@ +-- v1.19.2 — Telegram: one chat ⇄ one account, plus a TELEGRAM measurement +-- source for numeric-reply capture. +-- +-- 1. `measurement_source` += `TELEGRAM`. A numeric reply to a measurement +-- reminder is captured as a Measurement that enters through the +-- chat-bound webhook (not the cookie/Bearer client write path), so it +-- carries its own source label. Purely-additive enum extension; no row +-- touched. `ADD VALUE IF NOT EXISTS` makes the rerun safe. The new value +-- is NOT used elsewhere in this migration, so it is safe to extend the +-- enum in the same step (Postgres only forbids USING a freshly-added +-- value in the same transaction). Mirrors the 0160 source-extension +-- precedent. +-- +-- 2. `users.telegram_chat_id` UNIQUE. The inbound webhook resolves the user +-- from `telegram_chat_id` (`findTelegramUser`); a chat id shared across +-- two accounts would route a reply / button tap / numeric capture +-- ambiguously. The index is over a nullable column, so Postgres' default +-- NULL-distinct semantics keep an unlinked account (NULL chat id) from +-- ever colliding — only two accounts both binding the SAME non-NULL chat +-- id are rejected, which is the bug we are closing. +-- +-- Defensive pre-clean: should any duplicate non-NULL chat ids already +-- exist (a chat re-pointed between accounts before this constraint +-- landed), keep the most recently updated row's binding and NULL out the +-- stale ones so the unique index can be built without error. The cleared +-- accounts simply fall back to "Telegram not linked" and re-link from +-- Settings; no data beyond the binding is touched. +-- +-- Idempotent guards (`IF NOT EXISTS`) make a rerun safe. Forward-only. +-- +-- Reversibility: Postgres cannot remove an enum value, so `TELEGRAM` stays +-- (inert with no rows). The unique index drops with +-- `DROP INDEX IF EXISTS "users_telegram_chat_id_key"`. + +-- ── 1. measurement_source — append the user-driven webhook source ────── +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'TELEGRAM'; + +-- ── 2. users.telegram_chat_id — one chat binds one account ───────────── +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY telegram_chat_id + ORDER BY updated_at DESC, id DESC + ) AS rn + FROM "users" + WHERE telegram_chat_id IS NOT NULL +) +UPDATE "users" u +SET telegram_chat_id = NULL +FROM ranked +WHERE u.id = ranked.id AND ranked.rn > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS "users_telegram_chat_id_key" + ON "users" ("telegram_chat_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 54d7485f1..e6839cdce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,7 +94,12 @@ model User { // Telegram notifications (per-user) telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted - telegramChatId String? @map("telegram_chat_id") + // v1.19.2 — one Telegram chat binds to exactly one HealthLog account. + // The inbound webhook resolves the user from this chat id; a chat shared + // across two accounts would route a reply / button tap ambiguously. The + // unique is partial by Postgres NULL semantics (multiple NULLs allowed), + // so an unlinked account (NULL chat id) never collides. Migration 0189. + telegramChatId String? @unique @map("telegram_chat_id") telegramEnabled Boolean @default(false) @map("telegram_enabled") // Withings OAuth credentials (per-user, encrypted) @@ -1002,6 +1007,14 @@ enum MeasurementSource { // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS // DTO mirrors this exact spelling so /api/sync/changes decodes the rows. OURA + // v1.19.2 — measurements captured from a numeric reply to a Telegram + // measurement reminder. Unlike the server-owned integrations above, this + // is a user-driven write, but it enters through the webhook (chat-bound) + // rather than the cookie/Bearer client write path, so it carries its own + // source label. The client batch + single-POST surfaces still reject it + // (server-resolved only); iOS DTO mirrors this spelling so the + // /api/sync/changes delta feed decodes the rows. + TELEGRAM @@map("measurement_source") } diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 5c25ed80e..7de67e696 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -33,6 +33,10 @@ import { } from "@/lib/mood/create-from-telegram"; import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { + isTelegramCapturableType, + logTelegramMeasurement, +} from "@/lib/measurements/create-from-telegram"; interface TelegramUpdate { update_id: number; @@ -776,13 +780,15 @@ async function handleCallback(update: TelegramUpdate) { await deleteMessage(botToken, chatId, messageId); } } else if (data.startsWith("measure_done:")) { - // v1.19.0 — mark a Vorsorge/measurement reminder done. No value - // capture (restraint) — satisfy the cadence via the shared primitive. + // v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the + // cadence via the shared primitive. const reminderId = data.slice("measure_done:".length).trim(); const reminder = await prisma.measurementReminder.findFirst({ where: { id: reminderId, userId: user.id, deletedAt: null }, select: { id: true, + // v1.19.2 — the target metric; drives the optional numeric capture. + measurementType: true, intervalDays: true, rrule: true, anchorDate: true, @@ -809,6 +815,48 @@ async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } + + // v1.19.2 — optional numeric value capture. When the reminder names a + // single-value metric (BP and free-text Vorsorge are excluded), offer a + // force-reply prompt; a numeric reply is captured as a Measurement + // (source=TELEGRAM) in `handleTextMessage`. The reply binds to THIS + // reminder + user through a TelegramPromptContext keyed on the prompt + // message id, the SAME strict chat/message binding the mood-note path + // uses — the userId is never read from the inbound payload. Optional and + // self-cleaning: ignoring the prompt leaves the cadence already + // satisfied, and the prompt is swept on the ~30-min auto-delete. + if (isTelegramCapturableType(reminder.measurementType)) { + const valuePrompt = await sendTelegramMessage( + botToken, + chatId, + t("telegram.measureValuePrompt"), + { + replyMarkup: { + force_reply: true, + input_field_placeholder: t("telegram.buttonMeasureValue"), + }, + }, + ); + if (valuePrompt.ok && valuePrompt.messageId) { + try { + await prisma.telegramPromptContext.create({ + data: { + userId: user.id, + chatId, + promptMsgId: valuePrompt.messageId, + kind: "measure_value", + refId: reminder.id, + // Same ~30-min window as the deletion sweep; a later reply is + // treated as expired. + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }, + }); + } catch { + // Best-effort — without the context row the value simply can't bind. + } + await scheduleAutoDelete(user.id, chatId, [valuePrompt.messageId]); + } + } } else if (data.startsWith("measure_later:")) { // v1.19.0 — postpone a measurement reminder by N minutes (one-shot // nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in @@ -892,7 +940,11 @@ async function handleTextMessage(update: TelegramUpdate) { expiresAt: true, }, }); - if (context && context.userId === user.id && context.kind === "mood_note") { + if ( + context && + context.userId === user.id && + (context.kind === "mood_note" || context.kind === "measure_value") + ) { // Consume the context row regardless of outcome so a reply can't be // replayed onto the entry twice. await prisma.telegramPromptContext @@ -903,7 +955,9 @@ async function handleTextMessage(update: TelegramUpdate) { const resp = await sendTelegramMessage( botToken, chatId, - t("telegram.moodNoteExpired"), + context.kind === "measure_value" + ? t("telegram.measureValueExpired") + : t("telegram.moodNoteExpired"), ); const toDelete = [userMsgId, replyToId, resp.messageId].filter( (id): id is number => id != null, @@ -913,6 +967,49 @@ async function handleTextMessage(update: TelegramUpdate) { } return; } + + if (context.kind === "measure_value") { + // v1.19.2 — numeric value capture for a measurement reminder. The + // reminder named on the context row carries the expected metric; the + // userId is the chat-resolved one (never the payload). A non-numeric + // / out-of-range reply gets a calm hint, not a write. The reply, the + // prompt, and the confirmation all self-clean on the ~30-min sweep. + const reminder = await prisma.measurementReminder.findFirst({ + where: { id: context.refId, userId: user.id, deletedAt: null }, + select: { measurementType: true }, + }); + let confirmation: string; + if (!isTelegramCapturableType(reminder?.measurementType)) { + confirmation = t("telegram.measurementNotFound"); + } else { + const externalId = `telegram:measure:${chatId}:${replyToId}`.slice( + 0, + 120, + ); + const result = await logTelegramMeasurement({ + userId: user.id, + type: reminder.measurementType, + rawText: text, + tz: user.timezone, + externalId, + }); + confirmation = + result.status === "ok" + ? t("telegram.measureValueSaved") + : result.status === "out_of_range" + ? t("telegram.measureValueOutOfRange") + : t("telegram.measureValueInvalid"); + } + const resp = await sendTelegramMessage(botToken, chatId, confirmation); + const toDelete = [userMsgId, replyToId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + const attached = await attachTelegramMoodNote({ userId: user.id, moodEntryId: context.refId, diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts new file mode 100644 index 000000000..7dfdf3141 --- /dev/null +++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { measurement } = vi.hoisted(() => ({ + measurement: { + findUnique: vi.fn(), + create: vi.fn(), + }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement }, +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addMeta: vi.fn() }), +})); + +import { + logTelegramMeasurement, + isTelegramCapturableType, + parseTelegramNumber, +} from "@/lib/measurements/create-from-telegram"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +beforeEach(() => { + vi.clearAllMocks(); + measurement.findUnique.mockResolvedValue(null); + measurement.create.mockResolvedValue({ id: "m-1" }); +}); + +describe("parseTelegramNumber", () => { + it("parses an integer", () => { + expect(parseTelegramNumber("72")).toBe(72); + }); + it("parses a comma decimal (de-DE keyboard)", () => { + expect(parseTelegramNumber("82,5")).toBe(82.5); + }); + it("trims surrounding whitespace", () => { + expect(parseTelegramNumber(" 98 ")).toBe(98); + }); + it("rejects trailing unit noise", () => { + expect(parseTelegramNumber("72 bpm")).toBeNull(); + }); + it("rejects non-numeric text", () => { + expect(parseTelegramNumber("done")).toBeNull(); + }); +}); + +describe("isTelegramCapturableType", () => { + it("accepts single-value metrics", () => { + expect(isTelegramCapturableType("WEIGHT")).toBe(true); + expect(isTelegramCapturableType("BLOOD_GLUCOSE")).toBe(true); + }); + it("rejects blood pressure (needs two values)", () => { + expect(isTelegramCapturableType("BLOOD_PRESSURE_SYS")).toBe(false); + }); + it("rejects null / free-text reminders", () => { + expect(isTelegramCapturableType(null)).toBe(false); + expect(isTelegramCapturableType(undefined)).toBe(false); + }); +}); + +describe("logTelegramMeasurement", () => { + it("writes a Measurement with source=TELEGRAM and the canonical unit", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "WEIGHT", + rawText: "82,5", + tz: "Europe/Berlin", + externalId: "telegram:measure:7777:555", + }); + + expect(result.status).toBe("ok"); + expect(measurement.create).toHaveBeenCalledTimes(1); + const data = measurement.create.mock.calls[0][0].data; + expect(data.userId).toBe("user-1"); + expect(data.type).toBe("WEIGHT"); + expect(data.value).toBe(82.5); + expect(data.source).toBe("TELEGRAM"); + expect(data.unit).toBe("kg"); + expect(data.externalId).toBe("telegram:measure:7777:555"); + expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("rejects a non-numeric reply without writing", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "WEIGHT", + rawText: "heavy", + tz: null, + externalId: "telegram:measure:7777:556", + }); + expect(result.status).toBe("invalid_number"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("rejects an out-of-range value without writing", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "WEIGHT", + rawText: "999", + tz: null, + externalId: "telegram:measure:7777:557", + }); + expect(result.status).toBe("out_of_range"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("refuses an unsupported (BP) type", async () => { + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "BLOOD_PRESSURE_SYS", + rawText: "120", + tz: null, + externalId: "telegram:measure:7777:558", + }); + expect(result.status).toBe("unsupported_type"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("is idempotent on a redelivered reply (externalId hit)", async () => { + measurement.findUnique.mockResolvedValue({ id: "existing" }); + const result = await logTelegramMeasurement({ + userId: "user-1", + type: "PULSE", + rawText: "60", + tz: null, + externalId: "telegram:measure:7777:559", + }); + expect(result.status).toBe("ok"); + expect(measurement.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts b/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts new file mode 100644 index 000000000..67db1f4a4 --- /dev/null +++ b/src/lib/measurements/__tests__/telegram-chat-id-unique.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.19.2 — `User.telegramChatId` must stay UNIQUE. The inbound webhook + * resolves the user from the chat id; a chat shared across two accounts + * would route a reply / button tap / numeric capture ambiguously. The + * constraint is the structural guard, so this asserts it never silently + * drops out of the schema on a future regeneration. + */ +const SCHEMA_PATH = join(__dirname, "../../../..", "prisma", "schema.prisma"); + +describe("User.telegramChatId schema invariant", () => { + const schema = readFileSync(SCHEMA_PATH, "utf8"); + + it("declares the column @unique", () => { + const line = schema.split("\n").find((l) => l.includes("telegramChatId")); + expect(line).toBeDefined(); + expect(line).toMatch(/@unique/); + }); + + it("ships the 0189 unique-index migration", () => { + const migrationPath = join( + __dirname, + "../../../..", + "prisma", + "migrations", + "0189_v1192_telegram_chat_unique_and_source", + "migration.sql", + ); + const sql = readFileSync(migrationPath, "utf8"); + expect(sql).toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS/); + expect(sql).toMatch(/users_telegram_chat_id_key/); + }); +}); diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts new file mode 100644 index 000000000..8b415e253 --- /dev/null +++ b/src/lib/measurements/create-from-telegram.ts @@ -0,0 +1,164 @@ +/** + * v1.19.2 — server-authoritative measurement capture from the Telegram bot. + * + * The HTTP measurement-create path (`POST /api/measurements`) is bound to a + * cookie/Bearer session via `requireAuth`; the Telegram webhook resolves the + * user from the linked chat instead. This helper holds the shared write so + * the two entry points agree on the canonical shape: the same range guard, + * the same canonical unit, the same cache invalidation + DAY-rollup + * recompute the manual route fires. `source` is pinned to the `TELEGRAM` + * `MeasurementSource` value (added in migration 0189). + * + * The `userId` is NEVER taken from the Telegram payload — the caller passes + * the id it resolved from the stored `telegramChatId` binding. The capture + * is correlated to a specific `MeasurementReminder` (carrying the expected + * `measurementType`) through a `TelegramPromptContext` row, so a numeric + * reply can only ever land the metric that reminder asked for, for that + * user. BP (two values) is out of scope for the single-numeric-reply path. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma as defaultPrisma } from "@/lib/db"; +import { + getUnitForType, + validateMeasurementRange, +} from "@/lib/validations/measurement"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; +import { getEvent } from "@/lib/logging/context"; + +/** + * The single-value measurement types a numeric Telegram reply can capture. + * + * Excludes BLOOD_PRESSURE_SYS — a BP reading needs systolic AND diastolic, + * which a single numeric reply can't express unambiguously. The BP reminder + * keeps its existing satisfy-only "done" flow; a value capture for it is a + * documented follow-up (a guided two-number prompt). + */ +const TELEGRAM_CAPTURABLE_TYPES: ReadonlySet = + new Set([ + "WEIGHT", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", + ]); + +/** + * True when a numeric Telegram reply can capture a Measurement for this + * reminder's target type. The webhook gates the value-prompt on this so a + * BP reminder (or a free-text Vorsorge with no `measurementType`) never + * offers an ambiguous numeric capture. + */ +export function isTelegramCapturableType( + type: MeasurementType | null | undefined, +): type is MeasurementType { + return type != null && TELEGRAM_CAPTURABLE_TYPES.has(type); +} + +export interface TelegramMeasurementResult { + /** `ok` — a row was written (or already existed under the dedup key). + * `invalid_number` — the reply did not parse to a finite number. + * `out_of_range` — the value fell outside the type's plausible range. + * `unsupported_type` — the type is not single-value capturable. */ + status: "ok" | "invalid_number" | "out_of_range" | "unsupported_type"; +} + +/** + * Parse a free-text Telegram reply into a finite number. Tolerates a + * comma decimal separator (de-DE keyboards) and surrounding whitespace; + * rejects anything with trailing non-numeric noise so "72 bpm" doesn't + * silently capture 72. + */ +export function parseTelegramNumber(text: string): number | null { + const normalised = text.trim().replace(",", "."); + if (!/^-?\d+(\.\d+)?$/.test(normalised)) return null; + const n = Number(normalised); + return Number.isFinite(n) ? n : null; +} + +/** + * Capture a measurement on behalf of a Telegram-linked user from a numeric + * reply to a measurement-reminder prompt. + * + * Idempotent on `externalId` (the per-prompt key the caller derives from + * `telegram:measure::`): a redelivered reply converges + * onto the same row via the `(userId, type, source, externalId)` unique + * instead of minting a duplicate. + */ +export async function logTelegramMeasurement(input: { + userId: string; + type: MeasurementType; + rawText: string; + tz: string | null; + /** Stable per-prompt id for Telegram redelivery dedup. */ + externalId: string; + client?: PrismaClient | Prisma.TransactionClient; +}): Promise { + const prisma = (input.client ?? defaultPrisma) as PrismaClient; + + if (!isTelegramCapturableType(input.type)) { + return { status: "unsupported_type" }; + } + + const value = parseTelegramNumber(input.rawText); + if (value === null) { + return { status: "invalid_number" }; + } + + if (validateMeasurementRange(input.type, value) !== null) { + return { status: "out_of_range" }; + } + + const measuredAt = new Date(); + + const existing = await prisma.measurement.findUnique({ + where: { + userId_type_source_externalId: { + userId: input.userId, + type: input.type, + source: "TELEGRAM", + externalId: input.externalId, + }, + }, + select: { id: true }, + }); + if (existing) { + return { status: "ok" }; + } + + await prisma.measurement.create({ + data: { + userId: input.userId, + type: input.type, + value, + unit: getUnitForType(input.type), + source: "TELEGRAM", + measuredAt, + externalId: input.externalId, + }, + }); + + invalidateUserMeasurements(input.userId, { evict: true }); + + // Best-effort rollup refresh — a cache tier, never a write-path invariant. + try { + await recomputeBucketsForMeasurement(input.userId, input.type, measuredAt); + } catch (rollupErr) { + getEvent()?.addMeta( + "telegram_measurement_rollup_failed", + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + ); + } + + return { status: "ok" }; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 9d3197295..991094226 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -177,6 +177,13 @@ export const measurementSourceEnum = z.enum([ // path), exactly like WHOOP/FITBIT. Part of this enum so the read/response // shapes (and the iOS decoder) can decode the rows it surfaces. "OURA", + // v1.19.2 — Telegram numeric-reply capture. A reply to a measurement + // reminder is written server-side from the chat-bound webhook (not the + // cookie/Bearer client write path), so it carries its own source label. + // Part of this enum so the read/response shapes (and the iOS decoder) can + // decode the rows it surfaces; the client-facing write surfaces still + // reject it (see `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist). + "TELEGRAM", ]); /** From c1d05c9e47c229875c10be36903ad67e53f17ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:08:28 +0200 Subject: [PATCH 11/12] chore(openapi): regenerate for v1.19.2 schema additions Pick up the HR bucket min/max fields, the TELEGRAM measurement source, and the recovery-gap driver/return additions in one regeneration. --- docs/api/openapi.yaml | 161 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 5 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3fe9cd882..46e7450c4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1744,6 +1744,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM - in: query name: valueMin schema: @@ -2238,6 +2239,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM externalId: type: string maxLength: 128 @@ -3355,6 +3357,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -3371,6 +3374,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -3387,6 +3391,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -3403,6 +3408,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -3419,6 +3425,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -3435,6 +3442,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -3451,6 +3459,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -3467,6 +3476,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -3483,6 +3493,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -3499,6 +3510,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -3515,6 +3527,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -3531,6 +3544,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -3547,6 +3561,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -3563,6 +3578,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -3579,6 +3595,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -3595,6 +3612,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -3611,6 +3629,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -3627,6 +3646,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM metricPriority: type: object properties: @@ -3646,6 +3666,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -3662,6 +3683,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -3678,6 +3700,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -3694,6 +3717,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -3710,6 +3734,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -3726,6 +3751,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -3742,6 +3768,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -3758,6 +3785,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -3774,6 +3802,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -3790,6 +3819,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -3806,6 +3836,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -3822,6 +3853,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -3838,6 +3870,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -3854,6 +3887,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -3870,6 +3904,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -3886,6 +3921,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -3902,6 +3938,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -3918,6 +3955,7 @@ paths: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM deviceTypePriority: type: object properties: @@ -7213,8 +7251,18 @@ components: value: type: number description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an - hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max - are a documented fast-follow. + hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides + `valueMin` / `valueMax`. + valueMin: + description: "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). + Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) + on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." + type: number + valueMax: + description: "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). + Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) + on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." + type: number unit: type: string minLength: 1 @@ -10553,6 +10601,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM notes: anyOf: - type: string @@ -11096,6 +11145,20 @@ components: additionalProperties: type: number - type: "null" + valueMin: + description: "v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate + bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and + absent for every other kind." + anyOf: + - type: number + - type: "null" + valueMax: + description: "v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate + bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and + absent for every other kind." + anyOf: + - type: number + - type: "null" required: - id - at @@ -11261,6 +11324,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM notes: anyOf: - type: string @@ -11503,6 +11567,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM - type: "null" start: type: string @@ -11629,6 +11694,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM - type: "null" start: type: string @@ -12001,6 +12067,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM externalId: anyOf: - type: string @@ -12109,6 +12176,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM externalId: anyOf: - type: string @@ -14315,6 +14383,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -14331,6 +14400,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -14347,6 +14417,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -14363,6 +14434,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -14379,6 +14451,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -14395,6 +14468,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -14411,6 +14485,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -14427,6 +14502,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -14443,6 +14519,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -14459,6 +14536,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -14475,6 +14553,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -14491,6 +14570,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -14507,6 +14587,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -14523,6 +14604,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -14539,6 +14621,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -14555,6 +14638,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -14571,6 +14655,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -14587,6 +14672,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM metricPriority: type: object properties: @@ -14606,6 +14692,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -14622,6 +14709,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -14638,6 +14726,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -14654,6 +14743,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -14670,6 +14760,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -14686,6 +14777,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -14702,6 +14794,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -14718,6 +14811,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -14734,6 +14828,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -14750,6 +14845,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -14766,6 +14862,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -14782,6 +14879,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -14798,6 +14896,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -14814,6 +14913,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -14830,6 +14930,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -14846,6 +14947,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -14862,6 +14964,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -14878,6 +14981,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM additionalProperties: false deviceTypePriority: type: object @@ -14943,6 +15047,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -14959,6 +15064,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -14975,6 +15081,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -14991,6 +15098,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -15007,6 +15115,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -15023,6 +15132,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -15039,6 +15149,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -15055,6 +15166,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -15071,6 +15183,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -15087,6 +15200,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -15103,6 +15217,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -15119,6 +15234,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -15135,6 +15251,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -15151,6 +15268,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -15167,6 +15285,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -15183,6 +15302,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -15199,6 +15319,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -15215,6 +15336,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM metricPriority: type: object properties: @@ -15234,6 +15356,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM activeEnergy: maxItems: 8 type: array @@ -15250,6 +15373,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM walkingRunningDistance: maxItems: 8 type: array @@ -15266,6 +15390,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM flightsClimbed: maxItems: 8 type: array @@ -15282,6 +15407,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM sleep: maxItems: 8 type: array @@ -15298,6 +15424,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM weight: maxItems: 8 type: array @@ -15314,6 +15441,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bloodPressure: maxItems: 8 type: array @@ -15330,6 +15458,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM pulse: maxItems: 8 type: array @@ -15346,6 +15475,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyFat: maxItems: 8 type: array @@ -15362,6 +15492,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM bodyTemperature: maxItems: 8 type: array @@ -15378,6 +15509,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM spo2: maxItems: 8 type: array @@ -15394,6 +15526,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM hrv: maxItems: 8 type: array @@ -15410,6 +15543,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM restingHeartRate: maxItems: 8 type: array @@ -15426,6 +15560,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM vo2Max: maxItems: 8 type: array @@ -15442,6 +15577,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM skinTemperature: maxItems: 8 type: array @@ -15458,6 +15594,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM respiratoryRate: maxItems: 8 type: array @@ -15474,6 +15611,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM recovery: maxItems: 8 type: array @@ -15490,6 +15628,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM stress: maxItems: 8 type: array @@ -15506,6 +15645,7 @@ components: - NIGHTSCOUT - POLAR - OURA + - TELEGRAM additionalProperties: false deviceTypePriority: type: object @@ -20316,6 +20456,10 @@ components: anyOf: - type: string - type: "null" + gapDriverType: + anyOf: + - type: string + - type: "null" redFlags: type: array items: @@ -20328,10 +20472,12 @@ components: - recoveryGapDays - adverseCoverageDays - feltBetterDay + - gapDriverType - redFlags additionalProperties: false description: "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital - physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags." + physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline + recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags." IllnessVitalDeviation: type: object properties: @@ -20376,13 +20522,18 @@ components: anyOf: - type: number - type: "null" + adverse: + type: boolean required: - type - returnedDay - gapDays + - adverse additionalProperties: false - description: "A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) - from the felt-better marker (positive = the body lagged the feeling)." + description: "A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap + (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was + illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for + the user-logged symptom-burden track (functionalImpact, baseline = constant 0)." IllnessRedFlag: type: object properties: From b4bf31a8c903459b635dba8b32e56fb798c4820b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 21:21:13 +0200 Subject: [PATCH 12/12] chore(release): v1.19.2 --- CHANGELOG.md | 21 +++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e85dfc98..3aea05deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.19.2] — 2026-06-21 — Recovery-gap symptom signal, resilience tile, long-range charts, richer device + Telegram data + +A follow-up that folds the illness journal's own symptom curve into the recovery-gap, surfaces Oura resilience, fixes multi-year chart ranges, and widens the heart-rate and Telegram data paths. Two additive migrations (`0188`, `0189`); no breaking changes. + +### Added + +- The recovery-gap now reads the illness journal's own daily functional-impact curve (and linked symptom severity), not just passive vitals. When symptoms lead the recovery, the card names them — "you tend to feel better before your logged symptoms ease". Sparse journals withhold rather than guess. +- An Oura resilience tile on the recovery surface, showing the latest resilience band with a quiet above/in-line/below-average cue and a calm learning state until enough days are recorded. +- Hourly heart-rate uploads can carry a per-bucket low and high alongside the average, so a high-frequency client no longer loses the within-hour spread. +- A numeric reply to a Telegram measurement reminder is captured as a reading (for example a weight or a glucose value), bound to the linked chat and self-cleaning like the mood path. Blood pressure stays a simple "done" for now, since it needs two values. + +### Changed + +- A multi-year "All" chart range now renders the whole history, stepped up to a weekly or monthly tier, instead of being silently truncated to the most recent year. + +### Fixed + +- `telegram_chat_id` is now unique, with a one-time clean-up of any stale duplicate bindings (no account is removed). +- Migration `0187` is rerun-safe. +- The sleep peer-card comment no longer overstates window parity; each card discloses its own night count. Dead streaming state left after the reasoning-disclosure removal is gone. + ## [1.19.1] — 2026-06-21 — Coach flow, sleep and mood polish, navigation, recovery-gap A follow-up to 1.19.0 that refines the Coach conversation flow and cost, adds a sleep average card, completes the mood colour pass, tidies navigation, and sharpens the illness recovery-gap. No schema changes; no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 46e7450c4..255762cab 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.19.1 + version: 1.19.2 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 3c4ab7789..aea525f95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.19.1", + "version": "1.19.2", "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 0cb85fa5a..b8a96ce5b 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.19.1"; + /* @sw-version-fallback */ "v1.19.2"; 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/*`