From 1212ed91d5f90953c231df753df790503cf6c5b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 10 Jul 2026 21:23:00 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.21=20=E2=80=94=20sleep?= =?UTF-8?q?=20history=20heals=20itself;=20plateau=20context;=20sync=20diag?= =?UTF-8?q?nostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-time background pass re-reads each connected account's Google Health sleep history so nights stored twice before the sleep fix collapse to their true total (new sleep_repaired_at marker, migration 0238; watermark-safe, enqueued once at boot per un-repaired connection). The sleep night read reports when two writers disagree about the same night — the served total is unchanged, the disagreement rides along as an annotation (sourceDiscrepancy on the night DTO) so clients can mark the number instead of silently picking a side. A GLP-1 medication whose weight has plateaued on the current dose says so next to the estimated drug level (efficacy tab + insights) — a factual note from the existing detector, association only, verb-guard covered. Google Health diagnostics: the sync log now separates a daily-totals read that returned nothing from one the importer could not parse (googleHealth.rollup.emptyResponse vs droppedAll), and the connection test's structure probe reports the raw response envelope key names when a rollup type parses to zero — a per-account naming drift becomes visible from one report. --- CHANGELOG.md | 25 ++- docs/api/openapi.yaml | 171 +++++++++++++++++- messages/de.json | 7 + messages/en.json | 7 + messages/es.json | 7 + messages/fr.json | 7 + messages/it.json | 7 + messages/pl.json | 7 + package.json | 2 +- .../migration.sql | 4 + prisma/schema.prisma | 5 + public/sw.js | 2 +- .../module-route-gate-inventory.test.ts | 5 + .../coach-route-gate-inventory.test.ts | 3 + src/app/api/insights/glp1-plateau/route.ts | 37 ++++ .../integrations/google-health/test/route.ts | 11 ++ src/app/api/sleep/night/route.ts | 13 ++ .../__tests__/sleep-hypnogram.test.tsx | 26 +++ src/components/insights/glp1-plateau-note.tsx | 76 ++++++++ .../insights/glp1-substance-curve-summary.tsx | 5 + src/components/insights/sleep-hypnogram.tsx | 120 +++++++++++- .../detail/efficacy/efficacy-tab.tsx | 7 +- .../analytics/__tests__/sleep-night.test.ts | 151 ++++++++++++++++ src/lib/analytics/sleep-night.ts | 86 +++++++++ src/lib/google-health/client.ts | 20 ++ src/lib/google-health/sync-activity.ts | 22 +++ src/lib/insights/glp1-plateau.ts | 7 +- .../google-health-sleep-repair.test.ts | 142 +++++++++++++++ src/lib/jobs/google-health-sleep-repair.ts | 132 ++++++++++++++ .../reminder/register-integration-sync.ts | 55 ++++++ src/lib/openapi/routes/insights/paths.ts | 23 +++ src/lib/openapi/routes/insights/schemas.ts | 42 +++++ src/lib/openapi/routes/measurements.ts | 23 +++ src/lib/query-keys/insights.ts | 6 + 34 files changed, 1256 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/0238_google_health_sleep_repaired/migration.sql create mode 100644 src/app/api/insights/glp1-plateau/route.ts create mode 100644 src/components/insights/glp1-plateau-note.tsx create mode 100644 src/lib/jobs/__tests__/google-health-sleep-repair.test.ts create mode 100644 src/lib/jobs/google-health-sleep-repair.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19de0f73c..0457aef8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,30 @@ ## [Unreleased] -## [1.28.20] — 2026-07-10 — Estimated drug level where you read efficacy +## [1.28.21] — 2026-07-10 — Sleep history heals itself; plateau context; sync diagnostics + +Three follow-ups to the recent sleep fix, plus better Google Health diagnostics. + +Nights that were stored twice before the sleep fix now repair themselves: a +one-time background pass re-reads each connected account's Google Health sleep +history and collapses every re-scored night to its true total. No operator +action needed — it runs once after the update and marks itself done. + +The sleep night read now reports when two sources disagree about the same night +(say, a sleep mat's ten hours in bed against a watch's seven and a half asleep). +The served total is unchanged — the disagreement rides along as an annotation so +clients can mark the number instead of silently picking a side. + +A GLP-1 medication whose weight has plateaued on the current dose now says so +where the estimated drug level is shown — a short, factual note with the +observed change over the window, next to the curve in the efficacy view and in +Insights. It states the association and nothing more. + +Google Health sync diagnostics: a daily-totals read that returns rows but +imports nothing now says so in the sync log, distinguishing an empty answer +from one the importer could not read — and the connection test's structure +probe reports the raw response field names when a type parses to zero, so a +per-account naming drift is visible from a single report. The estimated active-substance curve for a GLP-1 medication — the modelled drug level from your logged doses — now also appears in the efficacy view and in the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b735a4920..e4f0d512a 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.28.20 + version: 1.28.21 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -6576,6 +6576,25 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/glp1-plateau: + get: + tags: + - Insights + summary: GLP-1 weight-plateau detection + description: "Deterministic (non-LLM) weight-plateau read for users on an active GLP-1 medication: flags a stable dose + held for at least the trailing window with no weight loss beyond the threshold. `plateau` is null whenever the + condition does not hold, so clients hide the note cleanly. Association only — no verdict, no dose advice. Auth + via cookie or Bearer." + responses: + "200": + description: Plateau context (or null) plus the window length. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/correlations: get: tags: @@ -15842,6 +15861,44 @@ components: - end - minutes additionalProperties: false + sourceDiscrepancy: + anyOf: + - type: object + properties: + deltaMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Spread (max − min) of the disagreeing per-writer asleep totals, in minutes. + sources: + type: array + items: + type: object + properties: + source: + type: string + description: MeasurementSource value of the writer bucket. + deviceType: + anyOf: + - type: string + - type: "null" + asleepMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - source + - deviceType + - asleepMinutes + additionalProperties: false + required: + - deltaMinutes + - sources + additionalProperties: false + - type: "null" + description: Non-null when two writer buckets reported clearly different asleep totals for this session (> 45 min apart + and > 20% of the larger total). Observational only — the served totals stay the winning writer's; + clients may show a discreet 'sources disagree' hint listing each bucket's total. required: - night - source @@ -15854,6 +15911,7 @@ components: - reconstructed - stages - segments + - sourceDiscrepancy additionalProperties: false - type: "null" naps: @@ -15972,6 +16030,44 @@ components: - end - minutes additionalProperties: false + sourceDiscrepancy: + anyOf: + - type: object + properties: + deltaMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Spread (max − min) of the disagreeing per-writer asleep totals, in minutes. + sources: + type: array + items: + type: object + properties: + source: + type: string + description: MeasurementSource value of the writer bucket. + deviceType: + anyOf: + - type: string + - type: "null" + asleepMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - source + - deviceType + - asleepMinutes + additionalProperties: false + required: + - deltaMinutes + - sources + additionalProperties: false + - type: "null" + description: Non-null when two writer buckets reported clearly different asleep totals for this session (> 45 min apart + and > 20% of the larger total). Observational only — the served totals stay the winning writer's; + clients may show a discreet 'sources disagree' hint listing each bucket's total. required: - night - source @@ -15984,6 +16080,7 @@ components: - reconstructed - stages - segments + - sourceDiscrepancy additionalProperties: false required: - night @@ -23658,6 +23755,78 @@ components: description: Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table. + InsightsGlp1PlateauResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsGlp1PlateauResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsGlp1PlateauResponse: + type: object + properties: + plateau: + anyOf: + - type: object + properties: + drug: + type: string + description: Display drug name ("Mounjaro"). + doseValue: + type: number + description: Current dose value (e.g. 7.5). + doseUnit: + type: string + description: Dose unit (e.g. "mg"). + doseSince: + type: string + description: ISO date (YYYY-MM-DD) the current dose started. + daysOnDose: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days the user has been on the current dose. + weightDeltaKg: + type: number + description: Weight delta in kg over the trailing window (negative = loss). + readingsCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Number of weight readings considered. + required: + - drug + - doseValue + - doseUnit + - doseSince + - daysOnDose + - weightDeltaKg + - readingsCount + additionalProperties: false + - type: "null" + description: Null when no plateau is detected (no active GLP-1 medication, < window days on the current dose, weight + still dropping, or fewer than two readings). + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing comparison window in days (currently 21). + required: + - plateau + - windowDays + additionalProperties: false + description: "Deterministic weight-plateau detection for users on an active GLP-1 medication: stable dose for ≥ the + window with no weight loss beyond the threshold. Association only — carries no verdict or advice." CorrelationDiscoveryResponseEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 93f63d8d9..68ef6f2ce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1806,6 +1806,10 @@ "adherenceLane": "Einnahmetreue", "typicalRange": "Üblicher Bereich", "levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.", + "plateau": { + "title": "Gewichts-Plateau auf aktueller Dosis", + "evidence": "Gewichtsveränderung von {delta} kg in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}." + }, "retarget": { "label": "Nicht der richtige Messwert?", "placeholder": "Verfolgen anhand…", @@ -2741,6 +2745,9 @@ "source": "Quelle: {source}", "estimateNote": "Die Phasen-Zeiten sind geschätzt — diese Quelle liefert Summen je Phase, keinen gemessenen Verlauf." }, + "sourceDiscrepancy": { + "tooltipTitle": "Quellen weichen ab" + }, "stages": { "deep": "Tief", "rem": "REM", diff --git a/messages/en.json b/messages/en.json index c99619f58..ae42d7fdc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1806,6 +1806,10 @@ "adherenceLane": "Adherence", "typicalRange": "Typical range", "levelShift": "A shift in the level was detected around {date}.", + "plateau": { + "title": "Weight plateau on the current dose", + "evidence": "Weight moved by {delta} kg over the last {window} days ({readings} readings) — {days} days on {dose}." + }, "retarget": { "label": "Not the right measurement?", "placeholder": "Track against…", @@ -2741,6 +2745,9 @@ "source": "Source: {source}", "estimateNote": "Stage timing is estimated — this source reports totals per stage, not a measured timeline." }, + "sourceDiscrepancy": { + "tooltipTitle": "Sources disagree" + }, "stages": { "deep": "Deep", "rem": "REM", diff --git a/messages/es.json b/messages/es.json index ccf25e291..ef1762112 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1806,6 +1806,10 @@ "adherenceLane": "Adherencia", "typicalRange": "Rango habitual", "levelShift": "Se detectó un cambio de nivel en torno a {date}.", + "plateau": { + "title": "Meseta de peso con la dosis actual", + "evidence": "Variación de peso de {delta} kg en los últimos {window} días ({readings} mediciones) — {days} días con {dose}." + }, "retarget": { "label": "¿No es la medición correcta?", "placeholder": "Seguir según…", @@ -2741,6 +2745,9 @@ "source": "Fuente: {source}", "estimateNote": "Los tiempos de cada fase son estimados: esta fuente informa totales por fase, no una cronología medida." }, + "sourceDiscrepancy": { + "tooltipTitle": "Las fuentes difieren" + }, "stages": { "deep": "Profundo", "rem": "REM", diff --git a/messages/fr.json b/messages/fr.json index e3a275ee0..505c3c1f0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1806,6 +1806,10 @@ "adherenceLane": "Observance", "typicalRange": "Plage habituelle", "levelShift": "Un changement de niveau a été détecté autour du {date}.", + "plateau": { + "title": "Plateau de poids à la dose actuelle", + "evidence": "Variation de poids de {delta} kg sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}." + }, "retarget": { "label": "Ce n'est pas la bonne mesure ?", "placeholder": "Suivre selon…", @@ -2741,6 +2745,9 @@ "source": "Source : {source}", "estimateNote": "Les durées des phases sont estimées : cette source indique des totaux par phase, pas une chronologie mesurée." }, + "sourceDiscrepancy": { + "tooltipTitle": "Les sources divergent" + }, "stages": { "deep": "Profond", "rem": "REM", diff --git a/messages/it.json b/messages/it.json index 3c7c91572..41d9e6c1a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1806,6 +1806,10 @@ "adherenceLane": "Aderenza", "typicalRange": "Intervallo abituale", "levelShift": "È stato rilevato un cambio di livello intorno al {date}.", + "plateau": { + "title": "Plateau del peso alla dose attuale", + "evidence": "Variazione di peso di {delta} kg negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}." + }, "retarget": { "label": "Non è la misurazione giusta?", "placeholder": "Monitora in base a…", @@ -2741,6 +2745,9 @@ "source": "Fonte: {source}", "estimateNote": "I tempi delle fasi sono stimati: questa sorgente riporta i totali per fase, non una cronologia misurata." }, + "sourceDiscrepancy": { + "tooltipTitle": "Le fonti divergono" + }, "stages": { "deep": "Profondo", "rem": "REM", diff --git a/messages/pl.json b/messages/pl.json index aa22b3209..5a4ea06a4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1806,6 +1806,10 @@ "adherenceLane": "Regularność", "typicalRange": "Typowy zakres", "levelShift": "Wykryto zmianę poziomu w okolicach {date}.", + "plateau": { + "title": "Plateau masy ciała przy obecnej dawce", + "evidence": "Zmiana masy ciała o {delta} kg w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}." + }, "retarget": { "label": "To nie ten pomiar?", "placeholder": "Śledź według…", @@ -2741,6 +2745,9 @@ "source": "Źródło: {source}", "estimateNote": "Czasy faz są szacowane — to źródło podaje sumy dla każdej fazy, a nie zmierzony przebieg." }, + "sourceDiscrepancy": { + "tooltipTitle": "Źródła się różnią" + }, "stages": { "deep": "Głęboki", "rem": "REM", diff --git a/package.json b/package.json index 8851553e9..9cd9c5c6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.20", + "version": "1.28.21", "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/prisma/migrations/0238_google_health_sleep_repaired/migration.sql b/prisma/migrations/0238_google_health_sleep_repaired/migration.sql new file mode 100644 index 000000000..74ea22977 --- /dev/null +++ b/prisma/migrations/0238_google_health_sleep_repaired/migration.sql @@ -0,0 +1,4 @@ +-- One-shot Google Health sleep duplicate repair (post-v1.28.18 fix). +-- Null = repair pending; stamped once the boot-time full sleep re-read +-- completes for the connection. +ALTER TABLE "google_health_connections" ADD COLUMN "sleep_repaired_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a4f71534..c2dc16c60 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3361,6 +3361,11 @@ model GoogleHealthConnection { /// Set once the self-converging history backfill walks every data type to /// completion. Null = backfill still in flight (or never started). backfillCompletedAt DateTime? @map("backfill_completed_at") + /// Set once the one-shot sleep duplicate-repair re-read completed — the full + /// sleep-history re-sync that collapses duplicate rows left by the volatile + /// pre-fix externalId (a re-scored night minted parallel copies). Null = + /// repair pending (or the connection predates the fix). + sleepRepairedAt DateTime? @map("sleep_repaired_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/public/sw.js b/public/sw.js index d2129c86a..5722ae20f 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.28.20"; + /* @sw-version-fallback */ "v1.28.21"; 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/*` diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index c7c294aa3..d6989da1b 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -244,6 +244,11 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // inventory / side-effect events), and medications is a core, always-on // domain with no module gate. It is not an insights-module surface. "src/app/api/insights/glp1-timeline/route.ts", + // GLP-1 weight-plateau read backing the plateau note beside the drug-level + // curve (efficacy tab + /insights/medications) — the same MEDICATIONS-domain + // rationale as glp1-timeline above: a deterministic detector over weight + + // dose history, no AI narrative, medications is core/always-on. + "src/app/api/insights/glp1-plateau/route.ts", ]; const MODULE_GATE_NEEDLE = "requireModuleEnabled("; diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 1aa7bbe5b..99ba58ba5 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -88,6 +88,9 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ // ability to dispose of stale recommendations. "src/app/api/insights/feedback/route.ts", "src/app/api/insights/glp1-timeline/route.ts", + // Deterministic GLP-1 plateau detector read (no assistant prose) — same + // medications-domain posture as glp1-timeline directly above. + "src/app/api/insights/glp1-plateau/route.ts", // v1.5.5 — per-user tile layout for the `/insights` surface. The // endpoint persists tile visibility + ordering only; it carries no // assistant prose and is the mirror of `/api/dashboard/widgets`, diff --git a/src/app/api/insights/glp1-plateau/route.ts b/src/app/api/insights/glp1-plateau/route.ts new file mode 100644 index 000000000..10a8d6d92 --- /dev/null +++ b/src/app/api/insights/glp1-plateau/route.ts @@ -0,0 +1,37 @@ +/** + * v1.28.21 — GLP-1 weight-plateau read endpoint. + * + * Surfaces the existing server-side plateau detector + * (`detectGlp1Plateau`, v1.4.25) to the UI: until now the context only + * fed the insight-generation prompt. The two GLP-1 curve surfaces (the + * medication detail "Wirkung" tab and /insights/medications) render a + * compact association-only note from this read. `plateau` is `null` + * whenever the detector bows out (no active GLP-1 medication, < 21 days + * on the current dose, weight still dropping, or fewer than two + * readings), and the note hides cleanly. Mirrors the sibling + * `/api/insights/glp1-timeline` read: per-user, cheap, no module gate + * beyond auth. Auth via cookie or Bearer. + */ + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + detectGlp1Plateau, + PLATEAU_WINDOW_DAYS, +} from "@/lib/insights/glp1-plateau"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const plateau = await detectGlp1Plateau(user.id); + + annotate({ + action: { name: "insights.glp1-plateau.read" }, + meta: { detected: plateau !== null }, + }); + + return apiSuccess({ plateau, windowDays: PLATEAU_WINDOW_DAYS }); +}); diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts index 1ae7be1f9..d8b8632e5 100644 --- a/src/app/api/integrations/google-health/test/route.ts +++ b/src/app/api/integrations/google-health/test/route.ts @@ -115,6 +115,7 @@ async function runStructureProbe( try { let points: Record[]; let requestShape: string | undefined; + let envelopeKeys: string[] | undefined; if (dataType.timeField === "rollup") { // One minimal dailyRollUp exercise per rollup type (7-day range) — the // probe reports the response skeleton AND which documented request @@ -130,6 +131,13 @@ async function runStructureProbe( onShape: (shape) => { requestShape = shape; }, + // Raw envelope key names of the first page (names only). When the + // parse yields zero points this is the one signal that separates + // "the service returned nothing" from "the service returned points + // under a key this reader does not know" — per-cohort naming drift. + onEnvelopeKeys: (keys) => { + envelopeKeys = keys; + }, }, ); } else { @@ -155,6 +163,9 @@ async function runStructureProbe( count: points.length, structure: points.length > 0 ? describeStructure(points[0]) : null, ...(requestShape ? { requestShape } : {}), + // Only meaningful when the parse came back empty — a populated parse + // proves the envelope key already matches. + ...(points.length === 0 && envelopeKeys ? { envelopeKeys } : {}), }; } catch (e) { results[name] = diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index cded37eef..9f6267382 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -88,6 +88,19 @@ function serializeSession(s: SleepSession) { end: seg.end.toISOString(), minutes: Math.round(seg.minutes), })), + // Additive, observational: two writer buckets reported clearly + // different asleep totals for this session. Never changes the served + // totals above — the UI only marks the number with a discreet hint. + sourceDiscrepancy: s.sourceDiscrepancy + ? { + deltaMinutes: Math.round(s.sourceDiscrepancy.deltaMinutes), + sources: s.sourceDiscrepancy.sources.map((b) => ({ + source: b.source, + deviceType: b.deviceType, + asleepMinutes: Math.round(b.asleepMinutes), + })), + } + : null, }; } diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx index 45fbd7795..4bd96d7b6 100644 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx @@ -164,3 +164,29 @@ describe(" — timeline bar visibility", () => { expect(html).toContain('data-slot="sleep-hypnogram-breakdown"'); }); }); + +describe(" — source discrepancy marker", () => { + const discrepancy = { + deltaMinutes: 160, + sources: [ + { source: "WITHINGS", deviceType: null, asleepMinutes: 615 }, + { source: "GOOGLE_HEALTH", deviceType: null, asleepMinutes: 455 }, + ], + }; + + it("renders the discreet marker when the server flags disagreeing sources", () => { + const html = render({ ...baseSession, sourceDiscrepancy: discrepancy }); + expect(html).toContain('data-slot="sleep-source-discrepancy"'); + // Muted marker, no colour wash — the trigger stays on the muted tier. + expect(html).toContain("Sources disagree"); + }); + + it("omits the marker when the night carries no discrepancy", () => { + expect(render(baseSession)).not.toContain( + 'data-slot="sleep-source-discrepancy"', + ); + expect(render({ ...baseSession, sourceDiscrepancy: null })).not.toContain( + 'data-slot="sleep-source-discrepancy"', + ); + }); +}); diff --git a/src/components/insights/glp1-plateau-note.tsx b/src/components/insights/glp1-plateau-note.tsx new file mode 100644 index 000000000..dd8d9f01e --- /dev/null +++ b/src/components/insights/glp1-plateau-note.tsx @@ -0,0 +1,76 @@ +"use client"; + +/** + * v1.28.21 — compact GLP-1 weight-plateau note. + * + * Renders the existing server-side plateau detection (`detectGlp1Plateau`, + * until now prompt-only) as a discreet card on both GLP-1 curve surfaces: + * the medication detail "Wirkung" tab (below the estimated + * active-substance curve) and /insights/medications (below the same curve + * block). Reads `GET /api/insights/glp1-plateau`; when the detector + * returns `null` (no active GLP-1 medication, < 21 days on the current + * dose, weight still dropping, or too few readings) the card renders + * nothing. + * + * Association only, matching the efficacy-tab posture: the copy is a + * title + one evidence line (Δ kg over the window, reading count, days on + * the current dose) — no verdict, no advice, no dose suggestion. The + * strings live under `medications.efficacy.plateau.*` so the banned-verb + * guard (`medications-efficacy-verb-guard.test.ts`) covers them + * structurally. + */ +import { Scale } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import type { Glp1PlateauContext } from "@/lib/insights/glp1-plateau"; + +interface Glp1PlateauResponse { + plateau: Glp1PlateauContext | null; + windowDays: number; +} + +export function Glp1PlateauNote() { + const { t } = useTranslations(); + const { data } = useQuery({ + queryKey: queryKeys.insightsGlp1Plateau(), + queryFn: () => apiGet("/api/insights/glp1-plateau"), + staleTime: 60_000, + }); + + if (!data?.plateau) return null; + const plateau = data.plateau; + + const delta = + plateau.weightDeltaKg > 0 + ? `+${plateau.weightDeltaKg}` + : `${plateau.weightDeltaKg}`; + const dose = `${plateau.drug} ${plateau.doseValue} ${plateau.doseUnit}`; + + return ( + + + + + +

+ {t("medications.efficacy.plateau.evidence", { + delta, + window: data.windowDays, + readings: plateau.readingsCount, + days: plateau.daysOnDose, + dose, + })} +

+
+
+ ); +} diff --git a/src/components/insights/glp1-substance-curve-summary.tsx b/src/components/insights/glp1-substance-curve-summary.tsx index 9b337043d..9fc178a08 100644 --- a/src/components/insights/glp1-substance-curve-summary.tsx +++ b/src/components/insights/glp1-substance-curve-summary.tsx @@ -22,6 +22,7 @@ import dynamic from "next/dynamic"; import { useQuery } from "@tanstack/react-query"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -80,6 +81,10 @@ export function Glp1SubstanceCurveSummary({ /> ))} + {/* v1.28.21 — weight-plateau note (association only), below the + curve(s). Self-gating: renders nothing unless the server-side + detector reports a plateau on the current dose. */} + ); } diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index 2fe5610f7..e2384ad6f 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -10,7 +10,15 @@ import { YAxis, } from "recharts"; +import { TriangleAlert } from "lucide-react"; + import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { hourCycleOptions } from "@/lib/format-locale"; @@ -71,6 +79,20 @@ export interface SleepHypnogramSession { * Withings, Fitbit) is `false` and keeps the timeline. */ reconstructed?: boolean; + /** + * Non-null when two writer buckets reported clearly different asleep + * totals for this night (server-computed, observational only — the + * headline total stays the winning writer's). Renders as a discreet + * marker with a per-source breakdown in a tooltip. + */ + sourceDiscrepancy?: { + deltaMinutes: number; + sources: { + source: string; + deviceType: string | null; + asleepMinutes: number; + }[]; + } | null; } export interface SleepHypnogramProps { @@ -112,6 +134,61 @@ function formatMinutes(total: number, locale: string): string { return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; } +/** + * Localised label keys per `MeasurementSource` — the same map the + * source-priority settings surface uses (`sources-section.tsx`), so both + * surfaces humanise a source identically. + */ +const SOURCE_LABEL_KEYS: Record = { + WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS", + APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH", + MANUAL: "settings.sections.sources.sourceLabels.MANUAL", + IMPORT: "settings.sections.sources.sourceLabels.IMPORT", + WHOOP: "settings.sections.sources.sourceLabels.WHOOP", + COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED", + FITBIT: "settings.sections.sources.sourceLabels.FITBIT", +}; + +/** + * Brand names for sources without a localised settings label — brand + * spellings are locale-invariant, so no i18n key is needed for them. + */ +const SOURCE_BRAND_NAMES: Record = { + OURA: "Oura", + POLAR: "Polar", + GOOGLE_HEALTH: "Google Health", + NIGHTSCOUT: "Nightscout", + STRAVA: "Strava", + TELEGRAM: "Telegram", + MCP: "MCP", +}; + +/** + * Localised device-type label keys (`Measurement.deviceType`), reused from + * the source-priority settings surface. Appended in parentheses so two + * writer apps behind the same source (watch vs phone under Apple Health) + * stay distinguishable in the discrepancy tooltip. + */ +const DEVICE_TYPE_LABEL_KEYS: Record = { + watch: "settings.sections.sources.deviceLabels.watch", + band: "settings.sections.sources.deviceLabels.band", + ring: "settings.sections.sources.deviceLabels.ring", + phone: "settings.sections.sources.deviceLabels.phone", + scale: "settings.sections.sources.deviceLabels.scale", + other: "settings.sections.sources.deviceLabels.other", +}; + +function sourceDisplayName( + source: string, + deviceType: string | null, + t: (key: string) => string, +): string { + const labelKey = SOURCE_LABEL_KEYS[source]; + const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source); + const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined; + return deviceKey ? `${base} (${t(deviceKey)})` : base; +} + export function SleepHypnogram({ session }: SleepHypnogramProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); @@ -251,10 +328,51 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
- + {t("insights.sleep.hypnogram.subtitle", { asleep: formatMinutes(session.asleepMinutes, locale), })} + {/* Discreet source-discrepancy marker: two writers reported + clearly different totals for this night. Observational — the + headline stays the winning writer's number; the tooltip lists + each source's own total. Muted, no colour wash, no banner; + negative margins keep the 44 px hit target from growing the + caption line (same trick as measurement-diversity-nudge). */} + {session.sourceDiscrepancy ? ( + + + + + + +

+ {t("insights.sleep.sourceDiscrepancy.tooltipTitle")} +

+

+ {session.sourceDiscrepancy.sources + .map( + (b) => + `${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`, + ) + .join(" · ")} +

+
+
+
+ ) : null}
{/* The night's measuring source rides the header as a muted caption — it is already on the wire, and "which device diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index 10914ecea..ff04e897f 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -16,6 +16,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { TileHeader } from "@/components/insights/tile-header"; +import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { @@ -170,7 +171,7 @@ export function EfficacyTab({ straight into the tab's `space-y-6` rhythm. Additive: no existing Wirkung content changes. */} {isGlp1 && medicationName != null && medicationDose != null ? ( -
+
+ {/* v1.28.21 — weight-plateau note (association only). Self-gating: + renders nothing unless the server-side detector reports a + plateau on the current dose. */} +
) : null} diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 344dd2f3c..b5fddee60 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -1037,3 +1037,154 @@ describe("per-night writer richness pick", () => { expect(after[0].asleepMinutes).toBe(410); }); }); + +describe("source discrepancy annotation", () => { + // Granular Apple night (300 + 90 + 65 = 455 asleep minutes) next to a + // bare Withings aggregate claiming 615 for the same session. Apple wins + // the night on granular richness; Withings' disagreement is observed + // only, never blended into the served total. + const discrepantRows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + srcRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH"), + srcRow("2026-06-04T05:30:00.000Z", "ASLEEP", 615, "WITHINGS"), + ]; + + it("flags two writer buckets whose asleep totals clearly disagree", () => { + const sessions = reconstructSleepSessions(discrepantRows, "UTC"); + expect(sessions).toHaveLength(1); + const s = sessions[0]; + expect(s.sourceDiscrepancy).toEqual({ + deltaMinutes: 160, + sources: [ + { source: "WITHINGS", deviceType: null, asleepMinutes: 615 }, + { source: "APPLE_HEALTH", deviceType: null, asleepMinutes: 455 }, + ], + }); + }); + + it("never changes the served total — the winning writer's number stands", () => { + // The flagged night serves the SAME asleep total as the identical night + // without the disagreeing second writer: the annotation observes, the + // writer collapse decides. + const flagged = reconstructSleepSessions(discrepantRows, "UTC"); + const winnerOnly = reconstructSleepSessions( + discrepantRows.filter((r) => r.source === "APPLE_HEALTH"), + "UTC", + ); + expect(flagged[0].source).toBe("APPLE_HEALTH"); + expect(flagged[0].asleepMinutes).toBe(455); + expect(flagged[0].asleepMinutes).toBe(winnerOnly[0].asleepMinutes); + expect(winnerOnly[0].sourceDiscrepancy).toBeNull(); + }); + + it("stays null when the buckets agree within the absolute threshold", () => { + // 480 vs 455 → delta 25 ≤ 45 min: ordinary sensor variance, no flag. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + srcRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH"), + srcRow("2026-06-04T05:30:00.000Z", "ASLEEP", 480, "WITHINGS"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].sourceDiscrepancy).toBeNull(); + }); + + it("stays null when the delta clears the floor but not the relative gate", () => { + // 555 vs 500 → delta 55 > 45 min but ≤ 20% of 555 (111): a one-hour + // spread on a long night is not a clear disagreement. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 345, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + srcRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH"), + srcRow("2026-06-04T05:30:00.000Z", "ASLEEP", 555, "WITHINGS"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].asleepMinutes).toBe(500); + expect(sessions[0].sourceDiscrepancy).toBeNull(); + }); + + it("stays null for a single-writer session", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].sourceDiscrepancy).toBeNull(); + }); + + it("an IN_BED/AWAKE-only writer makes no asleep claim and never flags", () => { + // The classic watch + phone pairing: the phone writes only the in-bed + // envelope. It carries no asleep total, so there is nothing to disagree + // about — the marker must not fire on every dual-writer Apple night. + const rows: SleepStageRow[] = [ + writerRow( + "2026-06-04T00:30:00.000Z", + "CORE", + 300, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T03:00:00.000Z", + "DEEP", + 90, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T05:30:00.000Z", + "IN_BED", + 480, + "APPLE_HEALTH", + "phone", + ), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].sourceDiscrepancy).toBeNull(); + }); + + it("distinguishes two writers behind one source by device type", () => { + // Watch granular vs phone coarse ASLEEP behind the same APPLE_HEALTH + // source: the writer-bucket key (source, deviceType) keeps them apart, + // and the annotation carries the device type so the UI can label both. + const rows: SleepStageRow[] = [ + writerRow( + "2026-06-04T00:30:00.000Z", + "CORE", + 300, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T03:00:00.000Z", + "DEEP", + 90, + "APPLE_HEALTH", + "watch", + ), + writerRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH", "watch"), + writerRow( + "2026-06-04T05:30:00.000Z", + "ASLEEP", + 615, + "APPLE_HEALTH", + "phone", + ), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].asleepMinutes).toBe(455); + expect(sessions[0].sourceDiscrepancy).toEqual({ + deltaMinutes: 160, + sources: [ + { source: "APPLE_HEALTH", deviceType: "phone", asleepMinutes: 615 }, + { source: "APPLE_HEALTH", deviceType: "watch", asleepMinutes: 455 }, + ], + }); + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 138d94e20..fe050825e 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -323,6 +323,80 @@ function sourceOfWriterKey(key: string): string { return i === -1 ? key : key.slice(0, i); } +/** + * Thresholds for the per-session source-discrepancy annotation. Two writer + * buckets "clearly disagree" on a night only when the spread of their asleep + * totals exceeds BOTH gates — an absolute floor (small deltas are ordinary + * sensor variance) and a relative one (a 50-minute delta matters on a + * 2-hour nap, not on a 10-hour night). + */ +const DISCREPANCY_MIN_DELTA_MINUTES = 45; +const DISCREPANCY_MIN_RATIO = 0.2; + +/** + * OBSERVATIONAL annotation: two writer buckets reported clearly different + * asleep totals for the same session. Purely additive — it never changes + * which writer wins the night or what total is served; it only surfaces + * that a non-winning bucket disagreed, so the UI can show a discreet + * "sources disagree" marker where the number renders. + */ +export interface SleepSourceDiscrepancy { + /** max − min asleep minutes across the disagreeing writer buckets. */ + deltaMinutes: number; + /** Every writer bucket with an asleep total, most asleep minutes first. */ + sources: { + source: string; + deviceType: string | null; + asleepMinutes: number; + }[]; +} + +/** + * Compare the per-writer asleep totals of ONE session and flag a clear + * disagreement. Buckets follow the same `(source, deviceType)` writer key + * the dedup collapse uses, and each bucket's total applies the same + * granular-over-bare rule as the served number, so the comparison is + * apples-to-apples. Source-less legacy rows are skipped (a bucket the UI + * cannot label is not a nameable second opinion), as are buckets with no + * asleep minutes at all (an IN_BED/AWAKE-only phone writer makes no claim + * about the asleep total). Returns null when fewer than two buckets carry + * an asleep total or the spread stays inside the thresholds. + */ +function sessionSourceDiscrepancy( + rows: readonly SleepStageRow[], +): SleepSourceDiscrepancy | null { + const rowsByWriter = new Map(); + for (const r of rows) { + if (r.source == null) continue; + const key = writerKeyOf(r); + const list = rowsByWriter.get(key) ?? []; + list.push(r); + rowsByWriter.set(key, list); + } + if (rowsByWriter.size < 2) return null; + const buckets: SleepSourceDiscrepancy["sources"] = []; + for (const [key, writerRows] of rowsByWriter) { + const asleepMinutes = asleepMinutesOf(writerRows); + if (asleepMinutes <= 0) continue; + buckets.push({ + source: sourceOfWriterKey(key), + deviceType: writerRows[0].deviceType ?? null, + asleepMinutes, + }); + } + if (buckets.length < 2) return null; + buckets.sort( + (a, b) => + b.asleepMinutes - a.asleepMinutes || (a.source < b.source ? -1 : 1), + ); + const max = buckets[0].asleepMinutes; + const min = buckets[buckets.length - 1].asleepMinutes; + const delta = max - min; + if (delta <= DISCREPANCY_MIN_DELTA_MINUTES) return null; + if (delta <= max * DISCREPANCY_MIN_RATIO) return null; + return { deltaMinutes: delta, sources: buckets }; +} + /** * Count of DISTINCT granular stages (CORE / DEEP / REM) in a row set — the * stage-richness score of a writer's night. 3 = full hypnogram, 0 = coarse @@ -641,6 +715,14 @@ export interface SleepSession { * Fitbit) stay `false`. */ reconstructed: boolean; + /** + * Non-null when two writer buckets reported clearly different asleep + * totals for this session (see `sessionSourceDiscrepancy`). Observational + * only — the served `asleepMinutes` stays the winning writer's total; the + * annotation lets the UI mark the number with a discreet + * "sources disagree" hint. + */ + sourceDiscrepancy: SleepSourceDiscrepancy | null; } /** Resolve a stage row to its absolute span (start = end − duration). */ @@ -791,6 +873,10 @@ export function reconstructSleepSessions( // approximate layout; a real-series source (Apple/Withings/Fitbit) is // measured. reconstructed: RECONSTRUCTED_TIMELINE_SOURCES.has(canonicalSource), + // Compared across the RAW session (every writer, pre-collapse) so a + // losing bucket's disagreement is visible; the served totals above + // stay winner-only. + sourceDiscrepancy: sessionSourceDiscrepancy(session), }); } return sessions.sort((a, b) => a.start.getTime() - b.start.getTime()); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 9a2a969dd..31980d328 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -1025,6 +1025,16 @@ interface RollupQuery { * reports which shape Google actually accepted. */ onShape?: (shape: GoogleHealthRollupShape) => void; + /** + * Observes the raw top-level KEY NAMES of the first response page (never + * values). A rollup read that parses zero points is ambiguous — the service + * either returned nothing (account-side materialisation) or returned points + * under an envelope key this reader does not know (a per-cohort naming + * drift, the class of mismatch this integration has hit three times). The + * structure probe surfaces these keys so a live account settles it in one + * report: `["rollupDataPoints"]` = genuinely empty, anything else = drift. + */ + onEnvelopeKeys?: (keys: string[]) => void; } /** The dailyRollUp request shape a walk settled on. */ @@ -1152,6 +1162,16 @@ export async function fetchDailyRollUp( }); } + // Surface the raw envelope key names of the very first page (names + // only, never values) — see `RollupQuery.onEnvelopeKeys`. + if ( + chunkIndex === 0 && + pageCount === 0 && + json && + typeof json === "object" + ) { + query.onEnvelopeKeys?.(Object.keys(json)); + } for (const p of json?.rollupDataPoints ?? []) points.push(p); pageToken = json?.nextPageToken ?? null; pageCount += 1; diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts index a2f9f45eb..f75388d07 100644 --- a/src/lib/google-health/sync-activity.ts +++ b/src/lib/google-health/sync-activity.ts @@ -105,6 +105,15 @@ export async function syncUserActivity( const start = opts.start; let imported = 0; + // Rollup observability — a cumulative type that "works" (HTTP 200) can still + // land zero rows for two very different reasons, and the sync log must tell + // them apart without a live-account debugging round-trip: + // emptyResponse → the service returned NO rollup points at all (Google-side + // materialisation for the account / device cohort); + // droppedAll → points arrived but none survived the mapper (a response + // naming/shape drift on this account's service revision). + const rollupEmptyResponse: string[] = []; + const rollupDroppedAll: string[] = []; for (const resource of ROLLUP_RESOURCES) { let points: Record[]; try { @@ -131,12 +140,25 @@ export async function syncUserActivity( }); } } + if (points.length === 0) { + rollupEmptyResponse.push(resource.verb); + } else if (readings.length === 0) { + rollupDroppedAll.push(resource.verb); + } imported += ( await upsertGoogleHealthMeasurements(userId, readings, { deferRollup: opts.deferRollup, }) ).imported; } + if (rollupEmptyResponse.length > 0 || rollupDroppedAll.length > 0) { + annotate({ + meta: { + "googleHealth.rollup.emptyResponse": rollupEmptyResponse, + "googleHealth.rollup.droppedAll": rollupDroppedAll, + }, + }); + } // VO2 max — a daily summary (list + `.date` filter), not a rollup type. try { diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts index 539ce74c5..3f4cb2768 100644 --- a/src/lib/insights/glp1-plateau.ts +++ b/src/lib/insights/glp1-plateau.ts @@ -20,7 +20,12 @@ import { prisma } from "@/lib/db"; import type { Locale } from "@/lib/i18n/config"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; -const PLATEAU_WINDOW_DAYS = 21; +/** + * Trailing window (days) the detector compares weight across. Exported + * so the read endpoint (`GET /api/insights/glp1-plateau`) can surface + * the window alongside the context without duplicating the constant. + */ +export const PLATEAU_WINDOW_DAYS = 21; const PLATEAU_THRESHOLD_KG = 0.5; export interface Glp1PlateauContext { diff --git a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts new file mode 100644 index 000000000..e045ad70c --- /dev/null +++ b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts @@ -0,0 +1,142 @@ +/** + * v1.28.x — Google Health one-shot sleep duplicate-repair self-convergence + * tests (mocked). + * - discovery only matches connections not yet repaired + * (`sleepRepairedAt IS NULL`) and singleton-keys the enqueue per user; + * - a completed pass runs a FULL sleep re-read (no `start` lower bound), + * stamps `sleepRepairedAt` so the next discovery drops the account, and + * NEVER touches the `lastSyncedAt` sync watermark; + * - the discovery is best-effort — errors surface through the result value. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, bossSend, syncUserSleep } = vi.hoisted(() => ({ + prismaMock: { + googleHealthConnection: { findMany: vi.fn(), update: vi.fn() }, + }, + bossSend: vi.fn(), + syncUserSleep: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ send: bossSend }), +})); + +vi.mock("@/lib/google-health/sync-sleep", () => ({ + syncUserSleep: (...a: unknown[]) => syncUserSleep(...a), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: () => {}, + getEvent: () => null, +})); + +import { + enqueueBootTimeGoogleHealthSleepRepair, + runGoogleHealthSleepRepairForUser, +} from "../google-health-sleep-repair"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("enqueueBootTimeGoogleHealthSleepRepair — discovery", () => { + it("queries only un-repaired connections and enqueues one singleton-keyed job per user", async () => { + prismaMock.googleHealthConnection.findMany.mockResolvedValue([ + { userId: "u1" }, + { userId: "u2" }, + ]); + bossSend.mockResolvedValue("job-id"); + + const result = await enqueueBootTimeGoogleHealthSleepRepair(); + + expect(prismaMock.googleHealthConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { sleepRepairedAt: null } }), + ); + expect(result.enqueued).toBe(2); + expect(bossSend).toHaveBeenCalledWith( + "google-health-sleep-repair", + expect.objectContaining({ userId: "u1" }), + expect.objectContaining({ + singletonKey: "google-health-sleep-repair|u1", + }), + ); + expect(bossSend).toHaveBeenCalledWith( + "google-health-sleep-repair", + expect.objectContaining({ userId: "u2" }), + expect.objectContaining({ + singletonKey: "google-health-sleep-repair|u2", + }), + ); + }); + + it("self-converges: no un-repaired connections → nothing enqueued", async () => { + prismaMock.googleHealthConnection.findMany.mockResolvedValue([]); + + const result = await enqueueBootTimeGoogleHealthSleepRepair(); + + expect(result.enqueued).toBe(0); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("never throws — surfaces a discovery error through the result value", async () => { + prismaMock.googleHealthConnection.findMany.mockRejectedValue( + new Error("db down"), + ); + + const result = await enqueueBootTimeGoogleHealthSleepRepair(); + + expect(result.error).toBe("db down"); + expect(result.enqueued).toBe(0); + }); +}); + +describe("runGoogleHealthSleepRepairForUser", () => { + it("runs a FULL sleep re-read (no start lower bound) and stamps sleepRepairedAt", async () => { + syncUserSleep.mockResolvedValue(42); + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + + const { imported } = await runGoogleHealthSleepRepairForUser("u1"); + + expect(imported).toBe(42); + // Full-history walk: no `start` in the options object. + const syncOpts = syncUserSleep.mock.calls[0]![1] as Record; + expect(syncUserSleep).toHaveBeenCalledWith("u1", expect.any(Object)); + expect(syncOpts).not.toHaveProperty("start"); + + const updateArg = prismaMock.googleHealthConnection.update.mock + .calls[0]![0] as { + where: Record; + data: Record; + }; + expect(updateArg.where).toEqual({ userId: "u1" }); + expect(updateArg.data.sleepRepairedAt).toBeInstanceOf(Date); + }); + + it("does NOT touch the lastSyncedAt sync watermark", async () => { + syncUserSleep.mockResolvedValue(7); + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + + await runGoogleHealthSleepRepairForUser("u1"); + + // The single connection write stamps ONLY the repair marker — the + // watermark stays where the incremental orchestrator left it. + for (const call of prismaMock.googleHealthConnection.update.mock.calls) { + const { data } = call[0] as { data: Record }; + expect(data).not.toHaveProperty("lastSyncedAt"); + expect(Object.keys(data)).toEqual(["sleepRepairedAt"]); + } + expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledTimes(1); + }); + + it("propagates a sync failure without stamping the marker (pg-boss retries)", async () => { + syncUserSleep.mockRejectedValue(new Error("google 500")); + + await expect(runGoogleHealthSleepRepairForUser("u1")).rejects.toThrow( + "google 500", + ); + expect(prismaMock.googleHealthConnection.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/google-health-sleep-repair.ts b/src/lib/jobs/google-health-sleep-repair.ts new file mode 100644 index 000000000..5008516cb --- /dev/null +++ b/src/lib/jobs/google-health-sleep-repair.ts @@ -0,0 +1,132 @@ +/** + * v1.28.x — pg-boss queue + boot-time one-shot sleep duplicate repair for + * Google Health connections. Modelled on the self-converging backfill + * (`src/lib/jobs/google-health-backfill.ts`): a discovery query enqueues one + * job per connection not yet repaired, and the pass is idempotent across + * reboots (the predicate `sleep_repaired_at IS NULL` drops a connection once + * its repair finishes). + * + * Why: the pre-v1.28.18 volatile sleep externalId minted parallel duplicate + * rows every time Google re-scored a night, over-counting the night total. + * The fix (`replaceStaleGoogleHealthSleep`) makes any RE-READ night self-heal, + * and the incremental 24 h overlap heals recent nights — but historical nights + * keep their duplicates until re-read. This job forces one full sleep-history + * re-read per connection so the replace-by-window collapses every night once. + * + * Watermark-safe by construction: `syncUserSleep` never reads or stamps + * `lastSyncedAt` — `markSynced` is owned by the orchestrator + * (`syncUserGoogleHealth`), which this job does not call. Passing no `start` + * makes the resource fetch full-history (undefined = no lower bound). + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder/register-integration-sync.ts` or pg-boss never + * provisions it and the boot enqueue silently never drains (the v1.4.37 + * dead-queue class). + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { syncUserSleep } from "@/lib/google-health/sync-sleep"; + +export const GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE = "google-health-sleep-repair"; + +/** + * Serial concurrency — a repair walks the full sleep history for one account + * and is rate-bounded by Google's per-app quota; concurrency-1 keeps it from + * crowding the request pool, matching `GOOGLE_HEALTH_BACKFILL_CONCURRENCY`. + */ +export const GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY = 1; + +export interface GoogleHealthSleepRepairPayload { + userId: string; + enqueuedAt: string; +} + +/** + * Per-user repair handler. Runs a full sleep-history re-sync for one account — + * no `start`, so the fetch walks the whole history and + * `replaceStaleGoogleHealthSleep` collapses each night's duplicates in the + * write path — then stamps `sleepRepairedAt` so the discovery query drops it. + * Idempotent: the per-segment upserts are key-stable and the replace-by-window + * clears stale copies, so a re-run (e.g. a reboot mid-walk) converges rather + * than duplicating. Does NOT move `lastSyncedAt`. + */ +export async function runGoogleHealthSleepRepairForUser( + userId: string, +): Promise<{ imported: number }> { + const imported = await syncUserSleep(userId, { deferRollup: false }); + + await prisma.googleHealthConnection.update({ + where: { userId }, + data: { sleepRepairedAt: new Date() }, + }); + + annotate({ + action: { + name: "googleHealth.sleepRepair.complete", + details: { imported }, + }, + }); + return { imported }; +} + +/** + * Boot-time discovery. Finds every Google Health connection not yet repaired + * (`sleep_repaired_at IS NULL`) and enqueues one repair job per account. + * + * Idempotent across reboots: once a connection's repair completes, + * `sleepRepairedAt` is set and the predicate drops it from the discovery set. + * pg-boss `singletonKey` coalesces duplicate sends so a fast restart while a + * job is queued doesn't double up. + * + * Best-effort: errors are returned through the result value so the worker boot + * never fails because of a repair miss. + */ +export async function enqueueBootTimeGoogleHealthSleepRepair(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const connections = await prisma.googleHealthConnection.findMany({ + where: { sleepRepairedAt: null }, + select: { userId: true }, + }); + + if (connections.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const { userId } of connections) { + const payload: GoogleHealthSleepRepairPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, payload, { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `google-health-sleep-repair|${userId}`, + }); + if (jobId) { + enqueued += 1; + } else { + skipped += 1; + } + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index 3bbe3caa7..c1987024d 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -35,6 +35,13 @@ import { enqueueBootTimeGoogleHealthBackfill, type GoogleHealthBackfillPayload, } from "@/lib/jobs/google-health-backfill"; +import { + GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, + GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY, + runGoogleHealthSleepRepairForUser, + enqueueBootTimeGoogleHealthSleepRepair, + type GoogleHealthSleepRepairPayload, +} from "@/lib/jobs/google-health-sleep-repair"; import { STRAVA_BACKFILL_QUEUE, STRAVA_BACKFILL_CONCURRENCY, @@ -244,6 +251,10 @@ const allQueues = [ // self-converging boot backfill, and the daily OAuth-state ledger sweep. GOOGLE_HEALTH_SYNC_QUEUE, GOOGLE_HEALTH_BACKFILL_QUEUE, + // v1.28.x — one-shot sleep duplicate repair. Discovery enqueues one full + // sleep re-read per connection not yet repaired; the replace-by-window write + // path collapses each night's duplicate rows. Idempotent across reboots. + GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, // v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings. // Discovery enqueues one job per connection whose sleep rows predate the @@ -429,6 +440,25 @@ export async function registerIntegrationSyncQueues( } }, ); + // v1.28.x — one-shot Google Health sleep duplicate repair. The boot enqueue + // below sends one full sleep-history re-read per un-repaired connection; this + // handler runs it (watermark-safe — `syncUserSleep` never stamps + // `lastSyncedAt`) and stamps `sleepRepairedAt` so the discovery query drops + // the account. + await boss.work( + GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, + { localConcurrency: GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + const { imported } = await runGoogleHealthSleepRepairForUser(userId); + workerLog( + "info", + `[google-health-sleep-repair] user=${userId} imported=${imported}`, + ); + } + }, + ); await boss.work( GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, { localConcurrency: 1 }, @@ -600,6 +630,31 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise { ); } + // v1.28.x — one-shot Google Health sleep duplicate repair. Finds every + // connection not yet repaired and enqueues one full sleep re-read per + // account; a completed pass stamps `sleepRepairedAt`. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeGoogleHealthSleepRepair(); + if (error) { + workerLog( + "error", + `[google-health-sleep-repair] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[google-health-sleep-repair] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[google-health-sleep-repair] boot discovery threw an unexpected error", + err, + ); + } + // v1.17.1 — one-shot sleep-timeline backfill. Finds every WHOOP + Withings // connection whose sleep rows predate the stamp/shape fix and enqueues one // job per (user, provider). A completed pass stamps `sleepTimelineBackfillAt`. diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 59ef64879..3cda196b1 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -16,6 +16,7 @@ import { derivedBatchQuery, derivedBatchResponse, correlationDiscoveryResponse, + glp1PlateauResponse, insightStatusQuery, insightStatusResponse, medicationComplianceStatusResponse, @@ -377,6 +378,28 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/glp1-plateau": { + get: { + tags: ["Insights"], + summary: "GLP-1 weight-plateau detection", + description: + "Deterministic (non-LLM) weight-plateau read for users on an active GLP-1 medication: flags a stable dose held for at least the trailing window with no weight loss beyond the threshold. `plateau` is null whenever the condition does not hold, so clients hide the note cleanly. Association only — no verdict, no dose advice. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Plateau context (or null) plus the window length.", + content: { + "application/json": { + schema: dataEnvelope( + glp1PlateauResponse, + "InsightsGlp1PlateauResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/correlations": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 075d250ea..8a4a033a9 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -418,6 +418,48 @@ export const correlationDiscoveryResponse = z "v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.", }); +// v1.28.21 — GLP-1 weight-plateau read. Mirrors the fields of the +// server-side detector context (`Glp1PlateauContext`); `plateau` is null +// whenever the detector bows out. +export const glp1PlateauResponse = z + .object({ + plateau: z + .object({ + drug: z.string().describe('Display drug name ("Mounjaro").'), + doseValue: z.number().describe("Current dose value (e.g. 7.5)."), + doseUnit: z.string().describe('Dose unit (e.g. "mg").'), + doseSince: z + .string() + .describe("ISO date (YYYY-MM-DD) the current dose started."), + daysOnDose: z + .number() + .int() + .describe("Days the user has been on the current dose."), + weightDeltaKg: z + .number() + .describe( + "Weight delta in kg over the trailing window (negative = loss).", + ), + readingsCount: z + .number() + .int() + .describe("Number of weight readings considered."), + }) + .nullable() + .describe( + "Null when no plateau is detected (no active GLP-1 medication, < window days on the current dose, weight still dropping, or fewer than two readings).", + ), + windowDays: z + .number() + .int() + .describe("Trailing comparison window in days (currently 21)."), + }) + .meta({ + id: "InsightsGlp1PlateauResponse", + description: + "Deterministic weight-plateau detection for users on an active GLP-1 medication: stable dose for ≥ the window with no weight loss beyond the threshold. Association only — carries no verdict or advice.", + }); + // The seven specialised `*-status` routes accept an optional locale // override (the metric is fixed by the route path, unlike the generic // metric-status route which carries it as a query field). diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 09aab36be..3c15c8d21 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -243,6 +243,29 @@ const sleepSessionResource = z.object({ ), stages: z.record(sleepStageEnum, z.number().int().nonnegative()), segments: z.array(sleepSegmentResource), + sourceDiscrepancy: z + .object({ + deltaMinutes: z + .number() + .int() + .nonnegative() + .describe( + "Spread (max − min) of the disagreeing per-writer asleep totals, in minutes.", + ), + sources: z.array( + z.object({ + source: z + .string() + .describe("MeasurementSource value of the writer bucket."), + deviceType: z.string().nullable(), + asleepMinutes: z.number().int().nonnegative(), + }), + ), + }) + .nullable() + .describe( + "Non-null when two writer buckets reported clearly different asleep totals for this session (> 45 min apart and > 20% of the larger total). Observational only — the served totals stay the winning writer's; clients may show a discreet 'sources disagree' hint listing each bucket's total.", + ), }); const sleepNightResponse = z diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 22e306553..7d5a517fb 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -115,6 +115,12 @@ export const insightsKeys = { insightsProviderChain: () => ["insights", "provider-chain"] as const, insightsGlp1Timeline: (limit: number | string) => ["insights", "glp1-timeline", limit] as const, + /** + * v1.28.21 — GLP-1 weight-plateau read (`/api/insights/glp1-plateau`). + * Per-user, no params; the `["insights"]` prefix keeps it inside the + * existing invalidation fan-out. + */ + insightsGlp1Plateau: () => ["insights", "glp1-plateau"] as const, /** * v1.5.5 — per-user insights tile layout (mirrors