mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge: s15-stepsdisplay into release/v1.34.0
This commit is contained in:
commit
2aeb925e28
27 files changed
+716
-39
No files matched your search
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user