From 7c009ead854cf34293056683845bd515c2746da6 Mon Sep 17 00:00:00 2001 From: Matt OD Date: Wed, 15 Jul 2026 01:20:32 -0700 Subject: [PATCH] =?UTF-8?q?chore:=20resolve=20issue=20#151=20=E2=80=94=20f?= =?UTF-8?q?ix=20date-only=20off-by-one=20in=20local=20timezone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatDate and calculateTenure parsed 'YYYY-MM-DD' via new Date(str), which is UTC midnight and renders as the previous day in negative-offset zones (hire_date 2023-05-15 showed 'May 14, 2023' in PDT). Add a shared parseLocalDate helper that builds date-only strings from parts (local midnight) while passing timestamp strings through unchanged; route both formatters through it. All date-only surfaces (hire date, DOB, termination, rating/survey/review dates) share this util, so they are fixed together. Verification: 76 frontend tests pass (8 new in utils.test.ts), tsc --noEmit clean. Resolves #151. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/ui/utils.test.ts | 55 +++++++++++++++++++++++++++++++++ src/components/ui/utils.ts | 22 +++++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/utils.test.ts diff --git a/src/components/ui/utils.test.ts b/src/components/ui/utils.test.ts new file mode 100644 index 0000000..f62ec58 --- /dev/null +++ b/src/components/ui/utils.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { formatDate, calculateTenure, parseLocalDate } from './utils'; + +describe('parseLocalDate', () => { + it('parses a date-only string to LOCAL midnight (not UTC midnight)', () => { + // Regression for #151: new Date('2023-05-15') is UTC midnight, which is the + // previous calendar day in any negative-offset timezone. parseLocalDate must + // land on the same calendar day the string names, regardless of the runner TZ. + const d = parseLocalDate('2023-05-15'); + expect(d.getFullYear()).toBe(2023); + expect(d.getMonth()).toBe(4); // May (0-indexed) + expect(d.getDate()).toBe(15); + }); + + it('passes strings that carry a time component through to the native parser', () => { + const d = parseLocalDate('2023-05-15T12:00:00Z'); + expect(Number.isNaN(d.getTime())).toBe(false); + }); +}); + +describe('formatDate', () => { + it('renders a date-only string on its own calendar day regardless of local timezone', () => { + // #151: hire_date "2023-05-15" was rendering as "May 14, 2023" in PDT. + expect(formatDate('2023-05-15')).toBe('May 15, 2023'); + }); + + it('formats another date-only value on the correct day', () => { + expect(formatDate('2024-01-15')).toBe('Jan 15, 2024'); + }); + + it('does not drift across a year boundary', () => { + expect(formatDate('2024-01-01')).toBe('Jan 1, 2024'); + }); + + it('returns an em dash for missing input', () => { + expect(formatDate(undefined)).toBe('—'); + expect(formatDate('')).toBe('—'); + }); +}); + +describe('calculateTenure', () => { + it('returns an em dash for a missing hire date', () => { + expect(calculateTenure(undefined)).toBe('—'); + }); + + it('reads a multi-year date-only hire date without drifting the year down a day', () => { + // A hire date ~3.5 years before "now" (built from local parts) should read 3y. + const now = new Date(); + const past = new Date(now.getFullYear() - 3, now.getMonth() - 6, now.getDate()); + const y = past.getFullYear(); + const m = String(past.getMonth() + 1).padStart(2, '0'); + const d = String(past.getDate()).padStart(2, '0'); + expect(calculateTenure(`${y}-${m}-${d}`).startsWith('3y')).toBe(true); + }); +}); diff --git a/src/components/ui/utils.ts b/src/components/ui/utils.ts index 0aa36dc..2ed9eb2 100644 --- a/src/components/ui/utils.ts +++ b/src/components/ui/utils.ts @@ -22,6 +22,24 @@ export function getInitials(name: string): string { .slice(0, 2); } +/** + * Parse a date string into a local Date. + * + * Date-only strings (`YYYY-MM-DD`) are constructed from their parts so they land + * on LOCAL midnight. `new Date('2023-05-15')` instead parses as UTC midnight, + * which renders as the previous calendar day in any negative-offset timezone + * (e.g. "May 14" in PDT) — issue #151. Strings that carry a time component are + * passed through to the native parser unchanged. + */ +export function parseLocalDate(dateStr: string): Date { + const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (dateOnly) { + const [, year, month, day] = dateOnly; + return new Date(Number(year), Number(month) - 1, Number(day)); + } + return new Date(dateStr); +} + /** * Format a date string for display. * @example formatDate("2024-01-15") => "Jan 15, 2024" @@ -29,7 +47,7 @@ export function getInitials(name: string): string { export function formatDate(dateStr?: string): string { if (!dateStr) return '—'; try { - const date = new Date(dateStr); + const date = parseLocalDate(dateStr); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', @@ -47,7 +65,7 @@ export function formatDate(dateStr?: string): string { export function calculateTenure(hireDate?: string): string { if (!hireDate) return '—'; try { - const hire = new Date(hireDate); + const hire = parseLocalDate(hireDate); const now = new Date(); const years = Math.floor( (now.getTime() - hire.getTime()) / (365.25 * 24 * 60 * 60 * 1000)