Files
pn-new-crm/tests/unit/format-date.test.ts

113 lines
4.0 KiB
TypeScript
Raw Normal View History

feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
import { describe, expect, it } from 'vitest';
import { formatDate, formatDateRange, formatRelative } from '@/lib/utils/format-date';
describe('formatDate', () => {
const REF = '2026-05-12T14:30:45.000Z';
it('returns fallback for null/undefined/invalid', () => {
expect(formatDate(null)).toBe('-');
expect(formatDate(undefined)).toBe('-');
expect(formatDate('not a date')).toBe('-');
expect(formatDate(NaN)).toBe('-');
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
expect(formatDate(null, 'date.medium', { fallback: 'N/A' })).toBe('N/A');
});
it('formats date.iso correctly in UTC', () => {
expect(formatDate(REF, 'date.iso', { tz: 'UTC' })).toBe('2026-05-12');
});
it('formats date.iso correctly in a different timezone', () => {
// 14:30 UTC = 00:30 next day in Sydney (UTC+10)
expect(formatDate(REF, 'date.iso', { tz: 'Australia/Sydney' })).toBe('2026-05-13');
});
it('formats datetime.iso as full ISO string', () => {
expect(formatDate(REF, 'datetime.iso')).toBe('2026-05-12T14:30:45.000Z');
});
it('formats date.medium in en-GB', () => {
// Intl output may vary slightly across Node versions; assert it
// contains the expected day + month + year and uses no "/".
const out = formatDate(REF, 'date.medium', { tz: 'UTC' });
expect(out).toMatch(/12\s/); // day
expect(out).toContain('2026');
expect(out.toLowerCase()).toContain('may');
});
it('respects timezone - datetime.short in different zones', () => {
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
const ny = formatDate(REF, 'datetime.short', { tz: 'America/New_York' });
const utc = formatDate(REF, 'datetime.short', { tz: 'UTC' });
expect(ny).not.toBe(utc);
expect(utc).toContain('14:30');
});
it('formats time-only correctly', () => {
expect(formatDate(REF, 'time', { tz: 'UTC' })).toBe('14:30');
});
it('accepts Date instances + epoch ms', () => {
const d = new Date(REF);
expect(formatDate(d, 'date.iso', { tz: 'UTC' })).toBe('2026-05-12');
expect(formatDate(d.getTime(), 'date.iso', { tz: 'UTC' })).toBe('2026-05-12');
});
});
describe('formatDateRange', () => {
it('handles missing start/end', () => {
expect(formatDateRange(null, null)).toBe('-');
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
expect(formatDateRange('2026-05-12', null)).toMatch(/→$/);
expect(formatDateRange(null, '2026-05-12')).toMatch(/^→/);
});
it('collapses year when start + end are in same year', () => {
const out = formatDateRange('2026-05-12', '2026-08-14', { tz: 'UTC' });
// Start should NOT include year; end should.
expect(out).toContain('→');
expect(out).toContain('2026');
// Crude check: there's only one "2026" in the output.
expect((out.match(/2026/g) ?? []).length).toBe(1);
});
it('keeps year on both when years differ', () => {
const out = formatDateRange('2025-12-20', '2026-01-05', { tz: 'UTC' });
expect((out.match(/202\d/g) ?? []).length).toBe(2);
});
});
describe('formatRelative', () => {
const NOW = new Date('2026-05-12T12:00:00.000Z');
it('returns "just now" for tiny deltas', () => {
expect(formatRelative(new Date(NOW.getTime() - 5_000), { now: NOW })).toBe('just now');
});
it('uses minutes for past deltas under an hour', () => {
const ten = new Date(NOW.getTime() - 10 * 60_000);
const out = formatRelative(ten, { now: NOW });
expect(out).toMatch(/minute/);
});
it('uses hours for past deltas in the same day', () => {
const three = new Date(NOW.getTime() - 3 * 3600_000);
const out = formatRelative(three, { now: NOW });
expect(out).toMatch(/hour/);
});
it('uses days for past deltas across days', () => {
const yesterday = new Date(NOW.getTime() - 86_400_000);
const out = formatRelative(yesterday, { now: NOW });
expect(out).toMatch(/day|yesterday/i);
});
it('uses future tense for future dates', () => {
const tomorrow = new Date(NOW.getTime() + 86_400_000);
const out = formatRelative(tomorrow, { now: NOW });
expect(out).toMatch(/day|tomorrow/i);
});
it('returns fallback for invalid input', () => {
expect(formatRelative(null)).toBe('-');
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
});
});