fix(ui): granular sleep sources, viewport-safe tour, one aligned settings language

A night with coarse apple-health samples next to granular watch data
showed only awake and asleep — the per-session source pick now prefers
the source that knows its stages. Tour popovers clamp into the
viewport, wrap their footer and fall back to a bottom sheet on narrow
screens. Settings and admin sections share one alignment rule — icon
column left, everything else flush on the content column — with neutral
header icons, no duplicated section titles under the shell heading, and
every select on the account-style idiom with a visible chevron. The
dashboard's customize icon matches insights, the target-settings link
leaves the home charts, and the mood heatmap adopts the damped fills
the other mood charts already use.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 21:19:05 +02:00
1 parent 9f2c403f84
commit 024da9fe96
67 files changed
+545 -344

No files matched your search

@@ -286,6 +286,47 @@ describe("reconstructSleepNights", () => {
// Apple Health wins under the override: 230 + 80 = 310.
expect(nights[0].asleepMinutes).toBe(310);
});
it("prefers the stage-granular source over a coarse one regardless of ladder", () => {
// Mixed night: WHOOP writes the full hypnogram (REM/CORE/DEEP/AWAKE),
// Apple Health writes only coarse AWAKE/ASLEEP blocks + IN_BED for the
// SAME night. Even with a ladder that ranks Apple Health first, the
// granular WHOOP partition must carry the night — otherwise the chart
// collapses to awake/asleep and the stages disappear.
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
srcRow("2026-06-04T04:40:00.000Z", "AWAKE", 10, "WHOOP"),
// Apple Health's coarse parallel export of the same night.
srcRow("2026-06-04T01:30:00.000Z", "ASLEEP", 110, "APPLE_HEALTH"),
srcRow("2026-06-04T01:40:00.000Z", "AWAKE", 10, "APPLE_HEALTH"),
srcRow("2026-06-04T03:30:00.000Z", "ASLEEP", 100, "APPLE_HEALTH"),
srcRow("2026-06-04T04:30:00.000Z", "ASLEEP", 60, "APPLE_HEALTH"),
srcRow("2026-06-04T04:40:00.000Z", "IN_BED", 430, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
// WHOOP's granular partition: 240 + 90 + 90 = 420.
expect(nights[0].asleepMinutes).toBe(420);
expect(nights[0].stages.REM).toBe(90);
expect(nights[0].stages.DEEP).toBe(90);
expect(nights[0].stages.CORE).toBe(240);
// The coarse source's bare ASLEEP blocks must not blend in.
expect(nights[0].stages.ASLEEP).toBeUndefined();
});
it("keeps the coarse source as fallback when no granular source exists", () => {
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:30:00.000Z", "ASLEEP", 110, "APPLE_HEALTH"),
srcRow("2026-06-04T03:30:00.000Z", "ASLEEP", 100, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(210);
});
});
describe("reconstructSleepSessions", () => {
+18 -5
View File
@@ -206,10 +206,15 @@ export interface SleepNight {
const NO_SOURCE = "__none__";
/**
* Pick the one canonical source for a session. The user's `sleep`
* source-priority ladder wins (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
* 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.
*/
function pickSessionSource(
@@ -233,10 +238,18 @@ function pickSessionSource(
}
// 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.
const sources =
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;
const rankOf = (src: string): number => {
@@ -37,6 +37,13 @@ vi.mock("@/hooks/use-feature-flags", async () => {
};
});
// v1.16.1 — the nudge-driven `<LayoutCoachFab>` reads the app router
// (push + pathname); SSR test renders have no router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
/**
* v1.4.37 W5 — Coach disable cascade invariant.
*
@@ -178,7 +185,11 @@ const COACH_SURFACES: CoachSurface[] = [
<LayoutCoachFab />
</CoachLaunchProvider>
),
proofWhenOn: 'data-slot="coach-launch-fab"',
// v1.16.1 — the FAB is nudge-driven: it renders nothing until the
// nudge-status query resolves with an unseen nudge, so its SSR
// shape is empty even when the flag is on. The negative `coach-*`
// grep + the gate-fired spy assertion still pin the off state.
proofWhenOn: "",
},
{
name: "LayoutCoachMount drawer subtree",
@@ -69,6 +69,13 @@ vi.mock("@/hooks/use-auth", async () => {
// below trivially fail; instead we let the real `useDisableCoach`
// pass through and capture the underlying `useAuth` calls.
// v1.16.1 — the nudge-driven `<LayoutCoachFab>` reads the app router
// (push + pathname); SSR test renders have no router context.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights",
}));
function buildUser(disableCoach: boolean): AuthUser {
// Minimal `AuthUser` shape — the Coach gates only read
// `disableCoach`, but TypeScript requires the full interface to
@@ -143,7 +150,10 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
<LayoutCoachFab />
</CoachLaunchProvider>
),
proofWhenVisible: 'data-slot="coach-launch-fab"',
// v1.16.1 — nudge-driven: SSR shape is empty even when visible
// (the bubble waits for the nudge-status query). Negative
// assertion still pins the gate via the `coach-*` slot grep.
proofWhenVisible: "",
},
{
name: "LayoutCoachMount",