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:
Marc-André Bombeck authored and GitHub committed 2026-07-10 21:33:18 +02:00
commit 94ad48527e
34 files changed
+1256 -7

No files matched your search

+24 -1
View File
@@ -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
View File
@@ -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:
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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
View File
@@ -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);
+5
View File
@@ -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
View File
@@ -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] =
+13
View File
@@ -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>
);
}
+119 -1
View File
@@ -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 },
],
});
});
});
+86
View File
@@ -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());
+20
View File
@@ -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;
+22
View File
@@ -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 {
+6 -1
View File
@@ -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();
});
});
+132
View File
@@ -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`.
+23
View File
@@ -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).
+23
View File
@@ -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
+6
View File
@@ -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