mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
38 files changed
+4192
-3049
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+712
-697
File diff suppressed because it is too large.
Load diff
+720
-705
File diff suppressed because it is too large.
Load diff
+718
-703
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user