Merge v142/day-scores into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 14:22:21 +02:00
commit 6033935449
35 files changed
+1607 -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");
});
});
+25
View File
@@ -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 });
}
+85
View File
@@ -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>
);
}
+20 -14
View File
@@ -57,6 +57,7 @@ import {
ILLNESS_IMPACT_KEY,
WORKOUT_SPORT_KEY,
} from "./label-keys";
import { cycleRunningLine } from "./day-cycle-line";
import { useDayValueFormat } from "./use-day-value-format";
/**
@@ -297,19 +298,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
@@ -361,7 +367,7 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
/* ─── Values ──────────────────────────────────────────────────────────── */
function NumberLine({
export function NumberLine({
value,
band,
}: {
+4 -1
View File
@@ -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} />
</>
)}