fix: spell a day's sleep and scores as their charts do

The day route sends a night's length in min and a device score in score.
The day read the night as a bare 412 and the score in the wire's word;
it now reads a duration and the reader's word for points. The phone
sheet's header takes the short date, so a long weekday and month no
longer cut off the year at 360 px.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:20:36 +02:00
1 parent 8bcb3e6301
commit 4e7221f59b
4 files changed
+123 -9

No files matched your search

+11 -1
View File
@@ -1,5 +1,6 @@
import type { Locator, Page, TestInfo } from "@playwright/test";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { DAY_DOORS_ANALYTE } from "./setup/day-doors-fixture";
import { mockDay } from "./setup/day-mock";
import {
@@ -203,6 +204,11 @@ test.describe("every chart drawn in days opens its days", () => {
.locator('[data-slot="chart-overlay-comparison-lastMonth"]')
.click();
await page.keyboard.press("Escape");
// The overlay menu is closed before the chart is touched, so the tap
// lands on the chart and not on the menu's outside-dismiss.
await expect(
page.locator('[data-slot="chart-overlay-controls-content"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="chart-compare-caption"]').first(),
).toBeAttached();
@@ -265,9 +271,13 @@ test.describe("the dashboard and the list of all values", () => {
}, testInfo) => {
test.skip(isPhone(testInfo), "docked from 1280 px");
await page.setViewportSize({ width: 1440, height: 900 });
// The launcher follows the `coach` capability, which needs a provider
// the suite cannot reach, so the capability reads available here; this
// journey is about where the button sits, not what it opens.
await serveAiBlock(page, aiBlockAvailable());
await page.goto("/insights/weight");
const fab = page.locator('[data-slot="coach-fab"]');
test.skip((await fab.count()) === 0, "no Coach on this account");
await expect(fab).toBeVisible();
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import deMessages from "../../../../messages/de.json";
import enMessages from "../../../../messages/en.json";
/**
* A day's values read as their charts read them. The day route sends a
* night's length in "min" and a device score in "score"; the first read as a
* bare 412 and the second as an untranslated "score" before both were
* spelled the way the sleep and recovery charts spell them.
*/
vi.mock("@/hooks/use-unit-display", async () => {
const { unitDisplayFor } =
await import("@/__tests__/helpers/unit-display-mock");
return { useUnitDisplay: () => unitDisplayFor("metric") };
});
import { I18nProvider } from "@/lib/i18n/context";
import { useDayValueFormat } from "../use-day-value-format";
function Probe({
type,
value,
unit,
}: {
type: string;
value: number;
unit: string;
}) {
const { formatTile } = useDayValueFormat();
const shown = formatTile({
key: type,
values: [{ type, value, unit, at: "", source: "", band: null }],
});
return (
<span>
{shown.value}|{shown.unit}
</span>
);
}
function read(
type: string,
value: number,
unit: string,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider
initialLocale={locale}
initialMessages={locale === "de" ? deMessages : enMessages}
>
<Probe type={type} value={value} unit={unit} />
</I18nProvider>,
)
.replace(/<[^>]+>/g, "")
.replace(/<!-- -->/g, "");
}
describe("a day's values in their charts' spelling", () => {
it("reads a night sent in min as a duration", () => {
const [value, unit] = read("SLEEP_DURATION", 412, "min").split("|");
expect(value).toMatch(/6\s*h/);
expect(value).toMatch(/52/);
expect(unit).toBe("");
});
it("reads a device score in the reader's word for points", () => {
// German, where the wire's "score" and the chart's "Punkte" differ.
const [value, unit] = read("ANS_CHARGE", 8, "score", "de").split("|");
expect(value).toBe("8");
expect(unit).toBe("Punkte");
});
});
+17 -4
View File
@@ -54,14 +54,16 @@ import { useDayValueFormat } from "./use-day-value-format";
*/
/** "Saturday, 3 January 2026", for the header and the spoken name. */
export function useLongDayLabel(): (date: DateKey) => string {
export function useLongDayLabel(
length: "long" | "short" = "long",
): (date: DateKey) => string {
const { locale } = useTranslations();
const intl = resolveIntlLocale(locale);
return (date: DateKey) =>
new Intl.DateTimeFormat(intl, {
weekday: "long",
weekday: length,
day: "numeric",
month: "long",
month: length,
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T12:00:00.000Z`));
@@ -118,6 +120,10 @@ export function DayView({
}: DayViewProps) {
const { t, tCount } = useTranslations();
const longLabel = useLongDayLabel();
// "Mo., 5. Okt. 2026" in the phone sheet's header: a September Thursday
// spelled out in full does not fit beside the arrows at 360 px, and a
// date cut off before its year reads as the wrong day.
const shortLabel = useLongDayLabel("short");
const pathname = usePathname();
const day = useDay(date);
const { labelFor, formatTile } = useDayValueFormat();
@@ -218,7 +224,14 @@ export function DayView({
tabIndex={-1}
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{longLabel(date)}
{compact ? (
<>
<span aria-hidden="true">{shortLabel(date)}</span>
<span className="sr-only">{longLabel(date)}</span>
</>
) : (
longLabel(date)
)}
</Title>
<Button
type="button"
+18 -4
View File
@@ -27,6 +27,19 @@ const WHOLE_NUMBER_TYPES: ReadonlySet<string> = new Set([
"RESPIRATORY_RATE",
]);
/**
* Two spellings of one unit: the day route sends a duration in "min", the
* client's unit map knows it as "minutes". Read as the same unit, so a night
* reads as "6 h 52 min" and not as a bare 412.
*/
const UNIT_ALIASES: Readonly<Record<string, string>> = { min: "minutes" };
/** True when the row carries the type's canonical unit, in either spelling. */
function isCanonicalUnit(row: Pick<DayValue, "type" | "unit">): boolean {
const canonical = getUnitForType(row.type);
return row.unit === canonical || UNIT_ALIASES[row.unit] === canonical;
}
/** True for a type read in whole numbers (counts, pressures, pulses). */
export function isWholeNumberType(type: string): boolean {
return WHOLE_NUMBER_TYPES.has(type);
@@ -68,7 +81,7 @@ export function useDayValueFormat() {
const number = useCallback(
(row: Pick<DayValue, "type" | "unit">, raw: number): string => {
const canonical = row.unit === getUnitForType(row.type);
const canonical = isCanonicalUnit(row);
const shown = canonical ? units.toDisplay(row.type, raw) : raw;
if (row.type === "SLEEP_DURATION" && canonical) {
return formatDurationMinutes(shown, t);
@@ -89,10 +102,11 @@ export function useDayValueFormat() {
const unitFor = useCallback(
(row: Pick<DayValue, "type" | "unit">): string => {
if (row.type === "SLEEP_DURATION") return "";
if (row.unit === getUnitForType(row.type)) return units.unitFor(row.type);
return row.unit;
const unit = isCanonicalUnit(row) ? units.unitFor(row.type) : row.unit;
// A device score reads in points, as on its chart.
return unit === "score" ? t("insights.deviceScore.unitScore") : unit;
},
[units],
[t, units],
);
const formatTile = useCallback(