From 657f2323a356692a2fba598ba97b6795ca64c154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:52:52 +0200 Subject: [PATCH] fix(sleep,mood): pick the richest writer per night and show the whole note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sleep nights collapse per writer instead of per source: several HealthKit apps land behind the same source value, so a phone's coarse awake and in-bed blocks could blend into or fully constitute a night whose stages another writer had recorded. The winning writer per session is the one with the most distinct granular stages, then the user's source ladder, and its rows are kept whole. The device type is threaded through every sleep read including the stage stacked bar, and the sync overlap for late-arriving nights widens from one day to seven so a night scored after the cursor passed is fetched on the next tick — assembly is read-time, so affected nights heal without manual repair. Mood notes expand to their full text in the list on desktop and touch, replacing the hover-only tooltip, and the mood CSV export carries a note column. --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + src/app/api/analytics/route.ts | 1 + src/app/api/measurements/route.ts | 3 + src/app/api/measurements/series/route.ts | 3 + src/app/api/sleep/night/route.ts | 3 + .../mood-notes-timeline-removed.test.ts | 9 +- .../__tests__/mood-note-fulltext.test.tsx | 119 ++++++++++++ src/components/mood/mood-list.tsx | 83 +++++--- .../analytics/__tests__/sleep-night.test.ts | 153 +++++++++++++++ src/lib/analytics/sleep-night.ts | 181 ++++++++++++------ src/lib/analytics/summaries-slice.ts | 10 +- src/lib/export.ts | 6 + src/lib/whoop/sync.ts | 15 +- 18 files changed, 548 insertions(+), 86 deletions(-) create mode 100644 src/components/mood/__tests__/mood-note-fulltext.test.tsx diff --git a/messages/de.json b/messages/de.json index 8efde3a24..b35371b88 100644 --- a/messages/de.json +++ b/messages/de.json @@ -562,6 +562,8 @@ "nextPage": "Nächste Seite", "loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.", "note": "Notiz", + "noteCollapse": "Notiz einklappen", + "noteExpand": "Notiz vollständig anzeigen", "notePlaceholder": "Notiz dazu, wie du dich fühlst…", "tagPicker": "Tags", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Bereits in der Selbstauskunft enthalten", "failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen." }, + "rememberMessage": { + "action": "Merken", + "done": "Gemerkt — in deiner Selbstauskunft gespeichert", + "duplicate": "Schon in deiner Selbstauskunft enthalten", + "failed": "Merken fehlgeschlagen. Bitte erneut versuchen." + }, "guided": { "offerTitle": "Der Coach hat {count} Rückfragen an dich", "offerTitleOne": "Der Coach hat eine Rückfrage an dich", diff --git a/messages/en.json b/messages/en.json index db4e7e809..af05ebee5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -562,6 +562,8 @@ "nextPage": "Next page", "loginRequired": "Please sign in to view mood entries.", "note": "Note", + "noteCollapse": "Collapse note", + "noteExpand": "Show full note", "notePlaceholder": "Add a note about how you're feeling…", "tagPicker": "Tags", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Already part of your self-context", "failed": "Could not add. Please try again." }, + "rememberMessage": { + "action": "Remember", + "done": "Saved to your self-context", + "duplicate": "Already part of your self-context", + "failed": "Could not save. Please try again." + }, "guided": { "offerTitle": "The Coach has {count} follow-up questions for you", "offerTitleOne": "The Coach has a follow-up question for you", diff --git a/messages/es.json b/messages/es.json index fed72c593..869f354f4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -562,6 +562,8 @@ "nextPage": "Página siguiente", "loginRequired": "Inicia sesión para ver tus estados de ánimo.", "note": "Nota", + "noteCollapse": "Contraer nota", + "noteExpand": "Mostrar nota completa", "notePlaceholder": "Añade una nota sobre cómo te sientes…", "tagPicker": "Etiquetas", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Ya está en tu información personal", "failed": "No se pudo añadir. Inténtalo de nuevo." }, + "rememberMessage": { + "action": "Recordar", + "done": "Guardado en tu información personal", + "duplicate": "Ya está en tu información personal", + "failed": "No se pudo guardar. Inténtalo de nuevo." + }, "guided": { "offerTitle": "El Coach tiene {count} preguntas para ti", "offerTitleOne": "El Coach tiene una pregunta para ti", diff --git a/messages/fr.json b/messages/fr.json index 0ee754a1d..8d6eda75c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -562,6 +562,8 @@ "nextPage": "Page suivante", "loginRequired": "Connecte-toi pour voir tes entrées d’humeur.", "note": "Note", + "noteCollapse": "Réduire la note", + "noteExpand": "Afficher la note complète", "notePlaceholder": "Ajoutez une note sur votre ressenti…", "tagPicker": "Étiquettes", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Déjà présent dans vos informations personnelles", "failed": "Échec de l’ajout. Veuillez réessayer." }, + "rememberMessage": { + "action": "Mémoriser", + "done": "Enregistré dans vos informations personnelles", + "duplicate": "Déjà présent dans vos informations personnelles", + "failed": "Échec de l’enregistrement. Veuillez réessayer." + }, "guided": { "offerTitle": "Le Coach a {count} questions pour vous", "offerTitleOne": "Le Coach a une question pour vous", diff --git a/messages/it.json b/messages/it.json index de35bd3df..9162e2ef0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -562,6 +562,8 @@ "nextPage": "Pagina successiva", "loginRequired": "Accedi per vedere le voci dell’umore.", "note": "Nota", + "noteCollapse": "Comprimi nota", + "noteExpand": "Mostra nota completa", "notePlaceholder": "Aggiungi una nota su come ti senti…", "tagPicker": "Etichette", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Già presente nelle tue informazioni personali", "failed": "Aggiunta non riuscita. Riprova." }, + "rememberMessage": { + "action": "Memorizza", + "done": "Salvato nelle tue informazioni personali", + "duplicate": "Già presente nelle tue informazioni personali", + "failed": "Salvataggio non riuscito. Riprova." + }, "guided": { "offerTitle": "Il Coach ha {count} domande per te", "offerTitleOne": "Il Coach ha una domanda per te", diff --git a/messages/pl.json b/messages/pl.json index 3787a51e6..f1bdaca93 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -562,6 +562,8 @@ "nextPage": "Następna strona", "loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.", "note": "Notatka", + "noteCollapse": "Zwiń notatkę", + "noteExpand": "Pokaż całą notatkę", "notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…", "tagPicker": "Tagi", "tagCategory": { @@ -2042,6 +2044,12 @@ "duplicate": "Już znajduje się w informacjach o sobie", "failed": "Nie udało się dodać. Spróbuj ponownie." }, + "rememberMessage": { + "action": "Zapamiętaj", + "done": "Zapisano w informacjach o sobie", + "duplicate": "Już znajduje się w informacjach o sobie", + "failed": "Nie udało się zapisać. Spróbuj ponownie." + }, "guided": { "offerTitle": "Coach ma {count} pytania do Ciebie", "offerTitleOne": "Coach ma pytanie do Ciebie", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 821671dfb..e706750fc 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -489,6 +489,7 @@ async function computeSleepStageBreakdown( measuredAt: true, sleepStage: true, source: true, + deviceType: true, }, }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index e4ae2fcc5..4490aaa3d 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -704,6 +704,9 @@ async function sleepListResponse( measuredAt: true, sleepStage: true, source: true, + // Writer-level collapse: two HealthKit apps behind one source (watch + // stages vs phone in-bed) must not blend into one night. + deviceType: true, }, }); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 91a0d7549..620092d55 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -222,6 +222,9 @@ export const GET = apiHandler(async (request: NextRequest) => { measuredAt: true, sleepStage: true, source: true, + // Writer-level collapse: two HealthKit apps behind one source + // (watch stages vs phone in-bed) must not blend into one night. + deviceType: true, }, }); unit = "h"; diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index fa9c6a863..0bcdee635 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -105,6 +105,9 @@ export const GET = apiHandler(async (request: NextRequest) => { measuredAt: true, sleepStage: true, source: true, + // Writer-level collapse: two HealthKit apps behind one source (watch + // stages vs phone in-bed) must not blend into one night. + deviceType: true, }, }); diff --git a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts index 11eb0d561..cf734acb4 100644 --- a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts +++ b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts @@ -60,8 +60,13 @@ describe("mood entries table — long-note truncation", () => { expect(src).toContain("max-w-[18rem]"); }); - it("clamps the note and exposes the full text via a tooltip", () => { + it("clamps the note by default and exposes the full text via the row toggle", () => { + // v1.16.8 — the desktop hover tooltip is replaced by an expandable + // toggle (works on touch, preserves line breaks via pre-wrap). The + // default presentation stays clamped so the table keeps its density. expect(src).toContain("line-clamp-2"); - expect(src).toContain("TooltipContent"); + expect(src).toContain("mood-note-toggle"); + expect(src).toContain("whitespace-pre-wrap"); + expect(src).not.toContain("TooltipContent"); }); }); diff --git a/src/components/mood/__tests__/mood-note-fulltext.test.tsx b/src/components/mood/__tests__/mood-note-fulltext.test.tsx new file mode 100644 index 000000000..66320ca88 --- /dev/null +++ b/src/components/mood/__tests__/mood-note-fulltext.test.tsx @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.16.8 — full-text mood notes on the management list. The note used to be + * readable in full only through the edit sheet (desktop hover tooltip aside, + * unreachable on touch). Each row now carries a toggle that expands the + * clamped two-line preview into the complete text, line breaks preserved, + * rendered as plain React text children (no markup interpretation). + * + * Project convention: SSR-only component tests (`renderToStaticMarkup`) plus + * source-string structural assertions for the interactive branch. + */ + +const LONG_NOTE = + "Erster Absatz der Notiz mit etwas längerem Text, der über die Vorschau hinausgeht.\n" + + "Zweiter Absatz mit weiteren Details, die nur in der Vollansicht lesbar sind."; + +const baseEntries = [ + { + id: "e-1", + date: "2026-05-09", + mood: "GUT", + score: 4, + tags: ["work"], + tagKeys: [], + note: LONG_NOTE, + source: "MANUAL", + moodLoggedAt: "2026-05-09T20:00:00.000Z", + }, + { + id: "e-2", + date: "2026-05-08", + mood: "OKAY", + score: 3, + tags: [], + tagKeys: [], + note: null, + source: "MANUAL", + moodLoggedAt: "2026-05-08T20:00:00.000Z", + }, +]; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/mood", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { entries: baseEntries, meta: { total: 2 } }, + isLoading: false, + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "testuser", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodList } from "../mood-list"; + +function render(locale: "en" | "de" = "de") { + return renderToStaticMarkup( + + + , + ); +} + +describe("MoodList — full-text note affordance", () => { + it("renders the note as a collapsed toggle with the COMPLETE text in the DOM", () => { + const html = render("de"); + // One toggle per surface (desktop table + mobile card) for the noted row; + // the note-less row renders none. + const toggles = html.match(/data-testid="mood-note-toggle"/g); + expect(toggles?.length).toBe(2); + // Collapsed by default: clamped preview + expand affordance label. + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("line-clamp-2"); + expect(html).toContain("Notiz vollständig anzeigen"); + // The complete text is in the row (the clamp is visual), both paragraphs. + expect(html).toContain("Erster Absatz der Notiz"); + expect(html).toContain("Zweiter Absatz mit weiteren Details"); + }); + + it("localises the affordance label", () => { + const html = render("en"); + expect(html).toContain("Show full note"); + }); + + it("expanded branch preserves line breaks and renders plain text children", () => { + const src = readFileSync( + join(process.cwd(), "src/components/mood/mood-list.tsx"), + "utf8", + ); + // The expanded branch must keep the author's line breaks… + expect(src).toContain("whitespace-pre-wrap"); + // …flip the collapse label… + expect(src).toContain('t("mood.noteCollapse")'); + expect(src).toContain('t("mood.noteExpand")'); + // …and never interpret the note as markup. + expect(src).not.toContain("dangerouslySetInnerHTML"); + }); +}); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index e550e326b..e16372269 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -29,12 +29,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { @@ -140,6 +134,15 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { // v1.15.13 — page-scoped multi-select selection (current page ids only). const [selectedIds, setSelectedIds] = useState>(() => new Set()); + // v1.16.8 — per-entry note expansion. Notes were readable in full only + // through the edit sheet (desktop hover tooltip aside); the row now + // toggles between the clamped preview and the complete text. + const [expandedNoteIds, setExpandedNoteIds] = useState>( + () => new Set(), + ); + const toggleNote = (id: string) => + setExpandedNoteIds((prev) => toggleId(prev, id)); + const [editing, setEditing] = useState(null); const [editMood, setEditMood] = useState(""); const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({ @@ -599,20 +602,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { : "-"} {entry.note && ( - - - -

- {entry.note} -

-
- -

- {entry.note} -

-
-
-
+ toggleNote(entry.id)} + /> )} @@ -710,9 +704,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {

)} {entry.note && ( -

- {entry.note} -

+ toggleNote(entry.id)} + /> )} @@ -998,6 +994,47 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ); } +/** + * v1.16.8 — expandable free-text note. Collapsed: a two-line clamped + * preview. Expanded: the COMPLETE text, line breaks preserved, rendered as + * plain text children. The whole block is one toggle button so the full + * note is readable in place — previously the only full-text surface was + * the edit sheet (plus a hover tooltip on desktop, unreachable on touch). + */ +function MoodNoteText({ + note, + expanded, + onToggle, +}: { + note: string; + expanded: boolean; + onToggle: () => void; +}) { + const { t } = useTranslations(); + return ( + + ); +} + /** * v1.15.13 — render a `MoodEntry.source` value with its localized label. * MANUAL is shown via the source filter only (a manual row carries no diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index af5875de2..203128283 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -669,3 +669,156 @@ describe("reconstructSleepSessions total-safety (A5)", () => { expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow(); }); }); + +/** Build a stage row tagged with a source AND a writer device-type. */ +function writerRow( + iso: string, + stage: SleepStage | null, + minutes: number, + source: MeasurementSource, + deviceType: string | null, +): SleepStageRow { + return { + measuredAt: new Date(iso), + sleepStage: stage, + value: minutes, + source, + deviceType, + }; +} + +describe("per-night writer richness pick", () => { + it("a coarse awake-heavy source never masks the source that knows the stages", () => { + // The reported night: one source contributes a huge AWAKE block plus a + // sliver of bare ASLEEP (a phone/in-bed detection mis-scoring the night), + // the other carries the full hypnogram. The stage-bearing source must + // win the night — the headline is NOT 400 awake minutes with no phases. + const rows: SleepStageRow[] = [ + // Coarse export: 22:50 → 06:35 local, awake-dominant. + srcRow("2026-06-04T01:00:00.000Z", "IN_BED", 250, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 400, "APPLE_HEALTH"), + srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 30, "APPLE_HEALTH"), + // Detailed stages for the SAME night. + srcRow("2026-06-04T01:30:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 35, "WHOOP"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.CORE).toBe(240); + expect(nights[0].stages.DEEP).toBe(80); + expect(nights[0].stages.REM).toBe(90); + // The winner's own awake minutes survive; the coarse 400 must not. + expect(nights[0].awakeMinutes).toBe(35); + expect(nights[0].asleepMinutes).toBe(410); + + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("WHOOP"); + expect(sessions[0].stages.AWAKE).toBe(35); + }); + + it("separates writer apps behind ONE source: phone in-bed must not blend into watch stages", () => { + // Several HealthKit apps write under source = APPLE_HEALTH. The watch + // carries the granular partition; the phone's bedtime detection writes a + // parallel IN_BED + a long AWAKE block. Collapsing per source would blend + // both writers and inflate the night's awake total — the device-type + // refinement must keep only the stage-bearing writer. + const rows: SleepStageRow[] = [ + writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH", "phone"), + writerRow("2026-06-04T00:35:00.000Z", "ASLEEP", 200, "APPLE_HEALTH", "phone"), + writerRow("2026-06-04T03:00:00.000Z", "AWAKE", 385, "APPLE_HEALTH", "phone"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.CORE).toBe(120); + expect(nights[0].stages.DEEP).toBe(60); + expect(nights[0].stages.REM).toBe(80); + // The watch writer's awake sliver, not the phone's 385-minute block. + expect(nights[0].awakeMinutes).toBe(15); + // The phone's bare ASLEEP must not blend into the asleep total either. + expect(nights[0].asleepMinutes).toBe(260); + }); + + it("rows without a device-type collapse per source exactly as before", () => { + // Legacy rows (pre device-type column) and single-writer sources: one + // bucket per source — granular + in-bed from the same source stay one + // night with both facets. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.CORE).toBe(120); + expect(nights[0].inBedMinutes).toBe(460); + expect(nights[0].awakeMinutes).toBe(15); + }); + + it("the RICHEST stage set wins: three granular stages beat one, regardless of ladder", () => { + // WHOOP sits first on the default ladder but only knows CORE for this + // night; the watch carries the full three-stage hypnogram. Per-night + // richness must pick the watch. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T04:30:00.000Z", "CORE", 300, "WHOOP"), + writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), + ]; + const nights = reconstructSleepNights(rows, "Europe/Berlin"); + expect(nights).toHaveLength(1); + expect(nights[0].stages.REM).toBe(80); + expect(nights[0].stages.DEEP).toBe(60); + expect(nights[0].stages.CORE).toBe(120); + expect(nights[0].asleepMinutes).toBe(260); + }); + + it("equally rich writers fall back to the ladder (WHOOP beats Apple Health)", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), + writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), + ]; + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("WHOOP"); + expect(sessions[0].asleepMinutes).toBe(410); + }); + + it("a coarse-only night heals on the next read once the stage rows land", () => { + // The reconstruction is read-time: a night first assembled from coarse + // rows (detailed sync lagging) must flip to the stage partition as soon + // as the detailed rows exist — no manual repair, no cache to clear. + const coarseOnly: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "IN_BED", 250, "APPLE_HEALTH"), + srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 400, "APPLE_HEALTH"), + srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 30, "APPLE_HEALTH"), + ]; + const before = reconstructSleepNights(coarseOnly, "Europe/Berlin"); + expect(before[0].stages.REM).toBeUndefined(); + expect(before[0].awakeMinutes).toBe(400); + + const afterRows: SleepStageRow[] = [ + ...coarseOnly, + srcRow("2026-06-04T01:30:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"), + srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), + ]; + const after = reconstructSleepNights(afterRows, "Europe/Berlin"); + expect(after).toHaveLength(1); + expect(after[0].stages.REM).toBe(90); + expect(after[0].stages.DEEP).toBe(80); + expect(after[0].stages.CORE).toBe(240); + expect(after[0].awakeMinutes).toBeNull(); + expect(after[0].asleepMinutes).toBe(410); + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 15af56f71..08c652648 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -62,18 +62,29 @@ * and every other session on the same wake day is a NAP, surfaced separately * and never folded into the main night's headline. * - * Source de-dup + * Writer de-dup * ------------- * A user paired to more than one sleep source (e.g. WHOOP + Apple Health) * gets per-stage rows for the SAME night from each source. Summing them all * would ~double the night total. Before summing a session we collapse it to - * ONE source using the user's `sleep` source-priority ladder - * (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > WITHINGS): pick the - * highest-ranked source present in the session, falling back to the source - * with the most asleep minutes (then source name) when none of the session's - * sources sits on the ladder. Only that source's segments are summed — sources - * are never blended within a night. This mirrors the per-(type, day) collapse - * the rest of the v1.11.x numeric surfaces apply. + * ONE writer. A "writer" is `(source, deviceType)` — finer than `source` + * because several HealthKit writer apps land behind the SAME + * `source = APPLE_HEALTH` (the watch's granular stages, the phone's coarse + * in-bed detection, a wearable vendor app's re-export). Keying the collapse + * on source alone blends those writers into one bucket: the phone's + * awake/in-bed blocks inflate the night's awake total and sit on top of the + * watch's hypnogram. Rows without a `deviceType` collapse per source exactly + * as before, so legacy rows and single-writer sources are unaffected. + * + * The pick is per-night by STAGE RICHNESS first: the writer carrying the + * most distinct granular stages (CORE / DEEP / REM) wins, so a coarse + * awake/asleep-only export can never mask a writer that knows the night's + * phases. Among equally rich writers the user's `sleep` source-priority + * ladder decides (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > + * WITHINGS), then most asleep minutes, then a stable key tiebreak. Only the + * winning writer's segments are summed — writers are never blended within a + * session, mirroring the per-(type, day) collapse the rest of the v1.11.x + * numeric surfaces apply. * * Unit * ---- @@ -184,6 +195,15 @@ export interface SleepStageRow { * the de-dup is a no-op for single-source nights). */ source?: MeasurementSource | null; + /** + * Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25 + * W8c). Distinguishes WRITERS behind one source: several HealthKit apps + * write sleep under `source = APPLE_HEALTH` (watch granular stages, phone + * coarse in-bed, vendor-app re-exports), and collapsing on source alone + * blends them. Optional — callers that don't select the column (and every + * row stored before W8c) collapse per source exactly as before. + */ + deviceType?: string | null; } /** One reconstructed night: the asleep total + the per-stage breakdown. */ @@ -206,63 +226,111 @@ export interface SleepNight { const NO_SOURCE = "__none__"; /** - * Pick the one canonical source for a session. STAGE GRANULARITY wins first: - * a source carrying at least one granular stage (CORE / DEEP / REM) always - * beats a coarse ASLEEP/AWAKE-only source, so a wearable's full hypnogram is - * never masked by a parallel coarse export of the same night (e.g. Apple - * Health's 6–9 AWAKE/ASLEEP blocks alongside WHOOP's per-stage rows). Among - * equally granular (or equally coarse) sources the user's `sleep` - * source-priority ladder decides (lowest ladder index = highest priority); - * sources absent from the ladder fall back to "most asleep minutes", then a - * stable source-name tiebreak. Single-source (or source-less) sessions - * resolve to that one bucket with no work. + * Separator between the source and device-type parts of a writer key. + * `` cannot appear in a `MeasurementSource` enum literal or a + * device-type tag, so the split-back is unambiguous. */ -function pickSessionSource( +const WRITER_KEY_SEP = ""; + +/** + * Writer-bucket key of a stage row: the source, refined by the device-type + * tag when one is stored. Two HealthKit writer apps behind the same + * `APPLE_HEALTH` source (watch vs phone) land in DIFFERENT buckets so the + * coarse writer's awake/in-bed blocks never blend into the granular + * writer's night. Rows without a device-type keep the bare source as their + * key — single-writer sources and legacy rows collapse exactly as before. + */ +function writerKeyOf(r: SleepStageRow): string { + const src = r.source ?? NO_SOURCE; + const dev = r.deviceType; + return dev ? `${src}${WRITER_KEY_SEP}${dev}` : src; +} + +/** The source part of a writer key (for ladder ranking + display). */ +function sourceOfWriterKey(key: string): string { + const i = key.indexOf(WRITER_KEY_SEP); + return i === -1 ? key : key.slice(0, i); +} + +/** + * Count of DISTINCT granular stages (CORE / DEEP / REM) in a row set — the + * stage-richness score of a writer's night. 3 = full hypnogram, 0 = coarse + * (bare ASLEEP / AWAKE / IN_BED only). + */ +function granularStageCount(rows: readonly SleepStageRow[]): number { + const seen = new Set(); + for (const r of rows) { + const stage = r.sleepStage; + if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) seen.add(stage); + } + return seen.size; +} + +/** + * Pick the one canonical WRITER for a session. STAGE RICHNESS wins first: + * the writer carrying the most distinct granular stages (CORE / DEEP / REM) + * always beats a coarser one, so neither a parallel coarse export of the + * same night (e.g. Apple Health's AWAKE/ASLEEP blocks alongside WHOOP's + * per-stage rows) nor a second writer app behind the same source (the + * phone's in-bed detection next to the watch's stages) can mask the + * fullest hypnogram available for the night. Among equally rich writers + * the user's `sleep` source-priority ladder decides (lowest ladder index = + * highest priority); writers whose source is absent from the ladder fall + * back to "most asleep minutes", then a stable key tiebreak. Single-writer + * (or source-less) sessions resolve to that one bucket with no work. + */ +function pickSessionWriter( rows: SleepStageRow[], sleepLadder: readonly MeasurementSource[], ): string { - // Group rows by source, then sum each source's asleep minutes with the - // granular-over-bare rule so a source that writes BOTH a bare ASLEEP + // Group rows by writer, then sum each writer's asleep minutes with the + // granular-over-bare rule so a writer that stores BOTH a bare ASLEEP // aggregate and the granular breakdown is not over-weighted in the tiebreak. - const rowsBySource = new Map(); + const rowsByWriter = new Map(); for (const r of rows) { - const src = r.source ?? NO_SOURCE; - const list = rowsBySource.get(src) ?? []; + const key = writerKeyOf(r); + const list = rowsByWriter.get(key) ?? []; list.push(r); - rowsBySource.set(src, list); + rowsByWriter.set(key, list); } - const asleepBySource = new Map(); - for (const [src, srcRows] of rowsBySource) { - const minutes = asleepMinutesOf(srcRows); - if (minutes > 0) asleepBySource.set(src, minutes); + const asleepByWriter = new Map(); + for (const [key, writerRows] of rowsByWriter) { + const minutes = asleepMinutesOf(writerRows); + if (minutes > 0) asleepByWriter.set(key, minutes); } // No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set - // of all present sources so the session still resolves to a single bucket. - let sources = - asleepBySource.size > 0 - ? [...asleepBySource.keys()] - : [...new Set(rows.map((r) => r.source ?? NO_SOURCE))]; - // Granularity gate: a source with a CORE/DEEP/REM partition always beats a - // coarse ASLEEP-only source — otherwise a parallel coarse export of the - // same night masks the wearable's hypnogram. Coarse sources stay only as - // the fallback when NO granular source covers the session. - const granularSources = sources.filter((src) => - sawGranularStage(rowsBySource.get(src) ?? []), - ); - if (granularSources.length > 0) sources = granularSources; - if (sources.length <= 1) return sources[0] ?? NO_SOURCE; + // of all present writers so the session still resolves to a single bucket. + let writers = + asleepByWriter.size > 0 + ? [...asleepByWriter.keys()] + : [...rowsByWriter.keys()]; + // Richness gate: keep only the writers tied for the MOST distinct granular + // stages. A 3-stage hypnogram beats a 1-stage partial beats a coarse + // ASLEEP-only export; coarse writers survive only when NO writer carries a + // granular stage for the session. + const richnessOf = new Map(); + let maxRichness = 0; + for (const key of writers) { + const score = granularStageCount(rowsByWriter.get(key) ?? []); + richnessOf.set(key, score); + if (score > maxRichness) maxRichness = score; + } + if (maxRichness > 0) { + writers = writers.filter((key) => richnessOf.get(key) === maxRichness); + } + if (writers.length <= 1) return writers[0] ?? NO_SOURCE; - const rankOf = (src: string): number => { - const i = sleepLadder.indexOf(src as MeasurementSource); + const rankOf = (key: string): number => { + const i = sleepLadder.indexOf(sourceOfWriterKey(key) as MeasurementSource); return i === -1 ? Number.MAX_SAFE_INTEGER : i; }; - return sources.sort((a, b) => { + return writers.sort((a, b) => { const ra = rankOf(a); const rb = rankOf(b); if (ra !== rb) return ra - rb; - // Neither (or both) on the ladder — most asleep minutes wins, then name. - const ma = asleepBySource.get(a) ?? 0; - const mb = asleepBySource.get(b) ?? 0; + // Neither (or both) on the ladder — most asleep minutes wins, then key. + const ma = asleepByWriter.get(a) ?? 0; + const mb = asleepByWriter.get(b) ?? 0; if (ma !== mb) return mb - ma; return a < b ? -1 : 1; })[0]; @@ -316,15 +384,15 @@ export function reconstructSleepNights( if (current.length > 0) sessions.push(current); // Two sessions can land on the same wake day (e.g. a genuine overnight plus - // a same-day nap); collect each session's canonical-source rows under one + // a same-day nap); collect each session's canonical-writer rows under one // wake-day key. The rows are grouped PER SESSION (not flattened) so the // granular-over-bare gate and the asleep total are decided per session — a // bare-only nap keeps its minutes even when the overnight session is // granular (the v1.11.5 merged-night nap under-count fix). const byNight = new Map(); for (const session of sessions) { - const canonical = pickSessionSource(session, sleepLadder); - const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical); + const canonical = pickSessionWriter(session, sleepLadder); + const kept = session.filter((r) => writerKeyOf(r) === canonical); const pool = kept.length > 0 ? kept : session; // The wake day is the LATEST segment END in the session (Apple Health // attributes a session to the morning you wake up). @@ -526,8 +594,8 @@ export function reconstructSleepSessions( const sessions: SleepSession[] = []; for (const session of rawSessions) { - const canonical = pickSessionSource(session, sleepLadder); - const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical); + const canonical = pickSessionWriter(session, sleepLadder); + const kept = session.filter((r) => writerKeyOf(r) === canonical); const pool = kept.length > 0 ? kept : session; // v1.11.5 — drop the redundant bare ASLEEP aggregate (and stage-less // rows) from the hypnogram segments AND the per-stage breakdown when the @@ -571,10 +639,13 @@ export function reconstructSleepSessions( } } const asleep = asleepMinutesOf(pool); + const canonicalSource = sourceOfWriterKey(canonical); sessions.push({ night: userDayKey(latest, tz), source: - canonical === NO_SOURCE ? null : (canonical as MeasurementSource), + canonicalSource === NO_SOURCE + ? null + : (canonicalSource as MeasurementSource), start: earliest, end: latest, asleepMinutes: asleep, diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 1af2e97f8..db65866f7 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -316,7 +316,15 @@ async function withSleepNightTotals( measuredAt: { gte: since }, }, orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true, source: true }, + // `deviceType` feeds the writer-level collapse: two HealthKit apps + // behind one source (watch stages vs phone in-bed) must not blend. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, })) as SleepStageRow[]; if (rows.length === 0) return slice; diff --git a/src/lib/export.ts b/src/lib/export.ts index da69e3fd6..26e7d85aa 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -95,6 +95,8 @@ interface ExportMeasurement { notes: string | null; glucoseContext?: string | null; sleepStage?: SleepStage | null; + /** Writer tag for the per-night sleep collapse (watch vs phone). */ + deviceType?: string | null; } /** @@ -158,6 +160,7 @@ export function formatMeasurementsForExport( measuredAt: m.measuredAt, sleepStage: m.sleepStage ?? null, source: m.source as SleepStageRow["source"], + deviceType: m.deviceType ?? null, })); const sessions = reconstructSleepSessions( stageRows, @@ -281,6 +284,8 @@ export function formatMoodEntriesForExport( mood: string; score: number; tags: string | null; + /** Free-text note — exported in full so the texts are readable offline. */ + note?: string | null; source: string; moodLoggedAt: Date; }>, @@ -291,6 +296,7 @@ export function formatMoodEntriesForExport( mood: e.mood, score: e.score, tags: e.tags ?? "", + note: e.note ?? "", source: e.source, loggedAt: formatTimestamp(e.moodLoggedAt, userTz), })); diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index 2223d247a..dd691f191 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -102,12 +102,17 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; /** * Overlap window for the incremental sync, in ms. WHOOP re-scores recovery - * and sleep after the fact (the final recovery/sleep score can land hours - * after the night), so the recovery/sleep overlap is a full 24 h to make sure - * the re-scored record is re-fetched on the next tick. Workout/cycle settle - * fast — a smaller overlap suffices and keeps the page count down. + * and sleep after the fact, and a night can reach the WHOOP cloud DAYS late + * (phone offline, app unopened, score pending). The collection endpoints + * filter on the record's own time range — not on when it arrived — so any + * record surfacing after the cursor moved past it is missed FOREVER by a + * narrow overlap; the night's per-stage rows then never reach the DB and + * every sleep surface keeps showing the parallel coarse source for that + * night. Seven days re-fetches a handful of records per tick (the upserts + * are idempotent) and closes the gap for every realistic lag. Workout/cycle + * settle fast — a smaller overlap suffices and keeps the page count down. */ -export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 24 * 60 * 60 * 1000; +export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 7 * 24 * 60 * 60 * 1000; export const WHOOP_DEFAULT_OVERLAP_MS = 60 * 60 * 1000; // 1 h export interface WhoopTokenInfo {