mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge v142/day-scores into release/v1.42.0
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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