mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(workouts): render workout dates through useFormatters
The workout list, the workout detail header and the dashboard's recent workouts tile each rebuilt their own date formatter from the shared cache, with a private copy of the current-year check, and so ignored the reader's date-order preference. They now use useFormatters(), and the copies are gone. The timezone formatter guard now refuses any two-argument getDateTimeFormat call in src/components, so a component cannot build its own display formatter again. The medication card's day bucketing, the one other such call, reads userDayKey instead.
This commit is contained in:
1 parent
862361a224
commit
9fe6f98fdb
8 files changed
+121
-116
No files matched your search
@@ -216,6 +216,78 @@ describe("date formatters name the calendar they render in", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A component renders dates through `useFormatters()`, never through the
|
||||
* shared cache directly.
|
||||
*
|
||||
* The two-argument `getDateTimeFormat(locale, options)` from
|
||||
* `@/lib/intl/formatter-cache` can name a zone and still be wrong: it takes
|
||||
* the locale and the hour cycle from wherever the caller found them and
|
||||
* knows nothing of the reader's date-order preference. The workout surfaces
|
||||
* rebuilt their own formatters this way and showed "Dec 16" to a reader who
|
||||
* had chosen YYYY-MM-DD. The three-argument `(locale, timeZone, options)`
|
||||
* form from `@/lib/tz/intl-cache` builds day keys and clock parts, not
|
||||
* display text, and stays allowed.
|
||||
*/
|
||||
describe("components format dates through useFormatters", () => {
|
||||
const CALL = /\bgetDateTimeFormat\s*\(/g;
|
||||
|
||||
function scan() {
|
||||
const files = execFileSync(
|
||||
"grep",
|
||||
[
|
||||
"-rlE",
|
||||
"getDateTimeFormat\\s*\\(",
|
||||
"src/components",
|
||||
"--include=*.ts",
|
||||
"--include=*.tsx",
|
||||
],
|
||||
{ cwd: REPO_ROOT, encoding: "utf8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((f) => !f.includes("__tests__"));
|
||||
const offenders: string[] = [];
|
||||
let calls = 0;
|
||||
for (const file of files) {
|
||||
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
|
||||
CALL.lastIndex = 0;
|
||||
for (let m = CALL.exec(source); m; m = CALL.exec(source)) {
|
||||
const lineStart = source.lastIndexOf("\n", m.index) + 1;
|
||||
if (/^\s*(?:\/\/|\*|\/\*)/.test(source.slice(lineStart, m.index)))
|
||||
continue;
|
||||
calls += 1;
|
||||
const args = argumentList(source, m.index + m[0].length - 1);
|
||||
if (topLevelArgCount(args) < 3) {
|
||||
offenders.push(
|
||||
`${file}:${source.slice(0, m.index).split("\n").length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { offenders, calls };
|
||||
}
|
||||
|
||||
it("still sees the cache's calls in components (the matcher matches)", () => {
|
||||
expect(scan().calls).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("has no two-argument getDateTimeFormat in src/components", () => {
|
||||
expect(scan().offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matcher flags the two-argument form across lines", () => {
|
||||
const source =
|
||||
'getDateTimeFormat(\n locale,\n { timeZone, month: "short" },\n)';
|
||||
CALL.lastIndex = 0;
|
||||
const m = CALL.exec(source)!;
|
||||
expect(
|
||||
topLevelArgCount(argumentList(source, m.index + m[0].length - 1)),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A stated calendar date must not go through a zone-aware short formatter.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,13 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
// The day label follows the reader's date order through `useFormatters()`.
|
||||
const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD");
|
||||
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
|
||||
useFormatters: () => READER_FORMATTERS,
|
||||
}));
|
||||
|
||||
let mockResult: {
|
||||
data:
|
||||
| {
|
||||
@@ -147,5 +155,8 @@ describe("<RecentWorkoutsTile>", () => {
|
||||
expect(html).toContain("Cycling");
|
||||
expect(html).toContain("30m");
|
||||
expect(html).toContain("1h 0m");
|
||||
expect(html).toContain(
|
||||
READER_FORMATTERS.dateWithWeekdaySmart("2026-05-15T07:00:00Z"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,11 @@ import Link from "next/link";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
@@ -32,16 +31,6 @@ import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
* round-trip per session.
|
||||
*/
|
||||
|
||||
function formatDayLabel(iso: string, locale: string, timeZone: string): string {
|
||||
const d = new Date(iso);
|
||||
return getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
const Icon = iconForSport(workout.sportType);
|
||||
return (
|
||||
@@ -61,8 +50,8 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
}
|
||||
|
||||
export function RecentWorkoutsTile() {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data, isError, error, refetch } = useWorkouts({ limit: 3 });
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
@@ -148,7 +137,7 @@ export function RecentWorkoutsTile() {
|
||||
>
|
||||
{renderRow(workout, sportName)}
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatDayLabel(workout.startedAt, locale, timeZone)}
|
||||
{fmt.dateWithWeekdaySmart(workout.startedAt)}
|
||||
</span>
|
||||
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
|
||||
{formatDurationMinutes(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { WorkoutList } from "../workout-list";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
|
||||
@@ -18,6 +19,14 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
// The reader's date and clock preferences reach the list through
|
||||
// `useFormatters()`; this reader orders dates year first, on a 24-hour clock.
|
||||
const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD");
|
||||
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
|
||||
useFormatters: () => READER_FORMATTERS,
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<WorkoutList>` unit tests.
|
||||
*
|
||||
@@ -54,6 +63,13 @@ describe("<WorkoutList>", () => {
|
||||
expect(html).toContain("30m");
|
||||
});
|
||||
|
||||
it("writes the start in the reader's own date order and clock", () => {
|
||||
const html = render(<WorkoutList workouts={[ROW]} />);
|
||||
expect(html).toContain(
|
||||
`${READER_FORMATTERS.dateWithWeekdaySmart(ROW.startedAt)}, ${READER_FORMATTERS.time(ROW.startedAt)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the distance and energy chips when present", () => {
|
||||
const html = render(<WorkoutList workouts={[ROW]} />);
|
||||
expect(html).toContain("5.20 km");
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getNumberFormat } from "@/lib/intl/formatter-cache";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getQuantityTransform,
|
||||
paceSecondsPerDistanceUnit,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
|
||||
/**
|
||||
* Shared formatters for the workout-detail surface. Pure helpers pulled
|
||||
@@ -120,28 +116,3 @@ export function formatPaceSeconds(secPerUnit: number, unit: string): string {
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")} min/${unit}`;
|
||||
}
|
||||
|
||||
export function formatDateRange(
|
||||
startedAt: string,
|
||||
endedAt: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
timeZone: string,
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
});
|
||||
return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`;
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTimeFormatPreference,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { formatDuration, formatDistance, formatDateRange } from "./format";
|
||||
import { formatDuration, formatDistance } from "./format";
|
||||
|
||||
/**
|
||||
* Hero header — the at-a-glance verdict row. Sport icon badge, source
|
||||
@@ -35,8 +31,7 @@ function renderSportIconBadge(sportType: string) {
|
||||
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const fmt = useFormatters();
|
||||
const { preference } = useUnitDisplay();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
@@ -68,13 +63,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
{sportName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDateRange(
|
||||
workout.startedAt,
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
)}
|
||||
{`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}–${fmt.time(workout.endedAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,9 @@ import Link from "next/link";
|
||||
import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getNumberFormat } from "@/lib/intl/formatter-cache";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTimeFormatPreference,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,30 +32,6 @@ function formatEnergy(kcal: number, locale: string): string {
|
||||
}).format(Math.round(kcal));
|
||||
}
|
||||
|
||||
function formatDate(
|
||||
iso: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
timeZone: string,
|
||||
): string {
|
||||
const d = new Date(iso);
|
||||
// Show the year whenever the workout is not from the current year, so a
|
||||
// "Do., 16. Dez." from a previous year is never mistaken for this year's.
|
||||
const yearOf = (value: Date) =>
|
||||
getDateTimeFormat(locale, { timeZone, year: "numeric" }).format(value);
|
||||
const showYear = yearOf(d) !== yearOf(new Date());
|
||||
return getDateTimeFormat(locale, {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
...(showYear ? { year: "numeric" as const } : {}),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export interface WorkoutListProps {
|
||||
workouts: WorkoutListEntry[];
|
||||
className?: string;
|
||||
@@ -82,8 +50,9 @@ export function WorkoutList({
|
||||
onLoadMore,
|
||||
}: WorkoutListProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const timeZone = useDisplayTimezone();
|
||||
// The reader's date order, hour cycle and zone, as everywhere else; the
|
||||
// year shows whenever the workout is not from this year.
|
||||
const fmt = useFormatters();
|
||||
const { preference } = useUnitDisplay();
|
||||
|
||||
return (
|
||||
@@ -150,12 +119,7 @@ export function WorkoutList({
|
||||
stays readable beside the stats column. */}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<DayLinkAt at={workout.startedAt} className="z-10">
|
||||
{formatDate(
|
||||
workout.startedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
)}
|
||||
{`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}`}
|
||||
</DayLinkAt>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati
|
||||
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
|
||||
@@ -38,7 +37,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -381,22 +380,16 @@ export function MedicationCard({
|
||||
: null;
|
||||
|
||||
function formatLastTakenAt(value: string): string {
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
// The actual user-facing display goes through formatTime / formatDateTime.
|
||||
const dayFormatter = getDateTimeFormat("en-CA", {
|
||||
timeZone: userTz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
// Day keys (YYYY-MM-DD) in the profile zone, string-comparable for the
|
||||
// today / yesterday / older bucketing below. The user-facing display goes
|
||||
// through formatTime / formatDateTime.
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const intakeDay = dayFormatter.format(new Date(value));
|
||||
const todayDay = dayFormatter.format(now);
|
||||
const yesterdayDay = dayFormatter.format(yesterday);
|
||||
const intakeDay = userDayKey(new Date(value), userTz);
|
||||
const todayDay = userDayKey(now, userTz);
|
||||
const yesterdayDay = userDayKey(yesterday, userTz);
|
||||
const time = formatTime(value);
|
||||
|
||||
if (intakeDay === todayDay) return `${t("medications.today")}, ${time}`;
|
||||
|
||||
Reference in new issue
Block a user