mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge v1.25.3: honor the 24h time-format preference app-wide
This commit is contained in:
commit
d0eced41f9
9 files changed
+131
-26
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user