Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/components/ui/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Comment on lines +41 to +55
22 changes: 20 additions & 2 deletions src/components/ui/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,32 @@ 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);
}
Comment on lines +34 to +41

/**
* Format a date string for display.
* @example formatDate("2024-01-15") => "Jan 15, 2024"
*/
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',
Expand All @@ -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)
Expand Down