mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #465 from MBombeck/release/v1.28.21
release/v1.28.21 — sleep history heals itself; plateau context; sync diagnostics
This commit is contained in:
34 files changed
+1256
-7
No files matched your search
+24
-1
@@ -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
|
||||
|
||||
+170
-1
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -244,6 +244,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// 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(";
|
||||
|
||||
@@ -88,6 +88,9 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// 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`,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -115,6 +115,7 @@ async function runStructureProbe(
|
||||
try {
|
||||
let points: Record<string, unknown>[];
|
||||
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] =
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -164,3 +164,29 @@ describe("<SleepHypnogram> — timeline bar visibility", () => {
|
||||
expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SleepHypnogram> — 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Glp1PlateauResponse>("/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 (
|
||||
<Card className="gap-2 py-3 md:py-4" data-slot="glp1-plateau-note">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
size="sm"
|
||||
icon={Scale}
|
||||
title={t("medications.efficacy.plateau.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.efficacy.plateau.evidence", {
|
||||
delta,
|
||||
window: data.windowDays,
|
||||
readings: plateau.readingsCount,
|
||||
days: plateau.daysOnDose,
|
||||
dose,
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 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. */}
|
||||
<Glp1PlateauNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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) {
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<TileHeader title={t("insights.sleep.hypnogram.title")} />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
{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 ? (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="sleep-source-discrepancy"
|
||||
aria-label={t(
|
||||
"insights.sleep.sourceDiscrepancy.tooltipTitle",
|
||||
)}
|
||||
className="text-muted-foreground focus-visible:ring-ring/50 -mx-3 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<TriangleAlert className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
data-slot="sleep-source-discrepancy-body"
|
||||
align="start"
|
||||
className="max-w-xs leading-relaxed"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
|
||||
</p>
|
||||
<p>
|
||||
{session.sourceDiscrepancy.sources
|
||||
.map(
|
||||
(b) =>
|
||||
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`,
|
||||
)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</span>
|
||||
{/* The night's measuring source rides the header as a muted
|
||||
caption — it is already on the wire, and "which device
|
||||
|
||||
@@ -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 ? (
|
||||
<div data-slot="wirkung-drug-level-section">
|
||||
<div className="space-y-6" data-slot="wirkung-drug-level-section">
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medicationId,
|
||||
@@ -178,6 +179,10 @@ export function EfficacyTab({
|
||||
dose: medicationDose,
|
||||
}}
|
||||
/>
|
||||
{/* v1.28.21 — weight-plateau note (association only). Self-gating:
|
||||
renders nothing unless the server-side detector reports a
|
||||
plateau on the current dose. */}
|
||||
<Glp1PlateauNote />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, SleepStageRow[]>();
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
expect(syncUserSleep).toHaveBeenCalledWith("u1", expect.any(Object));
|
||||
expect(syncOpts).not.toHaveProperty("start");
|
||||
|
||||
const updateArg = prismaMock.googleHealthConnection.update.mock
|
||||
.calls[0]![0] as {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
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<string, unknown> };
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<GoogleHealthSleepRepairPayload>(
|
||||
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<GoogleHealthOAuthStateCleanupPayload>(
|
||||
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
@@ -600,6 +630,31 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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`.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
derivedBatchQuery,
|
||||
derivedBatchResponse,
|
||||
correlationDiscoveryResponse,
|
||||
glp1PlateauResponse,
|
||||
insightStatusQuery,
|
||||
insightStatusResponse,
|
||||
medicationComplianceStatusResponse,
|
||||
@@ -377,6 +378,28 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user