Merge v1.25.3: honor the 24h time-format preference app-wide

This commit is contained in:
Marc-André Bombeck committed 2026-06-29 15:49:52 +02:00
commit d0eced41f9
9 files changed
+131 -26

No files matched your search

+5 -2
View File
@@ -11,7 +11,8 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { hourCycleOptions } from "@/lib/format-locale";
import { STAGE_COLORS } from "./sleep-stage-stacked-bar";
/**
@@ -112,6 +113,7 @@ function formatMinutes(total: number, locale: string): string {
export function SleepHypnogram({ session }: SleepHypnogramProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const stageLabels: Record<string, string> = {
DEEP: t("insights.sleep.stages.deep"),
@@ -192,8 +194,9 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
}),
[locale],
[locale, timeFormat],
);
// Per-stage breakdown with percentage of the night's total tracked time.
+14 -2
View File
@@ -17,7 +17,11 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
/**
@@ -103,6 +107,7 @@ function formatDateRange(
startedAt: string,
endedAt: string,
locale: string,
timeFormat: TimeFormatPreference,
): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
@@ -115,6 +120,7 @@ function formatDateRange(
const timeFmt = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
}
@@ -139,6 +145,7 @@ function renderSportIconBadge(sportType: string) {
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
@@ -158,7 +165,12 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(workout.startedAt, workout.endedAt, locale)}
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
)}
</p>
</div>
<div className="flex flex-col items-end text-right">
+13 -3
View File
@@ -13,7 +13,11 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
/**
@@ -76,7 +80,11 @@ function formatEnergy(kcal: number, locale: string): string {
}).format(Math.round(kcal));
}
function formatDate(iso: string, locale: string): string {
function formatDate(
iso: string,
locale: string,
timeFormat: TimeFormatPreference,
): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(locale, {
weekday: "short",
@@ -84,6 +92,7 @@ function formatDate(iso: string, locale: string): string {
month: "short",
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
}).format(d);
}
@@ -94,6 +103,7 @@ export interface WorkoutListProps {
export function WorkoutList({ workouts, className }: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
return (
<ul
@@ -129,7 +139,7 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{sportName}</span>
<span className="text-muted-foreground truncate text-xs">
{formatDate(workout.startedAt, locale)}
{formatDate(workout.startedAt, locale, timeFormat)}
</span>
</div>
<div className="flex flex-col items-end gap-0.5 text-xs">
@@ -13,7 +13,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
@@ -54,6 +54,7 @@ export function SecurityActivityCard({
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
// Collapsed by default — the feed opens only on demand, keeping the security
// surface skimmable. UI-only state; nothing is persisted across reloads.
const [open, setOpen] = useState(false);
@@ -138,7 +139,7 @@ export function SecurityActivityCard({
</p>
</div>
<p className="text-muted-foreground shrink-0 text-xs">
{new Date(e.createdAt).toLocaleString()}
{fmt.dateTime(e.createdAt)}
</p>
</li>
))}
@@ -15,7 +15,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
@@ -36,6 +36,7 @@ export function SecuritySessionsCard({
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
// Collapsed by default — the list opens only when the user asks for it, so
@@ -158,7 +159,7 @@ export function SecuritySessionsCard({
{s.lastActiveAt && (
<p className="text-muted-foreground text-xs">
{t("settings.security.lastActive", {
time: new Date(s.lastActiveAt).toLocaleString(),
time: fmt.dateTime(s.lastActiveAt),
})}
</p>
)}
+1 -14
View File
@@ -10,6 +10,7 @@ import {
useTimeFormatPreference,
} from "@/lib/i18n/context";
import { formatDate, resolveDateLocale } from "@/lib/date-format";
import { hourCycleOptions } from "@/lib/format-locale";
/**
* Dependency-free datetime input that DISPLAYS the value in the user's
@@ -221,20 +222,6 @@ function formatLocalDateTime(
return `${datePart} ${timePart}`;
}
/** Mirror of `format-locale.ts` `hourCycleOptions`, kept local to the field. */
function hourCycleOptions(
timeFormat: ReturnType<typeof useTimeFormatPreference>,
): Intl.DateTimeFormatOptions {
switch (timeFormat) {
case "H12":
return { hour12: true };
case "H24":
return { hourCycle: "h23" };
default:
return {};
}
}
/** Split a local `yyyy-MM-ddTHH:mm` string into its calendar parts. */
function parseLocalDateTime(value: string): {
date: string;
+47
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
hourCycleOptions,
makeFormatters,
parseLocaleFromAcceptLanguage,
resolveIntlLocale,
@@ -137,3 +138,49 @@ describe("makeFormatters", () => {
expect(tokyo.date(lateUtc)).toMatch(/11/);
});
});
// v1.25.3 — the time-format preference must reach every hour/minute renderer,
// not only the `makeFormatters().time` path. Some surfaces build their own
// `Intl.DateTimeFormat` because they render in the browser timezone (chart
// axes) or combine weekday + time in one label (workout list/detail). They now
// spread `hourCycleOptions(preference)` into the options. These tests pin both
// the helper contract and the exact call-site pattern, so a future renderer
// that drops the spread (re-introducing AM/PM under H24) fails here.
describe("hourCycleOptions", () => {
it("AUTO contributes nothing (locale default applies)", () => {
expect(hourCycleOptions("AUTO")).toEqual({});
});
it("H12 forces a 12-hour clock", () => {
expect(hourCycleOptions("H12")).toEqual({ hour12: true });
});
it("H24 forces the 24-hour h23 cycle", () => {
expect(hourCycleOptions("H24")).toEqual({ hourCycle: "h23" });
});
// The shared call-site shape used by sleep-hypnogram, workout-list and
// workout-detail: an `en-US` formatter (AM/PM by default) with hour+minute.
const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
const buildLabel = (preference: Parameters<typeof hourCycleOptions>[0]) =>
new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(preference),
}).format(sample);
it("an en-US hour/minute formatter drops AM/PM under H24", () => {
const label = buildLabel("H24");
expect(label).not.toMatch(/[AP]M/i);
expect(label).toBe("16:30");
});
it("an en-US hour/minute formatter keeps AM/PM under H12", () => {
expect(buildLabel("H12")).toMatch(/PM/);
});
it("an en-US hour/minute formatter follows the locale (AM/PM) under AUTO", () => {
expect(buildLabel("AUTO")).toMatch(/PM/);
});
});
+10 -1
View File
@@ -61,8 +61,17 @@ export type DateFormatPreference = "AUTO" | "DMY" | "MDY" | "YMD";
/**
* Intl options for the requested hour cycle. AUTO contributes nothing so
* `Intl.DateTimeFormat` falls back to the locale default.
*
* Exported so call sites that build their own `Intl.DateTimeFormat` (chart
* axes, list rows that deliberately render in the browser timezone rather
* than the profile zone) can still honour the user's H12 / H24 preference by
* spreading these options. Pair it with `useTimeFormatPreference()` on the
* client. Any new hour/minute renderer must route through this rather than
* re-deciding the cycle locally — that is the regression this single source
* prevents (an `en`-locale or AUTO user got AM/PM even with H24 selected
* wherever a formatter omitted the cycle).
*/
function hourCycleOptions(
export function hourCycleOptions(
timeFormat: TimeFormatPreference,
): Intl.DateTimeFormatOptions {
switch (timeFormat) {
@@ -5,6 +5,7 @@
import { describe, expect, it } from "vitest";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { makeFormatters } from "@/lib/format-locale";
const t = (key: string, params?: Record<string, string | number>): string => {
if (key === "insights.updatedTodayAt")
@@ -54,4 +55,38 @@ describe("formatUpdatedLabel", () => {
"",
);
});
// v1.25.3 — the briefing freshness label (and the per-metric / narrative
// cards) feed `formatUpdatedLabel` the preference-aware `fmt.time`. With H24
// selected the "today" caption must never carry AM/PM, even for an en user
// whose locale default is 12-hour.
it("renders today's time without AM/PM when fed an H24 formatter", () => {
const fmt = makeFormatters("en", "UTC", "H24");
const now = new Date();
const out = formatUpdatedLabel(
now.toISOString(),
t,
fmt.dateShort,
fmt.time,
"UTC",
);
expect(out.startsWith("Updated today, ")).toBe(true);
expect(out).not.toMatch(/[AP]M/i);
});
it("renders today's time with AM/PM when fed an H12 formatter", () => {
const fmt = makeFormatters("en", "UTC", "H12");
// A fixed afternoon instant so the assertion is deterministic regardless
// of when the suite runs; bucketed as "today" via the UTC day boundary.
const today = new Date();
today.setUTCHours(14, 30, 0, 0);
const out = formatUpdatedLabel(
today.toISOString(),
t,
fmt.dateShort,
fmt.time,
"UTC",
);
expect(out).toMatch(/PM/);
});
});