mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user