diff --git a/src/components/Routines/screens/Detail/RoutineDetail.tsx b/src/components/Routines/screens/Detail/RoutineDetail.tsx index 9f97e004c..88c2fc826 100644 --- a/src/components/Routines/screens/Detail/RoutineDetail.tsx +++ b/src/components/Routines/screens/Detail/RoutineDetail.tsx @@ -10,11 +10,11 @@ import i18n from "@/i18n"; import React from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { dateToLocale } from "@/core/lib/date"; +import { formatNaiveDate } from "@/core/lib/date"; import { makeLink, WgerLink } from "@/core/lib/url"; export const RoutineDetail = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const params = useParams<{ routineId: string }>(); const routineId = parseInt(params.routineId ?? ''); if (Number.isNaN(routineId)) { @@ -25,7 +25,7 @@ export const RoutineDetail = () => { const routineQuery = useRoutineDetailQuery(routineId); const routine = routineQuery.data; - const subtitle = routine !== undefined ? `${dateToLocale(routine!.start)} - ${dateToLocale(routine!.end)} (${routine?.durationText})` : ''; + const subtitle = routine !== undefined ? `${formatNaiveDate(routine!.start, i18n.language)} - ${formatNaiveDate(routine!.end, i18n.language)} (${routine?.durationText})` : ''; const chip = routine?.isTemplate ? : null; diff --git a/src/components/Routines/screens/Detail/TemplateDetail.tsx b/src/components/Routines/screens/Detail/TemplateDetail.tsx index 43dfee932..d506557a4 100644 --- a/src/components/Routines/screens/Detail/TemplateDetail.tsx +++ b/src/components/Routines/screens/Detail/TemplateDetail.tsx @@ -8,7 +8,7 @@ import { DayDetailsCard } from "@/components/Routines/widgets/RoutineDetailsCard import React from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { dateToLocale } from "@/core/lib/date"; +import { formatNaiveDate } from "@/core/lib/date"; import { makeLink, WgerLink } from "@/core/lib/url"; export const TemplateDetail = () => { @@ -34,7 +34,7 @@ export const TemplateDetail = () => { mainContent={ - {dateToLocale(routine!.start)} - {dateToLocale(routine!.end)} ({routine!.durationText}) + {formatNaiveDate(routine!.start, i18n.language)} - {formatNaiveDate(routine!.end, i18n.language)} ({routine!.durationText}) {routine!.description !== '' diff --git a/src/components/Routines/screens/Overview/RoutineOverview.tsx b/src/components/Routines/screens/Overview/RoutineOverview.tsx index e74e6e196..7b06e8a06 100644 --- a/src/components/Routines/screens/Overview/RoutineOverview.tsx +++ b/src/components/Routines/screens/Overview/RoutineOverview.tsx @@ -8,7 +8,7 @@ import { AddRoutineFab } from "@/components/Routines/screens/Overview/Fab"; import { useRoutinesShallowQuery } from "@/components/Routines/queries"; import React from "react"; import { useTranslation } from "react-i18next"; -import { dateToLocale } from "@/core/lib/date"; +import { formatNaiveDate } from "@/core/lib/date"; import { makeLink, WgerLink } from "@/core/lib/url"; export const RoutineList = (props: { @@ -42,7 +42,7 @@ export const RoutineList = (props: { {primaryText} {chipTemplate} {chipVisibility}} - secondary={`${props.routine.durationText} (${dateToLocale(props.routine.start)} - ${dateToLocale(props.routine.end)})`} + secondary={`${props.routine.durationText} (${formatNaiveDate(props.routine.start, i18n.language)} - ${formatNaiveDate(props.routine.end, i18n.language)})`} /> diff --git a/src/core/lib/date.test.ts b/src/core/lib/date.test.ts index 9524f1305..37c581fc4 100644 --- a/src/core/lib/date.test.ts +++ b/src/core/lib/date.test.ts @@ -1,4 +1,4 @@ -import { calculatePastDate, dateTimeToHHMM, dateToYYYYMMDD } from "@/core/lib/date"; +import { calculatePastDate, dateTimeToHHMM, dateToYYYYMMDD, formatNaiveDate } from "@/core/lib/date"; describe("test date utility", () => { @@ -53,4 +53,32 @@ describe('calculatePastDate', () => { const result = calculatePastDate('lastYear', new Date('2023-02-14')); expect(result).toStrictEqual('2022-02-14'); }); +}); + +describe('formatNaiveDate - Timezone-Agnostic Validation', () => { + it('should format standard JS Date objects timezone-agnostically', () => { + // E.g. date parsed in UTC representing "2026-05-12" + const dateObject = new Date('2026-05-12T00:00:00Z'); + + const formattedUS = formatNaiveDate(dateObject, 'en-US'); + expect(formattedUS).toBe('05/12/2026'); + + const formattedIT = formatNaiveDate(dateObject, 'it-IT'); + expect(formattedIT).toBe('12/05/2026'); + }); + + it('should correctly format month boundaries without falling back to previous/next month', () => { + // First day of month + expect(formatNaiveDate(new Date('2026-01-01T00:00:00Z'), 'en-US')).toBe('01/01/2026'); + // Last day of month + expect(formatNaiveDate(new Date('2026-12-31T00:00:00Z'), 'en-US')).toBe('12/31/2026'); + // Leap year date + expect(formatNaiveDate(new Date('2024-02-29T00:00:00Z'), 'en-US')).toBe('02/29/2024'); + }); + + it('should return an empty string for invalid dates, null, or undefined', () => { + expect(formatNaiveDate(null)).toBe(''); + expect(formatNaiveDate(undefined)).toBe(''); + expect(formatNaiveDate(new Date('invalid-date'))).toBe(''); + }); }); \ No newline at end of file diff --git a/src/core/lib/date.ts b/src/core/lib/date.ts index 21f5611db..4f3864819 100644 --- a/src/core/lib/date.ts +++ b/src/core/lib/date.ts @@ -82,6 +82,30 @@ export function dateToLocale(dateTime: Date | null, locale?: string, options?: I return dateTime.toLocaleString(locale ? [locale] : [], options); } +/** + * Formats a standard JS Date object into a localized date format without applying + * local browser timezone translation (i.e. timezone-agnostic). + * + * @param date - The JS Date object representing a naive date + * @param locale - Optional locale for formatting (defaults to active i18n language or 'en-US') + * @returns The formatted date string, or an empty string if the input is invalid + */ +export function formatNaiveDate(date: Date | null | undefined, locale?: string): string { + if (!date || isNaN(date.getTime())) { + return ''; + } + + const resolvedLocale = locale ?? i18n.language ?? 'en-US'; + + // Format explicitly using the UTC timezone and consistent fields + return date.toLocaleDateString(resolvedLocale, { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +} + /* * Converts a date object to a non localized string in the format HH:MM */