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 {