Merge pull request #254 from MBombeck/release/v1.11.3

release/v1.11.3 — WHOOP body data, quality-of-life polish, fuller translations
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-04 16:06:19 +02:00
commit 979bfe4c01
38 files changed
+4192 -3049

No files matched your search

+31
View File
@@ -1,5 +1,36 @@
# Changelog
## [1.11.3] — 2026-06-04 — WHOOP body data, quality-of-life polish, fuller translations
### Added
- **WHOOP body data.** WHOOP weight and maximum-heart-rate context now flow in alongside recovery, sleep, cycle, and workout data. Weight is ranked below a real scale by your source priority, so a connected scale or manual entry still wins; your profile height is filled in from WHOOP only if you haven't set one yet.
- **Stop a Coach reply mid-stream.** A Stop control appears while the assistant is answering, and closing the Coach drawer now ends an in-flight response instead of leaving it running.
- **Undo a logged dose.** The confirmation after taking or skipping a dose now offers an Undo, so a mis-tap is one tap to reverse instead of a trip into the history.
### Changed
- **More of the app speaks your language.** Spanish, French, Italian, and Polish now cover the Settings, Medications, Insights rhythm-event, and Achievements copy that previously fell back to English.
- **A WHOOP plan that doesn't expose every data type no longer breaks the connection.** A data class WHOOP declines to share is skipped on its own while the rest keeps syncing.
- The first Coach reply on a cold cache arrives faster — its inputs are now gathered in parallel.
### Fixed
- **A failed dose log is no longer silent.** If recording a dose doesn't go through, you now see an error instead of a confirmation for something that didn't happen.
- The quick-entry sheets only ask before discarding when you have actually typed something.
- A failed export now shows a clear message instead of a raw error code.
- Saving feedback on an Insights recommendation surfaces an error if it can't be stored.
- Several loading placeholders now reserve their final height, removing the small layout shifts on the Insights and dashboard surfaces as content lands.
### Accessibility
- Reduced-motion now suppresses the Insights card entrance animation.
- The API-token revoke button carries an accessible name and a larger target, and the medication scheduler's step controls have a larger touch target.
### Notes
- WHOOP weight is the value stored in your WHOOP profile, not a scale reading; it is ranked last among weight sources and only surfaces when nothing higher-priority is present.
## [1.11.2] — 2026-06-04 — Coach-memory controls, privacy hardening, more pinnable metrics
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.11.2
version: 1.11.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+15
View File
@@ -278,6 +278,12 @@
"emptyDescription": "Lege zuerst ein Medikament mit Zeitfenster an, damit du die Einnahme protokollieren kannst.",
"emptyCta": "Medikament anlegen"
},
"quickEntryDiscard": {
"title": "Eintrag verwerfen?",
"description": "Du hast nicht gespeicherte Eingaben. Formular schließen und das Getippte verwerfen?",
"confirm": "Verwerfen",
"cancel": "Weiter bearbeiten"
},
"compliance7d": "Einnahmetreue (7 Tage)",
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
@@ -622,6 +628,10 @@
"skipped": "Übersprungen",
"intakeToastTaken": "{name} als genommen erfasst",
"intakeToastSkipped": "{name} übersprungen",
"intakeToastFailed": "{name} konnte nicht erfasst werden – bitte erneut versuchen",
"intakeUndo": "Rückgängig",
"intakeUndone": "Eintrag zurückgenommen",
"intakeUndoFailed": "Rückgängig fehlgeschlagen – im Verlauf korrigieren",
"weekdaySunday": "Sonntag",
"weekdayMonday": "Montag",
"weekdayTuesday": "Dienstag",
@@ -1518,6 +1528,7 @@
"feedbackThanks": "Danke für dein Feedback",
"feedbackConfirmed": "Feedback gespeichert",
"feedbackAlreadyRated": "Bereits bewertet",
"feedbackError": "Dein Feedback konnte nicht gespeichert werden. Bitte versuche es erneut.",
"confidence": "Vertrauen",
"confidenceAria": "Vertrauen: {value} von 100",
"confidenceHigh": "Hohes Vertrauen",
@@ -1539,6 +1550,7 @@
"regenerateAnalysis": "Analyse neu starten",
"regenerateSuccess": "Analyse wurde neu erstellt",
"warmAssessments": "Auswertungen vorbereiten",
"warmAssessmentsHint": "Auswertungen werden über Nacht automatisch aktualisiert. Bereite sie jetzt vor, um den neuesten Stand zu sehen.",
"warmStarted": "Auswertungen werden im Hintergrund erstellt",
"narrativeTitle": "Dein Zeitraum im Rückblick",
"narrativeWeek": "Diese Woche",
@@ -1696,6 +1708,7 @@
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
"send": "Senden",
"stop": "Stopp",
"thinking": "Denke nach…",
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
"composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.",
@@ -3093,6 +3106,7 @@
"title": "Export",
"description": "Lade deine Gesundheitsdaten als PDF, CSV oder vollständiges JSON-Backup herunter.",
"otherOptionsHeading": "Weitere Export-Optionen",
"downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.",
"hero": {
"eyebrow": "Arzttermin",
"valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Medikamenten-Compliance und (optional) Stimmungsverlauf, alles auf wenigen Seiten.",
@@ -3457,6 +3471,7 @@
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
"tokenRevoke": "Token widerrufen?",
"tokenRevokeAction": "Token widerrufen",
"tokenRevokeDescription": "Der Token wird unwiderruflich deaktiviert. Anwendungen, die ihn nutzen, verlieren den Zugriff.",
"tokenRevoked": "Widerrufen",
"tokenExpired": "Abgelaufen",
+15
View File
@@ -278,6 +278,12 @@
"emptyDescription": "Create a medication with a schedule first, then come back here to log the intake.",
"emptyCta": "Add medication"
},
"quickEntryDiscard": {
"title": "Discard this entry?",
"description": "You have unsaved input. Close the form and lose what you typed?",
"confirm": "Discard",
"cancel": "Keep editing"
},
"compliance7d": "Compliance (7 days)",
"mood": "Mood",
"customizeTitle": "Dashboard layout",
@@ -622,6 +628,10 @@
"skipped": "Skipped",
"intakeToastTaken": "{name} recorded as taken",
"intakeToastSkipped": "{name} skipped",
"intakeToastFailed": "Could not record {name} — try again",
"intakeUndo": "Undo",
"intakeUndone": "Entry reverted",
"intakeUndoFailed": "Could not undo — open the history to fix it",
"weekdaySunday": "Sunday",
"weekdayMonday": "Monday",
"weekdayTuesday": "Tuesday",
@@ -1518,6 +1528,7 @@
"feedbackThanks": "Thanks for your feedback",
"feedbackConfirmed": "Feedback recorded",
"feedbackAlreadyRated": "Already rated",
"feedbackError": "Could not save your feedback. Please try again.",
"confidence": "Confidence",
"confidenceAria": "Confidence: {value} of 100",
"confidenceHigh": "High confidence",
@@ -1539,6 +1550,7 @@
"regenerateAnalysis": "Re-run analysis",
"regenerateSuccess": "Analysis refreshed",
"warmAssessments": "Prepare assessments",
"warmAssessmentsHint": "Assessments refresh overnight automatically. Prepare them now to read the latest.",
"warmStarted": "Assessments are being prepared in the background",
"narrativeTitle": "Your period in review",
"narrativeWeek": "This week",
@@ -1696,6 +1708,7 @@
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
"send": "Send",
"stop": "Stop",
"thinking": "Thinking…",
"composerPlaceholder": "Ask anything about your data…",
"composerHint": "Press Enter to send, Shift+Enter for a new line.",
@@ -3093,6 +3106,7 @@
"title": "Export",
"description": "Download your health data as PDF, CSV, or a full JSON backup.",
"otherOptionsHeading": "Other export options",
"downloadFailed": "Couldn't download the export. Please try again.",
"hero": {
"eyebrow": "Doctor visit",
"valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication compliance and (optionally) mood, all on a few pages.",
@@ -3457,6 +3471,7 @@
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
"tokenCreated": "Token created — copy it now! It won't be shown again.",
"tokenRevoke": "Revoke token?",
"tokenRevokeAction": "Revoke token",
"tokenRevokeDescription": "The token will be permanently deactivated. Applications using it will lose access.",
"tokenRevoked": "Revoked",
"tokenExpired": "Expired",
+720 -705
View File
File diff suppressed because it is too large. Load diff
+712 -697
View File
File diff suppressed because it is too large. Load diff
+720 -705
View File
File diff suppressed because it is too large. Load diff
+718 -703
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.11.2",
"version": "1.11.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -4,51 +4,79 @@ import { join } from "node:path";
/**
* v1.4.43 W11 — insights mother-page dynamic-skeleton guards.
* v1.11.3 — rewritten for the shared `BlockSkeleton` loader.
*
* The three `next/dynamic` loading placeholders for
* `DailyBriefing` / `CorrelationRow` / `TrendsRow` used to reserve
* heights (`h-48` / `h-32` / `h-64`) noticeably shorter than the
* loaded content (~24 rem / ~20 rem each), which CLS-shifted the
* page on slow networks. They also lacked `motion-reduce:animate-none`,
* so motion-sensitive users saw a continuous pulse.
* The `next/dynamic` loading placeholders for the below-the-hero blocks
* used to be bespoke `<div className="… h-[Xrem] animate-pulse …
* motion-reduce:animate-none" />` snippets with hard-coded guessed
* heights. Those fixed heights pinned each placeholder taller or shorter
* than the resolved block, so the page CLS-shifted as each chunk landed.
*
* This textual guard pins both the larger reserved heights and the
* motion-reduce class. The check is intentionally simple — render-
* mounting the page would haul in TanStack-Query / Auth / I18n
* scaffolding for a property a substring search already proves.
* E1 collapses all six loaders onto a single shared `BlockSkeleton` that
* routes through the `Skeleton` primitive (which carries
* `motion-reduce:animate-none`) and holds the row open with a `min-h`
* floor rather than a fixed `h-[Xrem]`. This guard pins the new contract:
* every loader uses `BlockSkeleton`, none re-introduces a hard-coded
* fixed height, and the decorative (un-mountable) cards stay `aria-hidden`.
*
* The check is intentionally textual — render-mounting the page would haul
* in TanStack-Query / Auth / I18n scaffolding for a property a substring
* search already proves.
*/
describe("insights mother-page dynamic-skeleton heights + motion-reduce", () => {
describe("insights mother-page dynamic-skeleton loaders use the shared BlockSkeleton", () => {
const src = readFileSync(
join(process.cwd(), "src/app/insights/page.tsx"),
"utf8",
);
it("DailyBriefing skeleton reserves h-[24rem]", () => {
expect(src).toMatch(
/DailyBriefing[\s\S]*?h-\[24rem\][^"]*motion-reduce:animate-none/,
);
it("defines the shared BlockSkeleton helper", () => {
expect(src).toMatch(/function BlockSkeleton\(/);
// The helper renders the shared Skeleton primitive, which carries
// motion-reduce:animate-none, so every consumer inherits the guard.
expect(src).toMatch(/<Skeleton[\s\S]*?rounded-xl/);
});
it("CorrelationRow skeleton reserves h-[20rem]", () => {
expect(src).toMatch(
/CorrelationRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/,
);
it("every dynamic loader routes through BlockSkeleton with a min-h floor", () => {
// Each below-the-hero loader pins a `min-h-*` floor (not a guessed
// fixed `h-[Xrem]`) so the row holds open without fighting the
// resolved block's true height.
const loaderMatches = [
...src.matchAll(
/loading:\s*\(\)\s*=>\s*<BlockSkeleton\s+minHeight="(min-h-[\w[\]-]+)"/g,
),
];
// Five of the six dynamic blocks declare a skeleton loader
// (RhythmEventsCard intentionally has none — it un-mounts itself when
// the user has no such events, with no skeleton-then-empty flash).
expect(loaderMatches.length).toBeGreaterThanOrEqual(5);
for (const match of loaderMatches) {
expect(match[1]).toMatch(/^min-h-/);
}
});
it("TrendsRow skeleton reserves h-[20rem]", () => {
expect(src).toMatch(
/TrendsRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/,
);
it("the decorative (un-mountable) card loaders stay aria-hidden via the decorative flag", () => {
// CoincidentDeviationCard + PeriodNarrativeCard can un-mount, so their
// placeholders must hide from assistive tech.
const decorativeLoaders = [
...src.matchAll(/<BlockSkeleton[^/]*\bdecorative\b/g),
];
expect(decorativeLoaders.length).toBeGreaterThanOrEqual(2);
});
it("no insights-page skeleton falls back to the legacy h-48/h-32/h-64 trio", () => {
// The legacy classes shouldn't be reintroduced on the mother
// page's three loading placeholders.
const dynamicBlock = src.match(/const DailyBriefing[\s\S]*?const TrendsRow[\s\S]*?\);\n/);
it("no loader re-introduces a guessed fixed h-[Xrem] / h-[Xpx] height", () => {
// The guessed fixed heights were the CLS root cause; the loaders now
// hold the row open with a `min-h-*` floor instead. A fixed
// `h-[Xrem]` / `h-[Xpx]` must not creep back onto the dynamic-loader
// placeholders.
const dynamicBlock = src.match(
/const DailyBriefing[\s\S]*?const PeriodNarrativeCard[\s\S]*?\);\n/,
);
expect(dynamicBlock).not.toBeNull();
const block = dynamicBlock?.[0] ?? "";
expect(block).not.toMatch(/"[^"]*\bh-48\b[^"]*"/);
expect(block).not.toMatch(/"[^"]*\bh-32\b[^"]*"/);
expect(block).not.toMatch(/"[^"]*\bh-64\b[^"]*"/);
expect(block).not.toMatch(/h-\[\d+rem\]/);
expect(block).not.toMatch(/h-\[\d+px\]/);
// No bespoke pulsing card div should remain — every placeholder is
// the shared BlockSkeleton.
expect(block).not.toMatch(/animate-pulse/);
});
});
+50 -34
View File
@@ -12,6 +12,8 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useInsightsWarm } from "@/components/insights/use-insights-warm";
@@ -23,16 +25,40 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics";
/**
* v1.4.33 IW2 — defer the three below-the-fold mother-page blocks
* behind `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece)
* stays an eager import so the initial paint shows the greeting and
* health-score badge without a flash; the briefing, correlation row
* and trends row each carry their own icon-set + chart wiring (a chart
* card alone weighs in at the lucide tree-shake limit) and used to
* land on every Insights cold mount. Loader skeletons match the
* existing fallback so the layout doesn't shift while the chunks
* resolve.
* v1.4.33 IW2 — defer the below-the-fold mother-page blocks behind
* `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece) stays an
* eager import so the initial paint shows the greeting and health-score
* badge without a flash; the briefing, correlation row and trends row
* each carry their own icon-set + chart wiring (a chart card alone weighs
* in at the lucide tree-shake limit) and used to land on every Insights
* cold mount.
*
* v1.11.3 — every loader fallback now routes through the shared
* `BlockSkeleton` instead of a bespoke `h-[Xrem] animate-pulse` div. The
* fixed guessed heights pinned each placeholder taller (or shorter) than
* the resolved block, so the page jumped as each chunk landed. The shared
* skeleton carries a `min-h` floor — enough to hold the row open at cold
* mount — and lets the block grow into its true height without fighting a
* hard-coded pixel guess, killing the resolve-time layout shift. Reduced
* motion is honoured by the `Skeleton` primitive (`motion-reduce:animate-none`).
*/
function BlockSkeleton({
minHeight,
decorative = false,
}: {
/** Tailwind `min-h-*` utility holding the row open before the chunk lands. */
minHeight: string;
/** Decorative placeholders (cards that may un-mount) hide from a11y. */
decorative?: boolean;
}) {
return (
<Skeleton
{...(decorative ? { "aria-hidden": "true" } : {})}
className={cn("w-full rounded-xl", minHeight)}
/>
);
}
const DailyBriefing = dynamic(
() =>
import("@/components/insights/daily-briefing").then((mod) => ({
@@ -40,9 +66,7 @@ const DailyBriefing = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[24rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-96" />,
},
);
const CorrelationRow = dynamic(
@@ -52,9 +76,7 @@ const CorrelationRow = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
const TrendsRow = dynamic(
@@ -64,9 +86,7 @@ const TrendsRow = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of
@@ -80,9 +100,7 @@ const VitalsDashboard = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
// v1.10.0 — categorical events (WX-B). The device-flagged event awareness
@@ -111,12 +129,7 @@ const CoincidentDeviationCard = dynamic(
})),
{
ssr: false,
loading: () => (
<div
aria-hidden="true"
className="bg-card border-border min-h-48 animate-pulse rounded-xl border motion-reduce:animate-none"
/>
),
loading: () => <BlockSkeleton minHeight="min-h-48" decorative />,
},
);
@@ -132,12 +145,7 @@ const PeriodNarrativeCard = dynamic(
})),
{
ssr: false,
loading: () => (
<div
aria-hidden="true"
className="bg-card border-border min-h-40 animate-pulse rounded-xl border motion-reduce:animate-none"
/>
),
loading: () => <BlockSkeleton minHeight="min-h-40" decorative />,
},
);
@@ -293,10 +301,18 @@ export default function InsightsPage() {
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
<div className="flex justify-end">
{/* v1.11.3 — anchor the warm-assessments control. It used to float
right-aligned with no label, reading as an orphan affordance. The
left-aligned caption explains the nightly-refresh model and gives the
button context. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs">
{t("insights.warmAssessmentsHint")}
</p>
<Button
size="sm"
variant="outline"
className="self-start sm:self-auto"
onClick={warm}
disabled={isWarming}
>
+52 -2
View File
@@ -13,7 +13,9 @@ import type { MedicationPayload } from "@/components/medications/wizard/wizard-p
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { Loader2, Pill, Plus } from "lucide-react";
interface Schedule {
@@ -71,6 +73,44 @@ interface Medication {
schedules: Schedule[];
}
/**
* v1.11.3 C4 — loading placeholder for one medication card. Renders the
* real `Card`/`CardContent` shell (so padding, radius and gap match the
* loaded card exactly) with the shared `Skeleton` primitive standing in
* for each populated slot: header title + dose, status pill, next / last
* line, the two compliance bars and the action row. Replacing the bare
* centred spinner with a grid of these stops the layout jump when the
* cards resolve.
*/
function MedicationCardSkeleton() {
return (
<Card className="h-full" aria-hidden="true">
<div className="flex items-start justify-between gap-2 px-4 md:px-6">
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-20" />
</div>
<Skeleton className="size-8 rounded-md" />
</div>
<CardContent className="flex flex-col space-y-3.5">
<Skeleton className="h-6 w-40 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-3.5 w-48" />
<Skeleton className="h-3.5 w-36" />
</div>
<div className="space-y-2">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-full" />
</div>
<div className="flex gap-2 pt-1">
<Skeleton className="h-9 flex-1" />
<Skeleton className="h-9 flex-1" />
</div>
</CardContent>
</Card>
);
}
export default function MedicationsPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
@@ -223,8 +263,18 @@ export default function MedicationsPage() {
</div>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
// v1.11.3 C4 — card skeletons instead of a bare centred spinner so
// the page reserves the loaded layout and does not jump when the
// medications resolve.
<div
className="grid gap-4 sm:grid-cols-2"
role="status"
aria-busy="true"
aria-label={t("medications.title")}
>
{Array.from({ length: 4 }).map((_, i) => (
<MedicationCardSkeleton key={i} />
))}
</div>
) : isError ? (
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
+107 -5
View File
@@ -32,6 +32,16 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -59,6 +69,49 @@ const DASHBOARD_QUERY_OPTS = {
refetchOnWindowFocus: false,
} as const;
/**
* v1.11.3 F3 — guard the quick-entry sheets against an accidental
* dismiss (overlay tap, Esc, mobile swipe-down) discarding a partly
* filled form. The forms live behind a stable `ResponsiveSheet` body
* slot and don't expose their dirty state, so we read it from the DOM
* at dismiss time: any visible text/number input or textarea that
* carries a value, or any checked checkbox/radio that isn't a default,
* counts as unsaved input. Date/time pickers are excluded — the forms
* prefill them with the current timestamp on mount, so a pristine sheet
* would otherwise always read as dirty. Cheap, synchronous, and runs
* only on the one mounted sheet body.
*/
const PREFILLED_PICKER_TYPES = new Set([
"date",
"datetime-local",
"time",
"month",
"week",
]);
function sheetBodyHasUnsavedInput(): boolean {
if (typeof document === "undefined") return false;
const body = document.querySelector<HTMLElement>(
'[data-slot="responsive-sheet-body"]',
);
if (!body) return false;
const fields = body.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement
>("input, textarea");
for (const field of fields) {
if (field.disabled || field.type === "hidden") continue;
// Skip pickers the form prefills with "now" — an untouched default
// is not user input (v1.11.3 QA H1).
if (PREFILLED_PICKER_TYPES.has(field.type)) continue;
if (field.type === "checkbox" || field.type === "radio") {
const box = field as HTMLInputElement;
if (box.checked !== box.defaultChecked) return true;
continue;
}
if (field.value.trim() !== "") return true;
}
return false;
}
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
@@ -215,6 +268,22 @@ export default function DashboardPage() {
// mobile soft keyboard.
const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] =
useState<HTMLDivElement | null>(null);
// v1.11.3 F3 — when an open quick-entry sheet is dismissed with
// unsaved input, hold the close in this flag and surface a confirm
// instead of nulling the dialog outright. Cleared once the user
// confirms (close) or keeps editing (dismiss the confirm).
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
// Intercept a sheet dismiss: close immediately when the form is
// clean, otherwise keep the sheet open and ask before discarding.
function handleQuickEntryOpenChange(open: boolean) {
if (open) return;
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
setQuickEntryDialog(null);
}
// v1.4.29 M5 — the three inline dashboard queries default to a
// 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch
@@ -738,7 +807,7 @@ export default function DashboardPage() {
{/* Quick Entry Sheets — bottom-sheet on `<md`, centred Dialog on `md+`. */}
<ResponsiveSheet
open={quickEntryDialog === "measurement"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
onOpenChange={handleQuickEntryOpenChange}
title={t("measurements.addMeasurement")}
footer={
<div ref={setMeasurementFooterEl} className="flex w-full" />
@@ -752,7 +821,7 @@ export default function DashboardPage() {
</ResponsiveSheet>
<ResponsiveSheet
open={quickEntryDialog === "mood"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
onOpenChange={handleQuickEntryOpenChange}
title={t("mood.addEntry")}
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
>
@@ -768,7 +837,7 @@ export default function DashboardPage() {
stays reachable above the soft keyboard on mobile. */}
<ResponsiveSheet
open={quickEntryDialog === "medicationIntake"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
onOpenChange={handleQuickEntryOpenChange}
title={t("dashboard.medicationIntakeQuickAdd.sheetTitle")}
description={t("dashboard.medicationIntakeQuickAdd.sheetDescription")}
footer={
@@ -782,6 +851,39 @@ export default function DashboardPage() {
/>
</ResponsiveSheet>
{/* v1.11.3 F3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay
tap, Escape, or a mobile swipe-down. Keeps the sheet open
until the user decides. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
setQuickEntryDialog(null);
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{(() => {
type TrendEntry = { id: string; order: number; node: React.ReactNode };
const trendCards: TrendEntry[] = [];
@@ -1509,7 +1611,7 @@ export default function DashboardPage() {
{Array.from({ length: configuredTileCount }).map((_, idx) => (
<div
key={`tile-skeleton-${idx}`}
className="bg-card border-border min-h-[6rem] animate-pulse rounded-xl border p-4 motion-reduce:animate-none md:p-6"
className="bg-card border-border min-h-[8rem] animate-pulse rounded-xl border p-4 motion-reduce:animate-none md:p-6"
/>
))}
</div>
@@ -1570,7 +1672,7 @@ export default function DashboardPage() {
fallback={
<div
aria-hidden="true"
className="bg-card border-border flex h-full min-h-[6rem] w-full min-w-0 flex-col rounded-xl border p-4 md:p-6"
className="bg-card border-border flex h-full min-h-[8rem] w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6"
/>
}
>
@@ -65,3 +65,56 @@ describe("motion-reduce coverage — every animate-spin pairs with motion-reduce
).toEqual([]);
});
});
/**
* v1.11.3 — `.animate-insight-in` reduced-motion guard.
*
* Unlike `animate-spin` (a Tailwind utility paired with an inline
* `motion-reduce:animate-none` per site), `.animate-insight-in` is a custom
* keyframe utility defined in `globals.css` and applied across five insight
* surfaces (recommendations-grid, recommendation-card, insight-status-card,
* hero-strip, arztbericht-hero-card). Guarding it at each call site would be
* fragile, so the guard lives once in `globals.css`: a
* `@media (prefers-reduced-motion: reduce)` block collapses the animation to
* `none`, covering every consumer at once. This test pins that central guard
* so a future edit cannot drop it and silently re-introduce a 400 ms motion
* for motion-sensitive users.
*/
describe("motion-reduce coverage — animate-insight-in collapses under prefers-reduced-motion", () => {
const GLOBALS = join(process.cwd(), "src", "app", "globals.css");
const css = readFileSync(GLOBALS, "utf8");
it("globals.css defines the .animate-insight-in utility", () => {
expect(css).toMatch(/\.animate-insight-in\s*\{[^}]*animation:/);
});
it("a prefers-reduced-motion block collapses .animate-insight-in to no motion", () => {
// Locate the reduced-motion media query, then assert that within its
// body `.animate-insight-in` is reset to `animation: none`. The check
// is anchored on the media query so a stray `.animate-insight-in {
// animation: none }` outside a reduced-motion context cannot satisfy it.
const mediaStart = css.search(
/@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)/,
);
expect(
mediaStart,
"Expected a `@media (prefers-reduced-motion: reduce)` block in globals.css.",
).toBeGreaterThanOrEqual(0);
// Scan from the media query to the end of the file — every motion the app
// defers under reduced motion lives in such a block, and `.animate-insight-in`
// must be one of them.
const fromMedia = css.slice(mediaStart);
const guarded = /\.animate-insight-in\s*\{\s*animation:\s*none/.test(
fromMedia,
);
expect(
guarded,
"Expected `.animate-insight-in { animation: none }` inside a " +
"`@media (prefers-reduced-motion: reduce)` block in globals.css. " +
"Without it the 400 ms entrance keyframe plays for motion-sensitive " +
"users on every insight card.",
).toBe(true);
});
});
@@ -122,6 +122,65 @@ describe("<CoachInput>", () => {
expect(html).toContain("animate-spin");
});
it("swaps the send button for a Stop control while streaming with onCancel", () => {
// v1.11.3 D1 — while a reply streams the composer must surface a
// visible Stop affordance bound to the abort handler so the user
// can interrupt a long or off-track reply. The Stop button only
// appears when an `onCancel` handler is wired (the drawer always
// passes `send.cancel`).
const html = render(
<CoachInput
value="Hello"
onChange={() => {}}
onSubmit={() => {}}
onCancel={() => {}}
disabled
isStreaming
/>,
);
expect(html).toContain('data-slot="coach-input-stop"');
expect(html).not.toContain('data-slot="coach-input-send"');
expect(html).toContain("Stop");
// The Stop control is a plain button, never a submit, so tapping it
// aborts rather than re-firing the form.
const stopTag = html.match(
/<button[^>]*data-slot="coach-input-stop"[^>]*>/,
);
expect(stopTag?.[0]).toContain('type="button"');
});
it("keeps the send button (with spinner) while streaming without onCancel", () => {
// Backwards-compatible fallback: without an `onCancel` the composer
// keeps the legacy disabled-spinner send button.
const html = render(
<CoachInput
value="Hello"
onChange={() => {}}
onSubmit={() => {}}
disabled
isStreaming
/>,
);
expect(html).toContain('data-slot="coach-input-send"');
expect(html).not.toContain('data-slot="coach-input-stop"');
expect(html).toContain("animate-spin");
});
it("renders the localised Stop label under the 'de' locale", () => {
const html = render(
<CoachInput
value="Hallo"
onChange={() => {}}
onSubmit={() => {}}
onCancel={() => {}}
disabled
isStreaming
/>,
"de",
);
expect(html).toContain("Stopp");
});
it("invokes onChange when the parent passes a controlled handler", () => {
// SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly.
@@ -103,6 +103,18 @@ describe("<MessageThread>", () => {
);
});
it("announces the empty-thread hero as a polite live region", () => {
// v1.11.3 D5 — the bespoke empty-state hero stays (gradient avatar),
// but it must carry `role="status"` + `aria-live="polite"` so screen
// readers announce the hint when the thread first mounts.
const html = render(<MessageThread conversation={null} />);
const threadTag = html.match(
/<div[^>]*data-slot="coach-message-thread"[^>]*>/,
);
expect(threadTag?.[0]).toContain('role="status"');
expect(threadTag?.[0]).toContain('aria-live="polite"');
});
it("renders user + assistant bubbles for a persisted conversation", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user"');
@@ -163,9 +163,19 @@ export function CoachDrawer({
// and folds the saved window server-side — "what the rail shows" and
// "what the model receives" cannot drift.
const { data: conversation } = useCoachConversation(currentConversationId);
const send = useSendCoachMessage({
onDone: (resolvedId) => {
setCurrentConversationId(resolvedId);
},
});
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
// Abort any in-flight streamed reply so closing the drawer
// doesn't leave the SSE request running in the background.
send.cancel();
// Reset thread + composer on close so the next open starts on
// the rail's empty hint instead of re-rendering the previous
// conversation by accident.
@@ -174,16 +184,9 @@ export function CoachDrawer({
}
onOpenChange(next);
},
[onOpenChange, setInputValue],
[onOpenChange, send, setInputValue],
);
const { data: conversation } = useCoachConversation(currentConversationId);
const send = useSendCoachMessage({
onDone: (resolvedId) => {
setCurrentConversationId(resolvedId);
},
});
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
@@ -352,6 +355,7 @@ export function CoachDrawer({
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
// v1.4.27 MB3 / CF-30 — the composer mounts on drawer
@@ -7,7 +7,7 @@ import {
useEffect,
useRef,
} from "react";
import { Info, Loader2, Send } from "lucide-react";
import { Info, Loader2, Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -43,6 +43,13 @@ export interface CoachInputProps {
value: string;
onChange: (next: string) => void;
onSubmit: () => void;
/**
* Aborts the in-flight streamed reply. The composer swaps the send
* button for a Stop control while `isStreaming` is set; tapping it
* cancels the SSE request so the user can interrupt a long or
* off-track reply.
*/
onCancel?: () => void;
/** Disabled while a previous reply is still streaming. */
disabled?: boolean;
/** Surfaces the streaming-in-progress affordance on the send button. */
@@ -98,6 +105,7 @@ export function CoachInput({
value,
onChange,
onSubmit,
onCancel,
disabled = false,
isStreaming = false,
inputId = "coach-composer-textarea",
@@ -256,20 +264,42 @@ export function CoachInput({
{t("insights.coach.composerHint")}
</PopoverContent>
</Popover>
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
<span>{t("insights.coach.send")}</span>
</Button>
{isStreaming && onCancel ? (
// While a reply streams, swap the send button for a Stop
// control bound to the abort handler so the user can
// interrupt a long or off-track reply instead of waiting it
// out. The spinner stays inside the Stop affordance so the
// in-progress state is still legible.
<Button
type="button"
size="sm"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
className="gap-1.5"
>
<Square
className="size-3.5 fill-current"
aria-hidden="true"
/>
<span>{t("insights.coach.stop")}</span>
</Button>
) : (
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
<span>{t("insights.coach.send")}</span>
</Button>
)}
</div>
</div>
</form>
@@ -236,6 +236,8 @@ export function MessageThread({
return (
<div
data-slot="coach-message-thread"
role="status"
aria-live="polite"
className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 px-6 text-center"
>
<div
@@ -315,7 +317,7 @@ export function MessageThread({
intentional for clinical-adjacent UI. */}
<p
data-slot="coach-thread-disclaimer"
className="text-muted-foreground mt-auto pt-2 text-[10px] leading-relaxed"
className="text-muted-foreground mt-auto pt-2 text-xs leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
@@ -371,12 +373,15 @@ function ChatBubble({
return (
<div
data-slot="coach-bubble-user"
className="flex items-start justify-end gap-2"
className="flex items-start justify-end gap-2.5"
>
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/12 text-foreground",
"max-w-[80%] rounded-xl rounded-tr-sm border px-3.5 py-2.5",
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
// of the 80% cap so the bubble + avatar together never
// overflow a comfortable width on a narrow phone.
"max-w-[calc(80%-2.625rem)] rounded-xl rounded-tr-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
>
@@ -438,7 +443,7 @@ function ChatBubble({
<Sparkles className="text-background size-3.5" />
)}
</div>
<div className="flex max-w-[80%] flex-col gap-2">
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
@@ -583,7 +588,7 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
return (
<p
data-slot="coach-message-feedback-thanks"
className="text-muted-foreground text-[10px]"
className="text-muted-foreground text-xs"
>
{t("insights.coach.feedbackThanks")}
</p>
@@ -28,6 +28,7 @@
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ThumbsUp, ThumbsDown, Loader2, Check } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
@@ -194,10 +195,14 @@ export function RecommendationFeedback({
// next generation will scope its own invalidation.
},
onError: () => {
// Optimistic-rollback: drop back to default so the user can
// retry. We don't surface a toast — the parent rec card already
// crowded enough; a silent reset is the friendlier UX here.
// Optimistic-rollback: drop back to default so the user can retry.
// v1.11.3 — the POST used to fail silently; the buttons just
// reappeared with no signal that anything went wrong. Surface a
// lightweight error toast so a failed submission is legible while
// keeping the success path quiet (the inline confirmation row is
// signal enough there).
setState("default");
toast.error(t("insights.recommendation.feedbackError"));
},
});
@@ -101,7 +101,7 @@ export function RecommendationsGrid({ recs }: RecommendationsGridProps) {
}
data-stagger-index={index}
role="listitem"
className={`animate-insight-in transition-all md:hover:-translate-y-0.5 md:hover:shadow-lg ${borderClass} rounded-lg border-l-2`}
className={`animate-insight-in transition-all md:hover:-translate-y-0.5 md:hover:shadow-lg ${borderClass} rounded-lg border-l-2 motion-reduce:transition-none motion-reduce:hover:translate-y-0`}
style={{ animationDelay: `${index * STAGGER_INTERVAL_MS}ms` }}
>
<RecommendationCard rec={rec} index={index} />
@@ -16,6 +16,7 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -174,7 +175,14 @@ function IntakeEditDialogBody({
<Button variant="outline" onClick={onClose} disabled={busy}>
{t("medications.detail.intake.edit.cancel")}
</Button>
<Button onClick={() => void save()} disabled={busy} aria-busy={busy || undefined}>
<Button
onClick={() => void save()}
disabled={busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.intake.edit.save")}
</Button>
</DialogFooter>
+64 -21
View File
@@ -168,6 +168,26 @@ export function MedicationCard({
return () => clearInterval(interval);
}, []);
// v1.11.3 C2 — reverse the just-recorded intake via the soft-delete
// route. Surfaced from the success toast's Undo action so a misclicked
// take / skip no longer needs a history dive to correct.
async function undoIntake(eventId: string) {
try {
const res = await fetch(
`/api/medications/${medication.id}/intake/${eventId}`,
{ method: "DELETE" },
);
if (!res.ok) {
toast.error(t("medications.intakeUndoFailed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.intakeUndone"));
} catch {
toast.error(t("medications.intakeUndoFailed"));
}
}
async function recordIntake(skipped: boolean) {
const key = skipped ? "skip" : "take";
setIntakeLoading(key);
@@ -177,29 +197,52 @@ export function MedicationCard({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skipped }),
});
if (res.ok) {
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
),
// v1.11.3 C1 — a failed POST used to clear the spinner silently, so
// the user believed the dose was logged when it was not. Surface the
// failure and never show the success confirmation in that case.
if (!res.ok) {
toast.error(
t("medications.intakeToastFailed", { name: medication.name }),
);
await invalidateKeys(queryClient, medicationDependentKeys);
// v1.8.5 — after a TAKEN dose on a tracking-enabled injection,
// prompt (skippably) for the site. The dialog PATCHes it onto
// the just-created event via the status-toggle route.
if (!skipped && tracksInjection) {
try {
const json = await res.json();
const eventId = json?.data?.id as string | undefined;
if (eventId) setSiteIntakeId(eventId);
} catch {
/* dose recorded; the site prompt is best-effort */
}
}
return;
}
// The POST returns the created event (`apiSuccess(event, 201)`); its
// id drives both the Undo affordance and the optional injection-site
// prompt below.
let eventId: string | undefined;
try {
const json = await res.json();
eventId = json?.data?.id as string | undefined;
} catch {
/* dose recorded; the body is best-effort for the id */
}
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
),
eventId
? {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
}
: undefined,
);
await invalidateKeys(queryClient, medicationDependentKeys);
// v1.8.5 — after a TAKEN dose on a tracking-enabled injection,
// prompt (skippably) for the site. The dialog PATCHes it onto
// the just-created event via the status-toggle route.
if (!skipped && tracksInjection && eventId) {
setSiteIntakeId(eventId);
}
} catch {
toast.error(
t("medications.intakeToastFailed", { name: medication.name }),
);
} finally {
setIntakeLoading(null);
}
@@ -88,7 +88,7 @@ export function WizardStepper({
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']"
onClick={onFirst}
disabled={!firstEnabled}
data-slot="wizard-stepper-first"
@@ -184,7 +184,7 @@ export function WizardStepper({
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']"
onClick={onLast}
disabled={!lastEnabled}
data-slot="wizard-stepper-last"
+1 -1
View File
@@ -948,7 +948,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
{passkeys.map((pk) => (
<li
key={pk.id}
className="bg-card border-border rounded-lg border p-3"
className="bg-card border-border rounded-lg border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
+2 -1
View File
@@ -359,7 +359,8 @@ function ApiTokensCard() {
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
className="text-destructive h-9 w-9"
aria-label={t("settings.tokenRevokeAction")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
+6 -6
View File
@@ -259,8 +259,8 @@ function CsvCard({
const stamp = new Date().toISOString().slice(0, 10);
const filename = `${filenamePrefix}-${stamp}.csv`;
await downloadFromUrl(`${endpoint}${query}`, filename);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
@@ -344,8 +344,8 @@ function MedicationsCsvCard() {
`/api/export/medications${query}`,
`healthlog-medications-${stamp}.csv`,
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
@@ -435,8 +435,8 @@ function FullBackupCard() {
"/api/export/full-backup",
`healthlog-backup-${stamp}.json`,
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
+153 -89
View File
@@ -495,7 +495,13 @@ async function buildCoachSnapshotImpl(
}
const windowDays = windowToDays(window);
const features = await extractFeatures(userId, false, {
// v1.11.3 — kick the feature extraction off as a promise now and await
// it alongside the shared measurement read below. `extractFeatures`
// and the measurement `findMany` are independent (each only needs
// `userId` + the resolved window/sources), so running them
// concurrently shaves a round-trip off the cold path. No block reads
// `features` before the shared await, so the deferral is safe.
const featuresPromise = extractFeatures(userId, false, {
sinceDays: windowDays,
});
@@ -657,9 +663,9 @@ async function buildCoachSnapshotImpl(
(source) => METRIC_TYPES[source] ?? [],
);
const measurementRows =
const measurementRowsPromise =
wantedTypes.length > 0
? await prisma.measurement.findMany({
? prisma.measurement.findMany({
where: {
userId,
type: { in: wantedTypes as never[] },
@@ -677,7 +683,127 @@ async function buildCoachSnapshotImpl(
glucoseContext: true,
},
})
: [];
: Promise.resolve([]);
// v1.11.3 — `extractFeatures` and the shared measurement read are
// mutually independent and both gate the blocks below (every aggregate
// reads `features`; bp/weight/pulse/glucose read `measurementRows`), so
// run the two concurrently and resolve them in a single hop.
const [features, measurementRows] = await Promise.all([
featuresPromise,
measurementRowsPromise,
]);
// v1.11.3 — the remaining cold-path reads are mutually independent:
// the four conditional table reads (mood / compliance / sleep /
// workouts) and the four helper-block reads (GLP-1 / derived /
// trajectory / memory) each consume only `userId`, the window cutoff,
// or the synchronously-derived `derivedProfile` — none reads another's
// result. Fire them all off concurrently now, KEEPING the original
// `wants…` / `sources.has(…)` guards so a disabled source still issues
// no query (the guard yields `null`/`undefined`, never a wasted
// round-trip), then await the batch in one hop. The synchronous block
// assembly further below consumes the resolved values in the original
// order, so provenance and block-registration order are unchanged.
//
// `derivedProfile` is derived from `features.context` (now resolved)
// and feeds the GLP-1 / derived / trajectory / memory readers; it is
// hoisted here so those reads can start immediately.
const derivedSources: CoachScopeSource[] = [
"hrv",
"resting_hr",
"sleep",
"vo2_max",
];
const derivedCtx = features.context;
const derivedProfile: BaselineProfile = {
ageYears: derivedCtx?.ageYears ?? null,
sex:
derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE"
? (derivedCtx.gender as "MALE" | "FEMALE")
: null,
heightCm: derivedCtx?.heightCm ?? null,
};
const derivedActive = derivedSources.some((s) => sources.has(s));
const moodRowsPromise =
wantsMood && features.mood
? prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows from the Coach snapshot.
where: { userId, deletedAt: null, moodLoggedAt: { gte: cutoff } },
orderBy: { moodLoggedAt: "asc" },
select: { moodLoggedAt: true, score: true },
})
: null;
const intakeRowsPromise = wantsCompliance
? prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: { scheduledFor: true, takenAt: true, skipped: true },
})
: null;
const sleepRowsPromise = sources.has("sleep")
? prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" as never,
measuredAt: { gte: additiveCutoff("sleep") },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
})
: null;
const workoutRowsPromise = sources.has("workouts")
? prisma.workout.findMany({
where: { userId, startedAt: { gte: additiveCutoff("workouts") } },
orderBy: { startedAt: "desc" },
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalEnergyKcal: true,
totalDistanceM: true,
avgHeartRate: true,
maxHeartRate: true,
},
})
: null;
const glp1BlockPromise = excludesMedications
? null
: buildGlp1SnapshotBlock(userId, now);
const derivedBlockPromise = derivedActive
? buildDerivedSnapshotBlock(userId, derivedProfile, now)
: null;
const trajectoryBlockPromise = derivedActive
? buildTrajectorySnapshotBlock(userId, derivedProfile, now)
: null;
const memoryBlockPromise = buildCoachMemoryBlock(
userId,
derivedProfile,
now,
coachLocale,
);
const [
moodRows,
intakeRows,
sleepRows,
workoutRows,
glp1Block,
derivedBlock,
trajectoryBlock,
memoryBlock,
] = await Promise.all([
moodRowsPromise,
intakeRowsPromise,
sleepRowsPromise,
workoutRowsPromise,
glp1BlockPromise,
derivedBlockPromise,
trajectoryBlockPromise,
memoryBlockPromise,
]);
const byType = (t: string) =>
measurementRows
@@ -749,15 +875,8 @@ async function buildCoachSnapshotImpl(
counts.pulse = features.pulse.coverage?.count ?? undefined;
registerBlock("pulse", "pulse");
}
if (wantsMood && features.mood) {
// Mood entries live on a separate model. Pull only the recent
// window for the day-level rows + bucket the rest.
const moodRows = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows from the Coach snapshot.
where: { userId, deletedAt: null, moodLoggedAt: { gte: cutoff } },
orderBy: { moodLoggedAt: "asc" },
select: { moodLoggedAt: true, score: true },
});
if (wantsMood && features.mood && moodRows) {
// Mood entries live on a separate model — read in parallel above.
const normalised = moodRows.map((m) => ({
measuredAt: m.moodLoggedAt,
value: m.score,
@@ -779,17 +898,13 @@ async function buildCoachSnapshotImpl(
registerBlock("mood", "mood");
}
if (wantsCompliance) {
if (wantsCompliance && intakeRows) {
// Medication compliance lives outside the structured features
// today — the legacy Coach surface labelled it as "general"
// provenance only. v1.4.20.1 ships a per-day adherence row built
// from the intake-event log so the Coach can answer "did I miss
// my dose on Tuesday?" without inventing the schedule.
const intakeRows = await prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: { scheduledFor: true, takenAt: true, skipped: true },
});
// my dose on Tuesday?" without inventing the schedule. The read is
// issued in parallel above.
if (intakeRows.length > 0) {
// Per-day adherence rate within the recent window. Older days
// collapse into a single weekly bucket.
@@ -958,18 +1073,9 @@ async function buildCoachSnapshotImpl(
// dedicated read of the SLEEP_DURATION rows that carry a non-null
// stage; the duration timeline is built from the same rows summed per
// night (one night = the sum of its per-stage rows).
if (sources.has("sleep")) {
const sleepCutoff = additiveCutoff("sleep");
const sleepRows = await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" as never,
measuredAt: { gte: sleepCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
});
if (sources.has("sleep") && sleepRows) {
// The SLEEP_DURATION rows (with the `sleepStage` column) are read in
// parallel above.
if (sleepRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
@@ -1081,21 +1187,8 @@ async function buildCoachSnapshotImpl(
// energy, distance, avg/max HR) plus a per-sport weekly count + total
// duration/energy rollup for the tail so the prompt stays bounded
// even for a heavy-training account at a long window.
if (sources.has("workouts")) {
const workoutCutoff = additiveCutoff("workouts");
const workoutRows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: workoutCutoff } },
orderBy: { startedAt: "desc" },
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalEnergyKcal: true,
totalDistanceM: true,
avgHeartRate: true,
maxHeartRate: true,
},
});
if (sources.has("workouts") && workoutRows) {
// The workout sessions are read in parallel above.
if (workoutRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
@@ -1161,13 +1254,12 @@ async function buildCoachSnapshotImpl(
// "your medication", never to make recommendations.
// v1.4.36 W3 T2 — gated on `medications` exclusion. When excluded
// we skip the Prisma lookup entirely so the read cost vanishes too.
if (!excludesMedications) {
const glp1Block = await buildGlp1SnapshotBlock(userId, now);
if (glp1Block) {
snapshot.weeklyContext = { glp1: glp1Block };
metrics.add("compliance");
registerBlock("weeklyContext", "compliance");
}
if (!excludesMedications && glp1Block) {
// The GLP-1 block is read in parallel above (null when excluded or
// when the account has no active GLP-1 medication).
snapshot.weeklyContext = { glp1: glp1Block };
metrics.add("compliance");
registerBlock("weeklyContext", "compliance");
}
// v1.4.36 W3 T2 — anthropometrics block (height / age / gender).
@@ -1203,28 +1295,9 @@ async function buildCoachSnapshotImpl(
// contract every surface uses — no recompute. Gated on at least one of
// the signals the composites are built from staying in-scope (HRV /
// resting HR / sleep / VO₂max), so a user who excludes those doesn't see
// the block.
const derivedSources: CoachScopeSource[] = [
"hrv",
"resting_hr",
"sleep",
"vo2_max",
];
const derivedCtx = features.context;
const derivedProfile: BaselineProfile = {
ageYears: derivedCtx?.ageYears ?? null,
sex:
derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE"
? (derivedCtx.gender as "MALE" | "FEMALE")
: null,
heightCm: derivedCtx?.heightCm ?? null,
};
if (derivedSources.some((s) => sources.has(s))) {
const derivedBlock = await buildDerivedSnapshotBlock(
userId,
derivedProfile,
now,
);
// the block. `derivedActive` + `derivedProfile` are resolved up top so
// the derived / trajectory reads can run in the parallel batch.
if (derivedActive) {
if (derivedBlock) {
snapshot.derived = derivedBlock;
metrics.add("hrv");
@@ -1238,12 +1311,8 @@ async function buildCoachSnapshotImpl(
// Coach narrates the range conditionally (system-prompt rule 11 /
// ground rule 16) only when this block is present. Registered under an
// `environment`-cluster source so the soft-cap degrader sheds it FIRST,
// before any clinical cluster, under prompt-budget pressure.
const trajectoryBlock = await buildTrajectorySnapshotBlock(
userId,
derivedProfile,
now,
);
// before any clinical cluster, under prompt-budget pressure. Read in
// the parallel batch above.
if (trajectoryBlock) {
snapshot.trajectory = trajectoryBlock;
registerBlock("trajectory", "skin_temp");
@@ -1260,13 +1329,8 @@ async function buildCoachSnapshotImpl(
// (`environment`, the tail of CLUSTER_PRIORITY) so `degradeToBudget`
// sheds it FIRST under the char cap — before any clinical cluster. The
// builder is fault-isolated per sub-source and returns null when neither
// a narrative nor any band movement is on file.
const memoryBlock = await buildCoachMemoryBlock(
userId,
derivedProfile,
now,
coachLocale,
);
// a narrative nor any band movement is on file. Read in the parallel
// batch above.
if (memoryBlock) {
snapshot.memory = memoryBlock;
// `skin_temp` maps to the `environment` cluster — the lowest priority
+252
View File
@@ -0,0 +1,252 @@
/**
* v1.11.3 — WHOOP body-measurement sync tests (mocked). Covers:
* - `mapBody` maps weight/max-HR/height (m→cm) and nulls absent fields;
* - weight lands as a single overwrite-in-place WEIGHT row (stable externalId);
* - `max_heart_rate` is persisted to WhoopConnection.maxHeartRate;
* - height seeds User.heightCm ONLY when currently null (never overwrites);
* - a collection 403 soft-skips (returns 0, records no failure).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WhoopApiError } from "../response-classifier";
import type { WhoopMeasurementUpsert } from "../sync";
// ── Module mocks ────────────────────────────────────────────────
const {
prismaMock,
getValidTokenMock,
fetchBodyMeasurementMock,
upsertWhoopMeasurementsMock,
markSyncedMock,
recordWhoopSyncFailureMock,
} = vi.hoisted(() => ({
prismaMock: {
whoopConnection: {
update: vi.fn<(...a: unknown[]) => Promise<unknown>>(async () => ({})),
},
user: {
findUnique: vi.fn(),
update: vi.fn<(...a: unknown[]) => Promise<unknown>>(async () => ({})),
},
},
getValidTokenMock: vi.fn(),
fetchBodyMeasurementMock: vi.fn(),
upsertWhoopMeasurementsMock: vi.fn<(...a: unknown[]) => Promise<number>>(
async () => 1,
),
markSyncedMock: vi.fn<(...a: unknown[]) => Promise<void>>(async () => {}),
recordWhoopSyncFailureMock: vi.fn<(...a: unknown[]) => Promise<void>>(
async () => {},
),
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => null,
annotate: () => {},
}));
vi.mock("../client", async (orig) => {
const actual = await orig<typeof import("../client")>();
return {
...actual,
fetchBodyMeasurement: (...a: unknown[]) => fetchBodyMeasurementMock(...a),
};
});
vi.mock("../sync", async (orig) => {
const actual = await orig<typeof import("../sync")>();
return {
...actual,
getValidToken: (...a: unknown[]) => getValidTokenMock(...a),
upsertWhoopMeasurements: (...a: unknown[]) =>
upsertWhoopMeasurementsMock(...a),
markSynced: (...a: unknown[]) => markSyncedMock(...a),
recordWhoopSyncFailure: (...a: unknown[]) =>
recordWhoopSyncFailureMock(...a),
};
});
import { mapBody } from "../client";
import { syncUserBody, WHOOP_BODY_WEIGHT_EXTERNAL_ID } from "../sync-body";
const TOKEN = { accessToken: "acc", connection: { id: "c1", whoopUserId: "9" } };
beforeEach(() => {
vi.clearAllMocks();
getValidTokenMock.mockResolvedValue(TOKEN);
upsertWhoopMeasurementsMock.mockResolvedValue(1);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("mapBody", () => {
it("maps weight, max-HR, and height (m→cm)", () => {
const out = mapBody({
weight_kilogram: 81.234,
max_heart_rate: 191.6,
height_meter: 1.83,
});
expect(out.weightKg).toBe(81.23);
// max heart rate rounds to an integer (column is Int).
expect(out.maxHeartRate).toBe(192);
expect(out.heightCm).toBe(183);
});
it("nulls every field that WHOOP omits", () => {
expect(mapBody({})).toEqual({
weightKg: null,
maxHeartRate: null,
heightCm: null,
});
});
it("nulls weight when absent but keeps the present fields", () => {
const out = mapBody({ max_heart_rate: 180 });
expect(out.weightKg).toBeNull();
expect(out.maxHeartRate).toBe(180);
expect(out.heightCm).toBeNull();
});
});
describe("syncUserBody — weight overwrite", () => {
it("upserts weight against the stable externalId (no duplicate per sync)", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 });
const imported = await syncUserBody("user1");
expect(imported).toBe(1);
expect(upsertWhoopMeasurementsMock).toHaveBeenCalledTimes(1);
const readings = upsertWhoopMeasurementsMock.mock
.calls[0]![1] as WhoopMeasurementUpsert[];
expect(readings).toHaveLength(1);
expect(readings[0]).toMatchObject({
type: "WEIGHT",
value: 80,
unit: "kg",
externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID,
});
// A second sync re-uses the SAME externalId — the upsert collapses it.
await syncUserBody("user1");
const readings2 = upsertWhoopMeasurementsMock.mock
.calls[1]![1] as WhoopMeasurementUpsert[];
expect(readings2[0]!.externalId).toBe(readings[0]!.externalId);
});
it("does not upsert a weight row when WHOOP omits weight", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ max_heart_rate: 185 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 });
const imported = await syncUserBody("user1");
expect(imported).toBe(0);
expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled();
});
});
describe("syncUserBody — max heart rate", () => {
it("persists max_heart_rate to WhoopConnection.maxHeartRate", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ max_heart_rate: 186 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 });
await syncUserBody("user1");
expect(prismaMock.whoopConnection.update).toHaveBeenCalledWith({
where: { userId: "user1" },
data: { maxHeartRate: 186 },
});
});
it("skips the connection write when max_heart_rate is absent", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 });
await syncUserBody("user1");
expect(prismaMock.whoopConnection.update).not.toHaveBeenCalled();
});
});
describe("syncUserBody — height seed (only when null)", () => {
it("writes User.heightCm when it is currently null", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ height_meter: 1.8 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: null });
await syncUserBody("user1");
expect(prismaMock.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: { heightCm: 180 },
});
});
it("does NOT overwrite a user-set height", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ height_meter: 1.9 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 175 });
await syncUserBody("user1");
expect(prismaMock.user.update).not.toHaveBeenCalled();
});
it("never mints a Measurement for height", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ height_meter: 1.8 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: null });
const imported = await syncUserBody("user1");
// No weight present → nothing imported as a Measurement.
expect(imported).toBe(0);
expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled();
});
});
describe("syncUserBody — tier degradation", () => {
it("soft-skips a collection 403 (returns 0, records no failure)", async () => {
fetchBodyMeasurementMock.mockRejectedValue(
new WhoopApiError({
verb: "fetchBodyMeasurement",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
}),
);
const imported = await syncUserBody("user1");
expect(imported).toBe(0);
expect(recordWhoopSyncFailureMock).not.toHaveBeenCalled();
expect(markSyncedMock).not.toHaveBeenCalled();
});
it("rethrows a 401 (genuine reauth) instead of soft-skipping", async () => {
fetchBodyMeasurementMock.mockRejectedValue(
new WhoopApiError({
verb: "fetchBodyMeasurement",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
}),
);
// A 401 is not a per-class tier gate: it propagates so the connection
// parks (vs the 403 case above, which returns 0). The catch delegates to
// the shared `handleCollectionFetchError` (sync.ts), whose "401 records a
// reauth failure + rethrows" contract is unit-tested in sync.test.ts; here
// we assert the resource sync surfaces the error and writes nothing.
await expect(syncUserBody("user1")).rejects.toThrow();
expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled();
});
it("returns 0 without fetching when there is no valid token", async () => {
getValidTokenMock.mockResolvedValue(null);
const imported = await syncUserBody("user1");
expect(imported).toBe(0);
expect(fetchBodyMeasurementMock).not.toHaveBeenCalled();
});
});
+81
View File
@@ -87,10 +87,13 @@ vi.mock("@/lib/logging/context", () => ({
import {
getValidToken,
incrementalStart,
isCollectionForbidden,
upsertWhoopMeasurements,
WHOOP_DEFAULT_OVERLAP_MS,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
} from "../sync";
import { WhoopApiError } from "../response-classifier";
import { syncUserRecovery } from "../sync-recovery";
beforeEach(() => {
vi.clearAllMocks();
@@ -247,3 +250,81 @@ describe("upsertWhoopMeasurements — idempotent upsert", () => {
expect(a).toEqual(b);
});
});
describe("isCollectionForbidden — tier degradation gate", () => {
it("is true only for a WhoopApiError carrying HTTP 403", () => {
const forbidden = new WhoopApiError({
verb: "fetchRecoveries",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
});
expect(isCollectionForbidden(forbidden)).toBe(true);
});
it("is false for a 401 (genuine token reject → connection-wide reauth)", () => {
const unauthorized = new WhoopApiError({
verb: "fetchRecoveries",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
});
expect(isCollectionForbidden(unauthorized)).toBe(false);
});
it("is false for a non-WhoopApiError", () => {
expect(isCollectionForbidden(new Error("network down"))).toBe(false);
expect(isCollectionForbidden("boom")).toBe(false);
});
});
describe("per-resource 403 soft-skip vs reauth", () => {
beforeEach(() => {
prismaMock.whoopConnection.findUnique.mockResolvedValue({
id: "conn1",
whoopUserId: "42",
accessToken: "enc(live-access)",
refreshToken: "enc(live-refresh)",
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
lastSyncedAt: new Date("2026-06-01T00:00:00Z"),
});
prismaMock.whoopConnection.update.mockResolvedValue({});
});
it("a collection 403 soft-skips: returns 0, records NO failure (connection stays connected)", async () => {
fetchRecoveriesMock.mockRejectedValue(
new WhoopApiError({
verb: "fetchRecoveries",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
}),
);
const imported = await syncUserRecovery("user1");
expect(imported).toBe(0);
// No failure recorded → recordSyncFailure never parks the row at
// error_reauth, so the next syncUserWhoop does not short-circuit.
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("a collection 401 still records a reauth failure and rethrows", async () => {
fetchRecoveriesMock.mockRejectedValue(
new WhoopApiError({
verb: "fetchRecoveries",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
}),
);
await expect(syncUserRecovery("user1")).rejects.toThrow();
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
integration: "whoop",
kind: "reauth_required",
}),
);
});
});
+51
View File
@@ -610,6 +610,51 @@ export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
];
}
/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */
const M_TO_CM = 100;
/**
* The three destinations a WHOOP body-measurement object fans out to. Unlike
* the collection mappers it does NOT emit `MappedMeasurement[]` directly,
* because only `weight` lands in `Measurement` — `maxHeartRate` is a profile
* constant on `WhoopConnection` and `heightCm` is a one-time `User` profile
* seed (written only when the user has no height yet). The sync layer routes
* each piece to its own table.
*/
export interface MappedBodyMeasurement {
/** Self-reported profile weight in kg, or null when WHOOP omits it. */
weightKg: number | null;
/** Profile max heart rate in bpm, or null when WHOOP omits it. */
maxHeartRate: number | null;
/** Profile height converted m→cm, or null when WHOOP omits it. */
heightCm: number | null;
}
/**
* Map a WHOOP body-measurement object onto its three destinations. The body
* measurement is a single self-reported profile value, not a timestamped
* reading, so the sync layer stamps the weight row's `measuredAt` with the
* fetch time. Every field is optional on the wire — an absent field maps to
* null and the sync layer skips it.
*/
export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement {
// WHOOP body fields are self-reported profile data. Guard against absent,
// non-finite (NaN/Infinity), or non-positive values so a garbage reading
// never seeds a real WEIGHT measurement or a `User.heightCm` of 0
// (v1.11.3 QA L1).
const positive = (n: number | undefined): n is number =>
typeof n === "number" && Number.isFinite(n) && n > 0;
return {
weightKg: positive(b.weight_kilogram) ? round2(b.weight_kilogram) : null,
maxHeartRate: positive(b.max_heart_rate)
? Math.round(b.max_heart_rate)
: null,
heightCm: positive(b.height_meter)
? round2(b.height_meter * M_TO_CM)
: null,
};
}
/**
* Field→Measurement mapping table (mirror of `mapping.md`). Documents which
* WHOOP source field becomes which MeasurementType + unit. Used as the
@@ -685,4 +730,10 @@ export const WHOOP_FIELD_MAP: Record<
unit: "bpm",
note: "profile constant — stored on the connection, not a Measurement",
},
"body.height_meter": {
type: "User.heightCm",
unit: "cm",
factor: M_TO_CM,
note: "profile seed — written to User.heightCm only when it is null, never as a Measurement",
},
};
+15 -1
View File
@@ -59,7 +59,21 @@ fetch + the score→energy conversion factor (`KJ_TO_KCAL`).
## Body / profile (single objects, no pagination)
Ingested by `sync-body.ts` (`syncUserBody`), wired into the `syncUserWhoop`
loop + the backfill. The body endpoint is a single object, not a paginated
collection.
| Source field | Destination | Unit | Note |
|---|---|---|---|
| `body.weight_kilogram` | `WEIGHT` | kg | Picker ranks a real scale above WHOOP. |
| `body.weight_kilogram` | `WEIGHT` | kg | Source = WHOOP, STABLE externalId `whoop:body:weight` with overwrite semantics — a single self-reported profile value, not a time series, so a re-sync updates the same row rather than accumulating duplicates. `measuredAt` = the fetch time. Picker ranks a real scale above WHOOP. |
| `body.max_heart_rate` | `WhoopConnection.maxHeartRate` | bpm | Profile constant — stored on the connection, not a `Measurement`. |
| `body.height_meter` | `User.heightCm` | cm | Profile seed (m→cm). Written ONLY when `User.heightCm` is currently null — never overwrites a user-set height, never minted as a `Measurement`. |
## Blood pressure — watch-only
Blood pressure is API-invisible: the WHOOP public developer API (v2) exposes no
BP field, endpoint, or scope. WHOOP's Blood Pressure Insights are WHOOP MG
hardware + WHOOP Life membership + cuff-calibrated and live only in the WHOOP
app. No server-side code. If WHOOP ever ships an API surface it reuses the
existing `BLOOD_PRESSURE_SYS` / `BLOOD_PRESSURE_DIA` types — no new enum, no
migration. Re-check developer.whoop.com/docs/api-changelog periodically.
+112
View File
@@ -0,0 +1,112 @@
/**
* WHOOP body-measurement sync. The body endpoint is a single object (not a
* paginated collection): one self-reported profile snapshot carrying weight,
* max heart rate, and height. It fans out to three destinations:
*
* - `weight_kilogram` → a `WEIGHT` `Measurement` (source = WHOOP) keyed on a
* STABLE externalId (`whoop:body:weight`). Because the profile weight is a
* single value rather than a time series, the externalId never carries the
* fetch time — a re-sync overwrites the same row in place rather than
* accumulating a duplicate per poll. `measuredAt` is the fetch time so the
* read-time source-priority picker (a real scale outranks WHOOP) and the
* trend view treat it as "as of now".
* - `max_heart_rate` → `WhoopConnection.maxHeartRate` (a profile constant,
* not a time series — lives on the connection row, not a `Measurement`).
* - `height_meter` → `User.heightCm`, converted m→cm, written ONLY when the
* user has no height yet. A user-set height is never overwritten, and
* height is never minted as a `Measurement`.
*
* Every write is field-by-field and idempotent across reruns. A per-resource
* 403 soft-skips this data class (returns 0, leaves the connection connected)
* rather than parking the whole integration — see `isCollectionForbidden`.
*/
import { fetchBodyMeasurement, mapBody } from "./client";
import {
getValidToken,
handleCollectionFetchError,
markSynced,
upsertWhoopMeasurements,
type WhoopMeasurementUpsert,
} from "./sync";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
/** Stable externalId for the single WHOOP profile weight row (overwrite). */
export const WHOOP_BODY_WEIGHT_EXTERNAL_ID = "whoop:body:weight";
/**
* The body measurement is a single profile snapshot — there is no incremental
* window to seek, so this sync ignores `fullSync` (the incremental vs backfill
* distinction the collection syncs honour). The `opts` parameter is accepted to
* keep the `syncUserWhoop` loop signature uniform.
*/
export async function syncUserBody(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<number> {
void opts;
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
let body: Awaited<ReturnType<typeof fetchBodyMeasurement>>;
try {
body = await fetchBodyMeasurement(tokenInfo.accessToken);
} catch (err) {
return handleCollectionFetchError("body", userId, err);
}
const mapped = mapBody(body);
const measuredAt = new Date();
// Weight → a single overwrite-in-place WEIGHT Measurement.
let imported = 0;
if (mapped.weightKg !== null) {
const reading: WhoopMeasurementUpsert = {
type: "WEIGHT",
value: mapped.weightKg,
unit: "kg",
measuredAt,
externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID,
};
imported += await upsertWhoopMeasurements(userId, [reading]);
}
// Max heart rate → WhoopConnection.maxHeartRate (profile constant).
if (mapped.maxHeartRate !== null) {
try {
await prisma.whoopConnection.update({
where: { userId },
data: { maxHeartRate: mapped.maxHeartRate },
});
} catch (err) {
getEvent()?.addWarning(
`whoop: failed to persist maxHeartRate for ${userId}: ${err}`,
);
}
}
// Height → User.heightCm, only when the user has no height yet. Never
// overwrite a user-set value; never mint a Measurement.
if (mapped.heightCm !== null) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { heightCm: true },
});
if (user && user.heightCm === null) {
await prisma.user.update({
where: { id: userId },
data: { heightCm: mapped.heightCm },
});
}
} catch (err) {
getEvent()?.addWarning(
`whoop: failed to seed heightCm for ${userId}: ${err}`,
);
}
}
await markSynced(userId);
return imported;
}
+2 -3
View File
@@ -9,8 +9,8 @@ import { fetchCycles, mapCycle } from "./client";
import {
getValidToken,
incrementalStart,
handleCollectionFetchError,
markSynced,
recordWhoopSyncFailure,
upsertWhoopMeasurements,
type WhoopMeasurementUpsert,
} from "./sync";
@@ -37,8 +37,7 @@ export async function syncUserCycle(
try {
records = await fetchCycles(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
return handleCollectionFetchError("cycle", userId, err);
}
const readings: WhoopMeasurementUpsert[] = [];
+2 -3
View File
@@ -13,8 +13,8 @@ import { fetchRecoveries, mapRecovery } from "./client";
import {
getValidToken,
incrementalStart,
handleCollectionFetchError,
markSynced,
recordWhoopSyncFailure,
upsertWhoopMeasurements,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
type WhoopMeasurementUpsert,
@@ -43,8 +43,7 @@ export async function syncUserRecovery(
try {
records = await fetchRecoveries(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
return handleCollectionFetchError("recovery", userId, err);
}
const readings: WhoopMeasurementUpsert[] = [];
+2 -3
View File
@@ -11,8 +11,8 @@ import { fetchSleeps, mapSleep } from "./client";
import {
getValidToken,
incrementalStart,
handleCollectionFetchError,
markSynced,
recordWhoopSyncFailure,
upsertWhoopMeasurements,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
type WhoopMeasurementUpsert,
@@ -41,8 +41,7 @@ export async function syncUserSleep(
try {
records = await fetchSleeps(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
return handleCollectionFetchError("sleep", userId, err);
}
const readings: WhoopMeasurementUpsert[] = [];
+2 -3
View File
@@ -17,8 +17,8 @@ import { fetchWorkouts, KJ_TO_KCAL } from "./client";
import {
getValidToken,
incrementalStart,
handleCollectionFetchError,
markSynced,
recordWhoopSyncFailure,
} from "./sync";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
@@ -52,8 +52,7 @@ export async function syncUserWorkout(
try {
records = await fetchWorkouts(tokenInfo.accessToken, { start });
} catch (err) {
await recordWhoopSyncFailure(userId, err);
throw err;
return handleCollectionFetchError("workout", userId, err);
}
let imported = 0;
+36
View File
@@ -43,6 +43,40 @@ import {
type WhoopClassification,
} from "./response-classifier";
/**
* True when a caught error is a per-resource collection 403 (forbidden). A 403
* on a single data class is a tier/scope gate on THAT class — the right
* response is to soft-skip the class and keep the connection connected, NOT to
* park the whole integration at `error_reauth`. Reserve connection-wide reauth
* for a 401 (token rejected) and for a 403 on the token-refresh / profile path
* (a genuine grant revoke), which run outside the per-resource catch blocks.
*/
export function isCollectionForbidden(err: unknown): boolean {
return err instanceof WhoopApiError && err.httpStatus === 403;
}
/**
* Single-source the per-resource collection-fetch error handling. A 403 on one
* data class soft-skips it (warn + return 0) so sibling resources still sync;
* anything else records a classified sync failure and rethrows. Call as
* `return handleCollectionFetchError("recovery", userId, err)` from a resource
* sync's catch block.
*/
export async function handleCollectionFetchError(
resource: string,
userId: string,
err: unknown,
): Promise<number> {
if (isCollectionForbidden(err)) {
getEvent()?.addWarning(
`whoop ${resource} sync skipped for ${userId}: collection 403 (soft-skip)`,
);
return 0;
}
await recordWhoopSyncFailure(userId, err);
throw err;
}
/** Refresh the access token this many ms before `tokenExpiresAt`. */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
@@ -278,6 +312,7 @@ export async function syncUserWhoop(
const { syncUserSleep } = await import("./sync-sleep");
const { syncUserCycle } = await import("./sync-cycle");
const { syncUserWorkout } = await import("./sync-workout");
const { syncUserBody } = await import("./sync-body");
let total = 0;
let anyFailed = false;
@@ -286,6 +321,7 @@ export async function syncUserWhoop(
syncUserSleep,
syncUserCycle,
syncUserWorkout,
syncUserBody,
]) {
try {
total += await fn(userId, opts);