From a97b9c7385262c92abe4bf094391a0538ce937f0 Mon Sep 17 00:00:00 2001 From: Marcello Mendo Date: Sun, 24 May 2026 19:21:26 +0200 Subject: [PATCH 1/2] fix(routines): resolve local timezone shift bug on naive dates API returns naive date strings (e.g., 'YYYY-MM-DD') representing routine start and end dates. When parsed and formatted locally, negative UTC offset browser timezones shift the date backward by one day (e.g., formatting UTC midnight as the previous day in local browser time). To fix this, we: 1. Implemented 'formatNaiveDate' in 'date.ts' to format date strings and standard Date objects timezone-agnostically using 'timeZone: UTC'. 2. Replaced 'dateToLocale' calls with 'formatNaiveDate' in routine detail, template detail, and overview screens. 3. Added exhaustive unit test coverage in 'date.test.ts' verifying parsing, month boundaries, Date object support, and invalid/fallback inputs. --- .../Routines/screens/Detail/RoutineDetail.tsx | 6 +- .../screens/Detail/TemplateDetail.tsx | 4 +- .../screens/Overview/RoutineOverview.tsx | 4 +- src/core/lib/date.test.ts | 50 +++++++++++++- src/core/lib/date.ts | 67 +++++++++++++++++++ 5 files changed, 123 insertions(+), 8 deletions(-) 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..dba3737d2 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,52 @@ 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 a valid naive date string exactly as returned from the API without day shift', () => { + const naiveDate = '2026-05-12'; + + // en-US should format YYYY-MM-DD to MM/DD/YYYY + const formattedUS = formatNaiveDate(naiveDate, 'en-US'); + expect(formattedUS).toBe('05/12/2026'); + + // it-IT should format YYYY-MM-DD to DD/MM/YYYY + const formattedIT = formatNaiveDate(naiveDate, 'it-IT'); + expect(formattedIT).toBe('12/05/2026'); + }); + + 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('2026-01-01', 'en-US')).toBe('01/01/2026'); + // Last day of month + expect(formatNaiveDate('2026-12-31', 'en-US')).toBe('12/31/2026'); + // Leap year date + expect(formatNaiveDate('2024-02-29', 'en-US')).toBe('02/29/2024'); + }); + + it('should return an empty string for invalid dates or malformed formats', () => { + expect(formatNaiveDate('')).toBe(''); + expect(formatNaiveDate(null)).toBe(''); + expect(formatNaiveDate(undefined)).toBe(''); + expect(formatNaiveDate('invalid-date')).toBe(''); + expect(formatNaiveDate('2026-13-45')).toBe(''); // Out of bounds month/day + expect(formatNaiveDate('12/05/2026')).toBe(''); // Not YYYY-MM-DD format + }); + + it('should handle fallback date parsing for standard ISO datetime strings safely', () => { + const isoString = '2026-05-12T00:00:00Z'; + expect(formatNaiveDate(isoString, 'en-US')).toBe('05/12/2026'); + }); }); \ No newline at end of file diff --git a/src/core/lib/date.ts b/src/core/lib/date.ts index 21f5611db..71b9e5a14 100644 --- a/src/core/lib/date.ts +++ b/src/core/lib/date.ts @@ -82,6 +82,73 @@ export function dateToLocale(dateTime: Date | null, locale?: string, options?: I return dateTime.toLocaleString(locale ? [locale] : [], options); } +/** + * Formats a naive "YYYY-MM-DD" date string, standard ISO-8601 datetime string, + * or standard JS Date object into a localized date format without applying local + * browser timezone translation (i.e. timezone-agnostic). + * + * @param date - The naive date string or Date object + * @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: string | Date | null | undefined, locale?: string): string { + if (!date) { + return ''; + } + + const resolvedLocale = locale ?? i18n.language ?? 'en-US'; + let utcDate: Date; + + if (date instanceof Date) { + if (isNaN(date.getTime())) { + return ''; + } + utcDate = date; + } else if (typeof date === 'string') { + const trimmed = date.trim(); + // ISO-8601 regex strictly matching YYYY-MM-DD with optional time and timezone components + const isoRegex = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})?)?$/; + const match = trimmed.match(isoRegex); + + if (!match) { + return ''; // Strictly reject non-conforming formats + } + + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10) - 1; // JS months are 0-indexed + const day = parseInt(match[3], 10); + + const hasTime = !!match[4]; + + if (!hasTime) { + utcDate = new Date(Date.UTC(year, month, day)); + // Strict validation: Ensure JavaScript didn't silently roll the date over + if ( + utcDate.getUTCFullYear() !== year || + utcDate.getUTCMonth() !== month || + utcDate.getUTCDate() !== day + ) { + return ''; + } + } else { + utcDate = new Date(trimmed); + if (isNaN(utcDate.getTime())) { + return ''; + } + } + } else { + return ''; + } + + // Format explicitly using the UTC timezone and consistent fields + return utcDate.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 */ From 7b3cc2d4713587e11c515e8552cf30cc1f19aa4c Mon Sep 17 00:00:00 2001 From: Marcello Mendo Date: Tue, 26 May 2026 23:57:36 +0200 Subject: [PATCH 2/2] refactor(date-utils): simplify formatNaiveDate to only handle Date objects Per PR review feedback, removed string parsing and regex logic from formatNaiveDate as all dates are normalized to Date objects by the data layer. Updated corresponding unit tests in date.test.ts to use Date objects. --- src/core/lib/date.test.ts | 30 ++++----------------- src/core/lib/date.ts | 55 +++++---------------------------------- 2 files changed, 11 insertions(+), 74 deletions(-) diff --git a/src/core/lib/date.test.ts b/src/core/lib/date.test.ts index dba3737d2..37c581fc4 100644 --- a/src/core/lib/date.test.ts +++ b/src/core/lib/date.test.ts @@ -56,18 +56,6 @@ describe('calculatePastDate', () => { }); describe('formatNaiveDate - Timezone-Agnostic Validation', () => { - it('should format a valid naive date string exactly as returned from the API without day shift', () => { - const naiveDate = '2026-05-12'; - - // en-US should format YYYY-MM-DD to MM/DD/YYYY - const formattedUS = formatNaiveDate(naiveDate, 'en-US'); - expect(formattedUS).toBe('05/12/2026'); - - // it-IT should format YYYY-MM-DD to DD/MM/YYYY - const formattedIT = formatNaiveDate(naiveDate, 'it-IT'); - expect(formattedIT).toBe('12/05/2026'); - }); - 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'); @@ -81,24 +69,16 @@ describe('formatNaiveDate - Timezone-Agnostic Validation', () => { it('should correctly format month boundaries without falling back to previous/next month', () => { // First day of month - expect(formatNaiveDate('2026-01-01', 'en-US')).toBe('01/01/2026'); + expect(formatNaiveDate(new Date('2026-01-01T00:00:00Z'), 'en-US')).toBe('01/01/2026'); // Last day of month - expect(formatNaiveDate('2026-12-31', 'en-US')).toBe('12/31/2026'); + expect(formatNaiveDate(new Date('2026-12-31T00:00:00Z'), 'en-US')).toBe('12/31/2026'); // Leap year date - expect(formatNaiveDate('2024-02-29', 'en-US')).toBe('02/29/2024'); + expect(formatNaiveDate(new Date('2024-02-29T00:00:00Z'), 'en-US')).toBe('02/29/2024'); }); - it('should return an empty string for invalid dates or malformed formats', () => { - expect(formatNaiveDate('')).toBe(''); + it('should return an empty string for invalid dates, null, or undefined', () => { expect(formatNaiveDate(null)).toBe(''); expect(formatNaiveDate(undefined)).toBe(''); - expect(formatNaiveDate('invalid-date')).toBe(''); - expect(formatNaiveDate('2026-13-45')).toBe(''); // Out of bounds month/day - expect(formatNaiveDate('12/05/2026')).toBe(''); // Not YYYY-MM-DD format - }); - - it('should handle fallback date parsing for standard ISO datetime strings safely', () => { - const isoString = '2026-05-12T00:00:00Z'; - expect(formatNaiveDate(isoString, 'en-US')).toBe('05/12/2026'); + 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 71b9e5a14..4f3864819 100644 --- a/src/core/lib/date.ts +++ b/src/core/lib/date.ts @@ -83,65 +83,22 @@ export function dateToLocale(dateTime: Date | null, locale?: string, options?: I } /** - * Formats a naive "YYYY-MM-DD" date string, standard ISO-8601 datetime string, - * or standard JS Date object into a localized date format without applying local - * browser timezone translation (i.e. timezone-agnostic). + * Formats a standard JS Date object into a localized date format without applying + * local browser timezone translation (i.e. timezone-agnostic). * - * @param date - The naive date string or Date object + * @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: string | Date | null | undefined, locale?: string): string { - if (!date) { +export function formatNaiveDate(date: Date | null | undefined, locale?: string): string { + if (!date || isNaN(date.getTime())) { return ''; } const resolvedLocale = locale ?? i18n.language ?? 'en-US'; - let utcDate: Date; - - if (date instanceof Date) { - if (isNaN(date.getTime())) { - return ''; - } - utcDate = date; - } else if (typeof date === 'string') { - const trimmed = date.trim(); - // ISO-8601 regex strictly matching YYYY-MM-DD with optional time and timezone components - const isoRegex = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})?)?$/; - const match = trimmed.match(isoRegex); - - if (!match) { - return ''; // Strictly reject non-conforming formats - } - - const year = parseInt(match[1], 10); - const month = parseInt(match[2], 10) - 1; // JS months are 0-indexed - const day = parseInt(match[3], 10); - - const hasTime = !!match[4]; - - if (!hasTime) { - utcDate = new Date(Date.UTC(year, month, day)); - // Strict validation: Ensure JavaScript didn't silently roll the date over - if ( - utcDate.getUTCFullYear() !== year || - utcDate.getUTCMonth() !== month || - utcDate.getUTCDate() !== day - ) { - return ''; - } - } else { - utcDate = new Date(trimmed); - if (isNaN(utcDate.getTime())) { - return ''; - } - } - } else { - return ''; - } // Format explicitly using the UTC timezone and consistent fields - return utcDate.toLocaleDateString(resolvedLocale, { + return date.toLocaleDateString(resolvedLocale, { timeZone: 'UTC', year: 'numeric', month: '2-digit',