mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
8bcb3e6301
commit
4e7221f59b
4 files changed
+123
-9
No files matched your search
+11
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user