Files
HealthLog/src/lib/day/__tests__/sections.test.ts
T
Marc-André Bombeck 6e2a8fcd9f feat(day): show the day's scores and the cycle day with its phase
The day view gains a scores section below the values: the health score as
recorded that day, the readiness the nightly job persisted for the night
that ended that morning, a device's own recovery, the sleep score of that
night and strain (computed, or the device's day strain on its 0 to 21
scale). Each tile carries the person's usual range over the 30 days before
and opens the score's own page; a score the day does not hold has no tile,
and a day without any has no section.

Every value comes from where its page reads its history: the stored health
score record, the persisted RECOVERY_SCORE and STRAIN_SCORE rows read on
the day they describe, the canonical recovery ladder, and the sleep score
computed over one bounded read of the 30 nights up to the day. The stored
score rows leave the values list of the day they are stamped on, so a
readiness no longer appears a day early among the readings.

Scores follow their own module (recovery, sleep; the health score is core)
and map to the whole record for sharing: a scoped grant sees them named as
not shared, a whole-record grant sees them. get_day sends them to the
Coach and the MCP endpoint by name and number, and the Coach exclusions
take out every score built on excluded data.

A running cycle now states its cycle day and phase ("Cycle day 12,
follicular phase"), computed the way the cycle calendar does. Only logged
cycles count, the next logged start closes a cycle, and an open cycle
stops counting past its typical length plus the grace window instead of
reading a day count nobody recorded.
2026-10-09 14:03:31 +02:00

143 lines
4.6 KiB
TypeScript

/**
* Which sections of a day a caller sees (v1.42, #613): a switched-off module
* leaves without a word, a section the grant does not cover is named once,
* and the environment rows are the owner's alone.
*/
import { describe, expect, it } from "vitest";
import { DAY_SECTION_KEYS } from "@/lib/day/contract";
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
import { ENTIRE_RECORD, SHARE_DOMAINS } from "@/lib/sharing/scope";
import {
DAY_SECTION_SHARE_DOMAIN,
measurementTypeVisible,
resolveDayAccessFrom,
} from "../sections";
const ALL_ON = Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record<
ModuleKey,
boolean
>;
describe("day access", () => {
it("opens every section to the owner", () => {
const access = resolveDayAccessFrom({
modules: ALL_ON,
domainVisible: () => true,
owner: true,
});
expect([...access.readable].sort()).toEqual([...DAY_SECTION_KEYS].sort());
expect(access.notShared).toEqual([]);
});
it("leaves a switched-off module's section out without naming it", () => {
const access = resolveDayAccessFrom({
modules: { ...ALL_ON, mood: false, timeline: false },
domainVisible: () => true,
owner: true,
});
expect(access.readable.has("mood")).toBe(false);
expect(access.readable.has("lifeEvents")).toBe(false);
expect(access.notShared).toEqual([]);
expect(access.moduleOff).toEqual(
expect.arrayContaining(["mood", "lifeEvents"]),
);
});
it("reads only the domains a scoped grant covers and names the rest", () => {
const access = resolveDayAccessFrom({
modules: ALL_ON,
domainVisible: (d) => d === "measurements",
owner: false,
});
for (const section of DAY_SECTION_KEYS) {
const domain = DAY_SECTION_SHARE_DOMAIN[section];
expect(access.readable.has(section), section).toBe(
domain === "measurements",
);
}
expect(access.notShared).toEqual(
expect.arrayContaining([
"medications",
"mood",
"lifeEvents",
"environment",
]),
);
});
it("keeps the environment rows and the life events for the owner even under a full grant", () => {
const access = resolveDayAccessFrom({
modules: ALL_ON,
domainVisible: () => true,
owner: false,
});
expect(access.readable.has("environment")).toBe(false);
expect(access.readable.has("lifeEvents")).toBe(false);
expect(access.notShared).toEqual(["environment", "lifeEvents"]);
});
it("opens the life events to the owner", () => {
const access = resolveDayAccessFrom({
modules: ALL_ON,
domainVisible: () => true,
owner: true,
});
expect(access.readable.has("lifeEvents")).toBe(true);
});
it("maps every section to a real sharing domain or to the owner", () => {
for (const section of DAY_SECTION_KEYS) {
const domain = DAY_SECTION_SHARE_DOMAIN[section];
if (domain !== null && domain !== ENTIRE_RECORD) {
expect(SHARE_DOMAINS).toContain(domain);
}
}
// Owner-only in v1.42: no share level reaches them.
expect(DAY_SECTION_SHARE_DOMAIN.lifeEvents).toBeNull();
// Composites across sections: only a grant over the whole record.
expect(DAY_SECTION_SHARE_DOMAIN.scores).toBe(ENTIRE_RECORD);
});
it("names the scores as not shared under a scoped grant, even one ticking every section", () => {
const access = resolveDayAccessFrom({
modules: ALL_ON,
domainVisible: (d) => d !== ENTIRE_RECORD,
owner: false,
});
expect(access.readable.has("scores")).toBe(false);
expect(access.notShared).toContain("scores");
expect(access.readable.has("values")).toBe(true);
});
it("opens the scores under a grant over the whole record", () => {
const access = resolveDayAccessFrom({
modules: ALL_ON,
domainVisible: () => true,
owner: false,
});
expect(access.readable.has("scores")).toBe(true);
});
it("narrows readings type by type through their own module", () => {
expect(measurementTypeVisible("WEIGHT", { ...ALL_ON })).toBe(true);
expect(
measurementTypeVisible("HEART_RATE_VARIABILITY", {
...ALL_ON,
recovery: false,
}),
).toBe(false);
});
});
describe("life-event coverage", () => {
it("reaches to the end of a coarse date", async () => {
const { lastDayCovered } = await import("@/lib/life-events/dates");
expect(lastDayCovered("2023-09-14", "DAY")).toBe("2023-09-14");
expect(lastDayCovered("2024-02-01", "MONTH")).toBe("2024-02-29");
expect(lastDayCovered("2023-12-01", "MONTH")).toBe("2023-12-31");
expect(lastDayCovered("2019-01-01", "YEAR")).toBe("2019-12-31");
});
});