mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(workouts): render workout dates in the profile timezone
The workout list, the workout header and the dashboard tile formatted instants through the shared formatter cache without a timeZone, so they followed the browser's zone. The year comparison in the list used the browser's calendar as well. All three now take the profile zone, and the timezone guard also matches getDateTimeFormat(locale, options) calls that leave the zone out.
This commit is contained in:
1 parent
01bf8fbb64
commit
ff3559c169
6 files changed
+72
-12
No files matched your search
@@ -51,6 +51,11 @@ const ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
|
||||
const FORMATTER_PATTERNS = [
|
||||
/new Intl\.DateTimeFormat\s*\(/g,
|
||||
/\.toLocale(?:Date|Time)String\s*\(/g,
|
||||
// The shared cache's own entry point: it forwards its options straight to
|
||||
// `Intl.DateTimeFormat`, so a call that omits the zone is a bare formatter
|
||||
// exactly like the constructor form (the workout surfaces did this until
|
||||
// v1.43.6).
|
||||
/\bgetDateTimeFormat\s*\(/g,
|
||||
];
|
||||
|
||||
/** The argument list starting at the `(` at or after `from`. */
|
||||
@@ -68,12 +73,30 @@ function argumentList(source: string, from: number): string {
|
||||
return source.slice(open);
|
||||
}
|
||||
|
||||
/** Number of top-level, comma-separated arguments in `(…)`. */
|
||||
function topLevelArgCount(args: string): number {
|
||||
let depth = 0;
|
||||
let count = 0;
|
||||
let sawContent = false;
|
||||
for (const ch of args.slice(1, -1)) {
|
||||
if ("([{".includes(ch)) depth += 1;
|
||||
else if (")]}".includes(ch)) depth -= 1;
|
||||
else if (ch === "," && depth === 0) {
|
||||
count += 1;
|
||||
sawContent = false;
|
||||
continue;
|
||||
}
|
||||
if (!/\s/.test(ch)) sawContent = true;
|
||||
}
|
||||
return sawContent ? count + 1 : count;
|
||||
}
|
||||
|
||||
function bareFormatterSites(): Array<{ file: string; line: number }> {
|
||||
const listed = execFileSync(
|
||||
"grep",
|
||||
[
|
||||
"-rlE",
|
||||
"new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(",
|
||||
"new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(|getDateTimeFormat\\(",
|
||||
"src",
|
||||
"--include=*.ts",
|
||||
"--include=*.tsx",
|
||||
@@ -105,6 +128,15 @@ function bareFormatterSites(): Array<{ file: string; line: number }> {
|
||||
// of them and the honest response would be to allowlist them — which
|
||||
// is how a guard stops guarding.
|
||||
if (/\btimeZone\s*(?::|,|\}|$)/m.test(args)) continue;
|
||||
// `getDateTimeFormat(locale, tz, options)` takes the zone as its own
|
||||
// argument; only the two-argument `(locale, options)` form can leave
|
||||
// it out.
|
||||
if (m[0].startsWith("getDateTimeFormat") && topLevelArgCount(args) >= 3)
|
||||
continue;
|
||||
// A mention in a comment renders nothing.
|
||||
const lineStart = source.lastIndexOf("\n", m.index) + 1;
|
||||
if (/^\s*(?:\/\/|\*|\/\*)/.test(source.slice(lineStart, m.index)))
|
||||
continue;
|
||||
hits.push({
|
||||
file,
|
||||
line: source.slice(0, m.index).split("\n").length,
|
||||
@@ -125,7 +157,7 @@ describe("date formatters name the calendar they render in", () => {
|
||||
"grep",
|
||||
[
|
||||
"-rlE",
|
||||
"new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(",
|
||||
"new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(|getDateTimeFormat\\(",
|
||||
"src",
|
||||
"--include=*.ts",
|
||||
"--include=*.tsx",
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDisplayTimezone, 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";
|
||||
@@ -32,9 +32,10 @@ import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
* round-trip per session.
|
||||
*/
|
||||
|
||||
function formatDayLabel(iso: string, locale: string): string {
|
||||
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",
|
||||
@@ -61,6 +62,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
|
||||
export function RecentWorkoutsTile() {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const { data, isError, refetch } = useWorkouts({ limit: 3 });
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
@@ -145,7 +147,7 @@ export function RecentWorkoutsTile() {
|
||||
>
|
||||
{renderRow(workout, sportName)}
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatDayLabel(workout.startedAt, locale)}
|
||||
{formatDayLabel(workout.startedAt, locale, timeZone)}
|
||||
</span>
|
||||
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
|
||||
{formatDurationMinutes(
|
||||
|
||||
@@ -126,16 +126,19 @@ export function formatDateRange(
|
||||
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),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTimeFormatPreference,
|
||||
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";
|
||||
@@ -32,6 +36,7 @@ function renderSportIconBadge(sportType: string) {
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const { preference } = useUnitDisplay();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
@@ -68,6 +73,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,11 @@ import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useTimeFormatPreference,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
@@ -40,12 +44,16 @@ 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 showYear = d.getFullYear() !== new Date().getFullYear();
|
||||
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",
|
||||
@@ -75,6 +83,7 @@ export function WorkoutList({
|
||||
}: WorkoutListProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const { preference } = useUnitDisplay();
|
||||
|
||||
return (
|
||||
@@ -141,7 +150,12 @@ 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)}
|
||||
{formatDate(
|
||||
workout.startedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
)}
|
||||
</DayLinkAt>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -124,9 +124,12 @@ function asDate(value: DateInput): Date {
|
||||
* lookup is cheap.
|
||||
*/
|
||||
function isCurrentYearInTz(date: Date, tz: string, localeTag: string): boolean {
|
||||
const yearOnly = { timeZone: tz, year: "numeric" as const };
|
||||
const target = getDateTimeFormat(localeTag, yearOnly).format(date);
|
||||
const now = getDateTimeFormat(localeTag, yearOnly).format(new Date());
|
||||
const yearIn = (value: Date) =>
|
||||
getDateTimeFormat(localeTag, { timeZone: tz, year: "numeric" }).format(
|
||||
value,
|
||||
);
|
||||
const target = yearIn(date);
|
||||
const now = yearIn(new Date());
|
||||
return target === now;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user