merge: s15-stepsdisplay into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:34 +02:00
commit 2aeb925e28
27 files changed
+716 -39

No files matched your search

+1 -1
View File
@@ -1337,7 +1337,7 @@ export function HealthChart({
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value));
return computePaddedYDomain(values);
return computePaddedYDomain(values, types);
}, [
chartDataWithCompare,
effectiveCompareBaseline,
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
INTEGER_VALUED_MEASUREMENT_TYPES,
metricFractionDigits,
DEFAULT_METRIC_FRACTION_DIGITS,
} from "@/lib/measurements/value-domain";
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
/**
* GUARD — an integer-valued metric never renders a fractional part.
*
* The step sub-page read "MIN 104.0 steps" and "your usual range is
* …–13,387.6 steps". Precision was a per-page prop defaulting to one
* decimal: the pulse page remembered to pass 0, the step page did not, and
* a tenth of a step was rendered. Every page remembering this individually
* IS the defect — so the assertions below pin that the METRIC answers, and
* that a page which passes nothing still renders a count as a count.
*
* Both surfaces on the reported card are covered: the Min/Max stat strip and
* the coach-read strip underneath it.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="healthkit-chart-stub" />;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/insights/steps",
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
// The coach-read strip paints nothing until it is client-mounted (React
// #418). Force the mounted branch so the band sentence reaches the markup.
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
const coachReadMock = vi.fn();
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return { ...actual, useQuery: () => coachReadMock() };
});
import { CoachReadStrip } from "../derived/coach-read-strip";
import InsightsStepsPage from "@/app/insights/steps/page";
function render(node: React.ReactNode, locale: "en" | "de" = "de") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
/** The band and the reading the reporter saw, to the digit. */
const REPORTED_BAND: CoachReadStripData = {
learning: false,
baseline: {
low: 104,
high: 13387.6,
latest: 8213.4,
placement: "within",
},
driver: null,
} as unknown as CoachReadStripData;
/**
* A German-locale decimal on a count: "104,0", "13.387,6". The Latin-1
* comma-decimal shape is what the reporter photographed.
*/
const FRACTIONAL_GERMAN = /\d,\d/;
beforeEach(() => {
analyticsMock.mockReset();
coachReadMock.mockReset();
});
describe("integer-metric precision guard", () => {
it("resolves zero decimals for every discrete metric", () => {
expect(INTEGER_VALUED_MEASUREMENT_TYPES.size).toBeGreaterThan(10);
for (const type of INTEGER_VALUED_MEASUREMENT_TYPES) {
expect(metricFractionDigits(type), type).toBe(0);
}
// A continuous metric keeps the shared default; an unknown one too.
expect(metricFractionDigits("PULSE")).toBe(DEFAULT_METRIC_FRACTION_DIGITS);
expect(metricFractionDigits("WEIGHT")).toBe(DEFAULT_METRIC_FRACTION_DIGITS);
expect(metricFractionDigits(null)).toBe(DEFAULT_METRIC_FRACTION_DIGITS);
});
it("renders the coach-read band without a fractional step", () => {
coachReadMock.mockReturnValue({ data: REPORTED_BAND });
const html = render(
<CoachReadStrip metricType="ACTIVITY_STEPS" unit="Schritte" />,
);
expect(html).toContain('data-slot="coach-read-baseline"');
expect(html).not.toMatch(FRACTIONAL_GERMAN);
// The band edges are still there, rounded to whole steps.
expect(html).toContain("13.388");
expect(html).toContain("104");
});
it("keeps the decimal for a metric that is not a count", () => {
coachReadMock.mockReturnValue({
data: {
learning: false,
baseline: { low: 71.4, high: 84.2, latest: 78.6, placement: "within" },
driver: null,
} as unknown as CoachReadStripData,
});
const html = render(<CoachReadStrip metricType="WEIGHT" unit="kg" />);
expect(html).toMatch(FRACTIONAL_GERMAN);
});
it("renders the step page stat strip without a fractional step", () => {
// The page passes no precision prop at all — that is the point.
analyticsMock.mockReturnValue({
data: {
summaries: {
ACTIVITY_STEPS: {
count: 214,
latest: 8213,
min: 104,
max: 11762,
mean: 6431.72,
median: 6802.5,
},
},
},
isEmpty: false,
isLoading: false,
});
coachReadMock.mockReturnValue({ data: undefined });
const html = render(<InsightsStepsPage />);
expect(html).toContain('data-slot="metric-stat-strip"');
expect(html).not.toMatch(FRACTIONAL_GERMAN);
// The numbers themselves survive, thousands-grouped and whole.
expect(html).toContain("11.762");
expect(html).toContain("104");
// …and the unit reads in German, not as the English word. (The route
// slug `/insights/steps` legitimately appears in the header links, so
// match the unit in its rendered position: right after a number.)
expect(html).toContain("104 Schritte");
expect(html).not.toMatch(/\d\s*steps\b/);
});
});
@@ -8,6 +8,7 @@ import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { apiGet } from "@/lib/api/api-fetch";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
@@ -38,9 +39,12 @@ export interface CoachReadStripProps {
/** Unit suffix rendered next to the band edges + today's value. */
unit: string;
/**
* Decimal precision for the formatted numbers. Defaults to 1 — enough for
* weight (78.4) / HRV (41.5); integer metrics drop the trailing zero via
* `Intl.NumberFormat`. Pages pass 0 for integer-only metrics (BP, steps).
* Decimal precision for the formatted numbers. Optional: when omitted the
* precision follows the METRIC (`metricFractionDigits`) — a discrete metric
* resolves 0, everything else the shared default of 1. It used to default
* to a flat 1 and rely on each page remembering to pass 0, which is how a
* step page came to print "104.0 steps". Pass it only to override the
* metric's own answer.
*/
fractionDigits?: number;
/**
@@ -55,9 +59,10 @@ export interface CoachReadStripProps {
export function CoachReadStrip({
metricType,
unit,
fractionDigits = 1,
fractionDigits,
valueScale = 1,
}: CoachReadStripProps) {
const digits = fractionDigits ?? metricFractionDigits(metricType);
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const mounted = useMounted();
@@ -89,7 +94,7 @@ export function CoachReadStrip({
const fmt = (value: number): string =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
maximumFractionDigits: digits,
}).format(
transformed
? unitDisplay.toDisplay(metricType, value)
@@ -14,6 +14,7 @@ import { useTranslations } from "@/lib/i18n/context";
import type { InsightMetric } from "@/lib/insights/metric-availability";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Card, CardContent } from "@/components/ui/card";
@@ -98,6 +99,17 @@ export interface HealthKitMetricPageProps {
* transformed types omit these props; everything else still passes `unit`.
*/
unit?: string;
/**
* i18n key for a unit that is a WORD rather than a symbol — `steps`,
* `flights`, `falls`, `years`, `events`. Resolved through `t()` so it
* renders in the reader's language; a German user was shown "13.387,6
* steps". Symbols (`bpm`, `mmHg`, `%`, `m/s`, `kg/m²`, …) are language-
* neutral and stay literals in `unit` — translating a symbol would be the
* opposite mistake. Wins over `unit` / `yAxisUnit` for both the chart and
* the axis label; a type with a registered display transform still
* overrides everything.
*/
unitKey?: string;
/** Optional y-axis label shown above the unit. */
yAxisUnit?: string;
/** Optional value bands (Apple-Health-style target zone shading). */
@@ -206,6 +218,7 @@ export function HealthKitMetricPage({
i18nPrefix,
color,
unit,
unitKey,
yAxisUnit,
valueBands,
emptyStateCtaType,
@@ -264,13 +277,24 @@ export function HealthKitMetricPage({
const transform = unitDisplay.isTransformed(effectiveType)
? unitDisplay.transformFor(effectiveType)
: null;
const resolvedUnit = transform ? transform.displayUnit : unit;
const resolvedYAxisUnit = transform ? transform.displayUnit : yAxisUnit;
// A word-shaped unit arrives as an i18n key and is resolved here, once, for
// the chart line, the axis label, the stat strip and the coach-read strip.
const localisedUnit = unitKey ? t(unitKey) : undefined;
const resolvedUnit = transform
? transform.displayUnit
: (localisedUnit ?? unit);
const resolvedYAxisUnit = transform
? transform.displayUnit
: (localisedUnit ?? yAxisUnit);
const resolvedScale = transform ? transform.factor : (valueScale ?? 1);
const resolvedOffset = transform ? (transform.offset ?? 0) : 0;
// Precision follows the METRIC, not the page. A discrete metric renders no
// fractional part even when the page says nothing — the steps page said
// nothing and its Min read "104.0 steps". An explicit `statFractionDigits`
// still wins for the metrics that genuinely need one (mmol/L, skin temp).
const resolvedFractionDigits = transform
? transform.decimals
: statFractionDigits;
: (statFractionDigits ?? metricFractionDigits(effectiveType));
// Value bands are absolute bounds → affine-convert (factor + offset) so the
// shaded zone tracks the converted line. Metric users get the identity.
const resolvedBands =