mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
This commit is contained in:
1 parent
d8a3614120
commit
6e2a8fcd9f
35 files changed
+1590
-60
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
import {
|
||||
DAY_SCORE_HREF,
|
||||
DAY_SCORE_KEYS,
|
||||
type DayScore,
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
import { cycleRunningLine } from "../day-cycle-line";
|
||||
|
||||
/**
|
||||
* The day's scores: a tile for each score the day holds and nothing for the
|
||||
* rest, every tile opening its score's own page, worded from the bundle with
|
||||
* no raw code and no dot separator.
|
||||
*/
|
||||
|
||||
async function render(scores: DayScore[], locale: "en" | "de" = "en") {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { DayScores } = await import("../day-scores");
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider
|
||||
initialLocale={locale}
|
||||
initialMessages={locale === "en" ? enMessages : deMessages}
|
||||
>
|
||||
<DayScores scores={scores} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const READINESS: DayScore = {
|
||||
key: "readiness",
|
||||
value: 71,
|
||||
max: 100,
|
||||
source: "COMPUTED",
|
||||
band: { lo: 60, hi: 80, n: 21 },
|
||||
};
|
||||
const DEVICE_STRAIN: DayScore = {
|
||||
key: "strain",
|
||||
value: 12.4,
|
||||
max: 21,
|
||||
source: "WHOOP",
|
||||
band: null,
|
||||
};
|
||||
|
||||
describe("<DayScores>", () => {
|
||||
it("renders nothing for a day without scores", async () => {
|
||||
expect(await render([])).toBe("");
|
||||
});
|
||||
|
||||
it("shows only the scores the day holds, each linking to its page", async () => {
|
||||
const html = await render([READINESS, DEVICE_STRAIN]);
|
||||
expect(html).toContain('data-slot="day-scores"');
|
||||
expect(html).toContain(">Scores<");
|
||||
expect(html.match(/data-slot="day-score"/g)).toHaveLength(2);
|
||||
expect(html).toContain(`href="${DAY_SCORE_HREF.readiness}"`);
|
||||
expect(html).toContain(`href="${DAY_SCORE_HREF.strain}"`);
|
||||
for (const key of DAY_SCORE_KEYS) {
|
||||
if (key === "readiness" || key === "strain") continue;
|
||||
expect(html).not.toContain(`data-score="${key}"`);
|
||||
}
|
||||
expect(html).toContain("Readiness");
|
||||
expect(html).toContain("Strain");
|
||||
});
|
||||
|
||||
it("states a device scale, draws the usual range and never prints a code or a dot", async () => {
|
||||
const html = await render([READINESS, DEVICE_STRAIN]);
|
||||
expect(html).toContain("12.4");
|
||||
expect(html).toContain("of 21");
|
||||
// The 0 to 100 scores read as a bare number, as on their pages.
|
||||
expect(html).not.toContain("of 100");
|
||||
expect(html.match(/data-slot="day-number-line"/g)).toHaveLength(1);
|
||||
expect(html).toContain("Usually 60 to 80");
|
||||
expect(html).not.toContain(" · ");
|
||||
expect(html).not.toContain("—");
|
||||
for (const raw of ["COMPUTED", "WHOOP", "readiness<", "strain<"]) {
|
||||
expect(html).not.toContain(raw);
|
||||
}
|
||||
});
|
||||
|
||||
it("names every score from the bundle in each locale", async () => {
|
||||
const all: DayScore[] = DAY_SCORE_KEYS.map((key) => ({
|
||||
key,
|
||||
value: 50,
|
||||
max: 100,
|
||||
source: "COMPUTED",
|
||||
band: null,
|
||||
}));
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
const html = await render(all, locale);
|
||||
expect(html.match(/data-slot="day-score"/g)).toHaveLength(5);
|
||||
expect(html).not.toMatch(/insights\.|day\.groups/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle line", () => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
const path = key.split(".");
|
||||
let node: unknown = enMessages;
|
||||
for (const part of path) node = (node as Record<string, unknown>)[part];
|
||||
let text = String(node);
|
||||
for (const [k, v] of Object.entries(params ?? {})) {
|
||||
text = text.replace(`{${k}}`, String(v));
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
it("words the cycle day and phase without a separator dot", () => {
|
||||
const line = cycleRunningLine(
|
||||
{ kind: "cyclePhase", sub: "FOLLICULAR", dayIndex: 12 },
|
||||
t,
|
||||
);
|
||||
expect(line).toBe("Cycle day 12, follicular phase");
|
||||
expect(line).not.toContain("·");
|
||||
});
|
||||
|
||||
it("falls back to the cycle day without a phase", () => {
|
||||
expect(
|
||||
cycleRunningLine({ kind: "cyclePhase", sub: null, dayIndex: 3 }, t),
|
||||
).toBe("Cycle day 3");
|
||||
});
|
||||
|
||||
it("leaves every other running item alone", () => {
|
||||
expect(
|
||||
cycleRunningLine({ kind: "illness", sub: null, dayIndex: 3 }, t),
|
||||
).toBeNull();
|
||||
expect(
|
||||
cycleRunningLine(
|
||||
{ kind: "cyclePhase", sub: "LUTEAL", dayIndex: null },
|
||||
t,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,7 @@ const DAY: DayResponse = {
|
||||
params: { since: "2025-03-01", value: 138 },
|
||||
},
|
||||
],
|
||||
scores: [],
|
||||
sections: {},
|
||||
};
|
||||
|
||||
@@ -339,4 +340,58 @@ describe("<DayView>", () => {
|
||||
};
|
||||
expect(await render()).toContain("Some areas are not shared with you.");
|
||||
});
|
||||
|
||||
it("puts the scores below the values and holds a day that has only scores", async () => {
|
||||
const scores: DayResponse["scores"] = [
|
||||
{ key: "readiness", value: 71, max: 100, source: "COMPUTED", band: null },
|
||||
];
|
||||
dayQuery = { data: { ...DAY, scores } };
|
||||
const html = await render();
|
||||
const values = html.indexOf('data-slot="day-values"');
|
||||
const scoreSection = html.indexOf('data-slot="day-scores"');
|
||||
const events = html.indexOf('data-slot="day-events"');
|
||||
expect(values).toBeGreaterThan(-1);
|
||||
expect(scoreSection).toBeGreaterThan(values);
|
||||
expect(events).toBeGreaterThan(scoreSection);
|
||||
|
||||
dayQuery = {
|
||||
data: {
|
||||
...DAY,
|
||||
counts: { values: 0, entries: 0 },
|
||||
running: [],
|
||||
values: [],
|
||||
events: [],
|
||||
notable: [],
|
||||
scores,
|
||||
},
|
||||
};
|
||||
const only = await render();
|
||||
expect(only).toContain('data-score="readiness"');
|
||||
expect(only).not.toContain('data-slot="day-empty"');
|
||||
});
|
||||
|
||||
it("words a running cycle as its cycle day and phase", async () => {
|
||||
dayQuery = {
|
||||
data: {
|
||||
...DAY,
|
||||
running: [
|
||||
{
|
||||
kind: "cyclePhase",
|
||||
section: "cycle",
|
||||
id: "c1",
|
||||
title: "cycle",
|
||||
sub: "FOLLICULAR",
|
||||
since: "2025-12-23",
|
||||
until: null,
|
||||
dayIndex: 12,
|
||||
dayCount: null,
|
||||
href: "/cycle",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const html = await render();
|
||||
expect(html).toContain("Cycle day 12, follicular phase");
|
||||
expect(html).not.toContain("FOLLICULAR");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DayRunningItem } from "@/lib/day/contract";
|
||||
|
||||
const CYCLE_PHASES: ReadonlySet<string> = new Set([
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The quiet line under a running cycle: "Cycle day 12, follicular phase",
|
||||
* or the cycle day alone when the server could not place a phase. The
|
||||
* server sends the phase as a closed code in `sub`; it is worded here, in
|
||||
* the reader's language. Null for anything but a counted cycle.
|
||||
*/
|
||||
export function cycleRunningLine(
|
||||
item: Pick<DayRunningItem, "kind" | "sub" | "dayIndex">,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string | null {
|
||||
if (item.kind !== "cyclePhase" || item.dayIndex === null) return null;
|
||||
if (item.sub !== null && CYCLE_PHASES.has(item.sub)) {
|
||||
return t(`day.cyclePhaseLine.${item.sub}`, { n: item.dayIndex });
|
||||
}
|
||||
return t("day.cycleDay", { n: item.dayIndex });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
DAY_SCORE_HREF,
|
||||
type DayScore,
|
||||
type DayScoreKey,
|
||||
} from "@/lib/day/contract";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { DAY_SECTION_LABEL, NumberLine } from "./day-sections";
|
||||
|
||||
/**
|
||||
* The day's scores, below its values: one tile per score the record holds
|
||||
* for the day, each opening the score's own page. A score the day does not
|
||||
* have gets no tile, and a day without any gets no section.
|
||||
*/
|
||||
|
||||
/** The name each score carries on its own page. */
|
||||
const SCORE_LABEL: Readonly<Record<DayScoreKey, string>> = {
|
||||
healthScore: "insights.healthScore.label",
|
||||
readiness: "insights.derived.composite.READINESS.title",
|
||||
recovery: "insights.derived.scores.recovery",
|
||||
sleepScore: "insights.derived.composite.SLEEP_SCORE.title",
|
||||
strain: "insights.derived.scores.strain",
|
||||
};
|
||||
|
||||
/** The scale every score but a device's day strain is read on. */
|
||||
const PERCENT_SCALE = 100;
|
||||
|
||||
function ScoreTile({ score }: { score: DayScore }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const decimals = score.max === PERCENT_SCALE ? 0 : 1;
|
||||
const show = (n: number) => fmt.number(n, decimals);
|
||||
return (
|
||||
<li className="min-w-0">
|
||||
<Link
|
||||
href={DAY_SCORE_HREF[score.key]}
|
||||
data-slot="day-score"
|
||||
data-score={score.key}
|
||||
className="bg-muted/60 hover:bg-muted focus-visible:ring-ring/50 flex min-w-0 flex-col rounded-lg px-3 py-2.5 transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{t(SCORE_LABEL[score.key])}
|
||||
</span>
|
||||
<span className="truncate text-base font-semibold tabular-nums">
|
||||
{show(score.value)}
|
||||
{score.max !== PERCENT_SCALE ? (
|
||||
<span className="text-muted-foreground ml-1 text-xs font-medium">
|
||||
{t("day.scoreOfMax", { max: score.max })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{score.band ? (
|
||||
<>
|
||||
<NumberLine value={score.value} band={score.band} />
|
||||
<span className="sr-only">
|
||||
{t("day.usualRange", {
|
||||
lo: show(score.band.lo),
|
||||
hi: show(score.band.hi),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayScores({ scores }: { scores: readonly DayScore[] }) {
|
||||
const { t } = useTranslations();
|
||||
if (scores.length === 0) return null;
|
||||
return (
|
||||
<section className="space-y-2" data-slot="day-scores">
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.scores")}</h3>
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
{scores.map((score) => (
|
||||
<ScoreTile key={score.key} score={score} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
tileKeyOf,
|
||||
type DayValueTile,
|
||||
} from "./day-values-model";
|
||||
import { cycleRunningLine } from "./day-cycle-line";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
|
||||
/**
|
||||
@@ -209,19 +210,24 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.running")}</h3>
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const parts = [
|
||||
item.sub === null ? null : dayMeta(item.kind, item.sub, t),
|
||||
item.dayIndex !== null
|
||||
? item.dayCount !== null
|
||||
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
|
||||
: t("day.dayN", { n: item.dayIndex })
|
||||
: null,
|
||||
item.dayCount === null && item.since !== null
|
||||
? t("day.since", {
|
||||
date: fmt.dateShortSmartCalendar(item.since),
|
||||
})
|
||||
: null,
|
||||
].filter((part): part is string => !!part);
|
||||
const cycleLine = cycleRunningLine(item, t);
|
||||
const parts = (
|
||||
cycleLine !== null
|
||||
? [cycleLine]
|
||||
: [
|
||||
item.sub === null ? null : dayMeta(item.kind, item.sub, t),
|
||||
item.dayIndex !== null
|
||||
? item.dayCount !== null
|
||||
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
|
||||
: t("day.dayN", { n: item.dayIndex })
|
||||
: null,
|
||||
item.dayCount === null && item.since !== null
|
||||
? t("day.since", {
|
||||
date: fmt.dateShortSmartCalendar(item.since),
|
||||
})
|
||||
: null,
|
||||
]
|
||||
).filter((part): part is string => !!part);
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
@@ -273,7 +279,7 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
|
||||
|
||||
/* ─── Values ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function NumberLine({
|
||||
export function NumberLine({
|
||||
value,
|
||||
band,
|
||||
}: {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
DayRunning,
|
||||
DayValues,
|
||||
} from "./day-sections";
|
||||
import { DayScores } from "./day-scores";
|
||||
import { curateDayValues, tileKeyOf } from "./day-values-model";
|
||||
import { shiftDateKey } from "./day-url";
|
||||
import { useDay, usePrefetchDay } from "./use-day";
|
||||
@@ -168,7 +169,8 @@ export function DayView({
|
||||
data !== undefined &&
|
||||
data.values.length === 0 &&
|
||||
data.events.length === 0 &&
|
||||
data.running.length === 0;
|
||||
data.running.length === 0 &&
|
||||
(data.scores ?? []).length === 0;
|
||||
const notShared = data
|
||||
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
|
||||
: false;
|
||||
@@ -336,6 +338,7 @@ export function DayView({
|
||||
withMetric
|
||||
/>
|
||||
)}
|
||||
<DayScores scores={data.scores ?? []} />
|
||||
<DayEvents events={data.events} />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user