fix(sleep,mood): pick the richest writer per night and show the whole note

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 21:52:52 +02:00
1 parent a664395021
commit 657f2323a3
18 files changed
+548 -86

No files matched your search

+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+1
View File
@@ -489,6 +489,7 @@ async function computeSleepStageBreakdown(
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
});
+3
View File
@@ -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,
},
});
+3
View File
@@ -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";
+3
View File
@@ -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,
},
});
@@ -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");
});
});
@@ -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(
<I18nProvider initialLocale={locale}>
<MoodList />
</I18nProvider>,
);
}
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");
});
});
+60 -23
View File
@@ -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<Set<string>>(() => 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<Set<string>>(
() => new Set(),
);
const toggleNote = (id: string) =>
setExpandedNoteIds((prev) => toggleId(prev, id));
const [editing, setEditing] = useState<MoodEntry | null>(null);
const [editMood, setEditMood] = useState("");
const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({
@@ -599,20 +602,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
: "-"}
</span>
{entry.note && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
{entry.note}
</p>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs whitespace-pre-wrap">
{entry.note}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<MoodNoteText
note={entry.note}
expanded={expandedNoteIds.has(entry.id)}
onToggle={() => toggleNote(entry.id)}
/>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
@@ -710,9 +704,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</p>
)}
{entry.note && (
<p className="text-muted-foreground/80 truncate text-xs italic">
{entry.note}
</p>
<MoodNoteText
note={entry.note}
expanded={expandedNoteIds.has(entry.id)}
onToggle={() => toggleNote(entry.id)}
/>
)}
</div>
</div>
@@ -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 (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
data-testid="mood-note-toggle"
className="mt-0.5 block w-full min-w-0 cursor-pointer text-left"
>
<p
className={
expanded
? "text-muted-foreground/80 text-xs break-words whitespace-pre-wrap italic"
: "text-muted-foreground/80 line-clamp-2 text-xs italic"
}
>
{note}
</p>
<span className="text-dracula-purple text-[11px] font-medium">
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
</span>
</button>
);
}
/**
* 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
@@ -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);
});
});
+126 -55
View File
@@ -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<SleepStage>();
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<string, SleepStageRow[]>();
const rowsByWriter = new Map<string, SleepStageRow[]>();
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<string, number>();
for (const [src, srcRows] of rowsBySource) {
const minutes = asleepMinutesOf(srcRows);
if (minutes > 0) asleepBySource.set(src, minutes);
const asleepByWriter = new Map<string, number>();
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<string, number>();
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<string, SleepStageRow[][]>();
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,
+9 -1
View File
@@ -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;
+6
View File
@@ -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),
}));
+10 -5
View File
@@ -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 {