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)