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>
This commit is contained in:
2026-05-12 21:34:39 +02:00
parent 9fac84658a
commit f3aae61ad8
4 changed files with 295 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { formatDate } from '@/lib/utils/format-date';
import { documentTemplates, documents, files } from '@/lib/db/schema/documents'; import { documentTemplates, documents, files } from '@/lib/db/schema/documents';
import type { File as DbFile, Document as DbDocument } from '@/lib/db/schema/documents'; import type { File as DbFile, Document as DbDocument } from '@/lib/db/schema/documents';
import { clients, clientContacts } from '@/lib/db/schema/clients'; import { clients, clientContacts } from '@/lib/db/schema/clients';
@@ -361,7 +362,7 @@ export async function resolveTemplate(
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? ''; tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? ''; tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB') ? formatDate(interest.dateFirstContact, 'date.medium', { fallback: '' })
: ''; : '';
// `{{interest.notes}}` is now sourced from the threaded // `{{interest.notes}}` is now sourced from the threaded
// interest_notes timeline via EoiContext.interest.notes; this // interest_notes timeline via EoiContext.interest.notes; this
@@ -372,10 +373,10 @@ export async function resolveTemplate(
// These are never populated by EoiContext - always fill them in. // These are never populated by EoiContext - always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? ''; tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned
? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB') ? formatDate(interest.dateEoiSigned, 'date.medium', { fallback: '' })
: ''; : '';
tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
? new Date(interest.dateContractSigned).toLocaleDateString('en-GB') ? formatDate(interest.dateContractSigned, 'date.medium', { fallback: '' })
: ''; : '';
// Derive berth number from the interest when berthId wasn't passed and // Derive berth number from the interest when berthId wasn't passed and
// the EOI path didn't already populate it. Resolves through the // the EOI path didn't already populate it. Resolves through the

View File

@@ -44,6 +44,7 @@ import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { getRate } from '@/lib/services/currency'; import { getRate } from '@/lib/services/currency';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo'; import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { formatDate } from '@/lib/utils/format-date';
import { formatCurrency } from '@/lib/utils/currency'; import { formatCurrency } from '@/lib/utils/currency';
import { getStorageBackend } from '@/lib/storage'; import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
@@ -605,7 +606,7 @@ function addHeader(
// Move past the band and render the subheader below in black on white. // Move past the band and render the subheader below in black on white.
doc.y = bandHeight + 12; doc.y = bandHeight + 12;
const subheader = opts.subheader ?? `Generated on ${new Date().toLocaleDateString()}`; const subheader = opts.subheader ?? `Generated on ${formatDate(new Date(), 'date.medium')}`;
doc.fontSize(11).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' }); doc.fontSize(11).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
doc.fillColor('#000000').moveDown(0.8); doc.fillColor('#000000').moveDown(0.8);
// Suppress unused-warning on startY (kept as a documentation anchor). // Suppress unused-warning on startY (kept as a documentation anchor).

View File

@@ -0,0 +1,177 @@
/**
* Centralised date formatting helpers.
*
* Replaces ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls
* scattered across services and components. Backed by `Intl.DateTimeFormat`
* which is built into Node 18+ and every supported browser — no extra
* dependency. Timezone-aware via the `tz` option (passes through to
* `Intl.DateTimeFormat`'s `timeZone` field — accepts any IANA name).
*
* Why a wrapper:
* 1. One place to fix locale, timezone, format presets when the
* product needs to change them.
* 2. Defensive: `new Date(null)` → "Invalid Date" — we return "—".
* 3. Defensive: numeric epochs vs string dates handled uniformly.
* 4. Default to en-GB so the audit-flagged inconsistency (some sites
* used en-US, some default-locale, some en-GB) is settled.
*
* Presets are named so call sites don't need to remember
* `Intl.DateTimeFormatOptions` shape — `formatDate(x, 'date.short')`
* instead of `formatDate(x, { day: '2-digit', month: 'short' })`.
*/
export type DatePreset =
| 'date.short' // 12 May
| 'date.medium' // 12 May 2026
| 'date.long' // Monday, 12 May 2026
| 'date.iso' // 2026-05-12
| '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
const PRESET_OPTIONS: Record<
Exclude<DatePreset, 'date.iso' | 'datetime.iso'>,
Intl.DateTimeFormatOptions
> = {
'date.short': { day: '2-digit', month: 'short' },
'date.medium': { day: '2-digit', month: 'short', year: 'numeric' },
'date.long': { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' },
'datetime.short': {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
},
'datetime.medium': {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
},
'datetime.long': {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
timeZoneName: 'short',
},
time: { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' },
};
const DEFAULT_LOCALE = 'en-GB';
const DEFAULT_FALLBACK = '—';
export interface FormatDateOptions {
/** IANA timezone — e.g. "Europe/London", "UTC". Defaults to the
* runtime's local zone (server: usually UTC; browser: user's TZ). */
tz?: string;
/** BCP-47 locale tag. Defaults to en-GB. */
locale?: string;
/** What to show when the input is null/undefined/Invalid Date. */
fallback?: string;
}
function toDate(value: Date | string | number | null | undefined): Date | null {
if (value === null || value === undefined) return null;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* Format a Date / ISO string / epoch ms with one of the named presets.
* Defaults to `date.medium` (12 May 2026) in en-GB at the runtime's
* local zone. Pass `tz: 'UTC'` for stamps that should be tz-invariant.
*/
export function formatDate(
value: Date | string | number | null | undefined,
preset: DatePreset = 'date.medium',
options: FormatDateOptions = {},
): string {
const date = toDate(value);
if (!date) return options.fallback ?? DEFAULT_FALLBACK;
const locale = options.locale ?? DEFAULT_LOCALE;
if (preset === 'date.iso') {
if (options.tz && options.tz !== 'UTC') {
// For non-UTC ISO date we have to compute the date components in
// the target zone manually since toISOString is always UTC.
const parts = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: options.tz,
}).formatToParts(date);
const y = parts.find((p) => p.type === 'year')?.value ?? '';
const m = parts.find((p) => p.type === 'month')?.value ?? '';
const d = parts.find((p) => p.type === 'day')?.value ?? '';
return `${y}-${m}-${d}`;
}
// Faster path for UTC / default.
return date.toISOString().slice(0, 10);
}
if (preset === 'datetime.iso') {
// ISO datetime is always UTC by definition.
return date.toISOString();
}
const intlOpts: Intl.DateTimeFormatOptions = { ...PRESET_OPTIONS[preset] };
if (options.tz) intlOpts.timeZone = options.tz;
return new Intl.DateTimeFormat(locale, intlOpts).format(date);
}
/**
* Format a [start, end] date range. Smartly collapses repeated components
* — e.g. same year shows "12 May → 14 Aug 2026" not "12 May 2026 → 14 Aug 2026".
*/
export function formatDateRange(
start: Date | string | number | null | undefined,
end: Date | string | number | null | undefined,
options: FormatDateOptions = {},
): string {
const s = toDate(start);
const e = toDate(end);
const fallback = options.fallback ?? DEFAULT_FALLBACK;
if (!s && !e) return fallback;
if (!s) return `${formatDate(e, 'date.medium', options)}`;
if (!e) return `${formatDate(s, 'date.medium', options)}`;
const sameYear = s.getUTCFullYear() === e.getUTCFullYear();
if (sameYear) {
return `${formatDate(s, 'date.short', options)}${formatDate(e, 'date.medium', options)}`;
}
return `${formatDate(s, 'date.medium', options)}${formatDate(e, 'date.medium', options)}`;
}
/**
* Format a duration relative to now — "2 hours ago", "in 3 days", etc.
* Uses `Intl.RelativeTimeFormat` for proper i18n; falls back to "just now"
* for very small deltas.
*/
export function formatRelative(
value: Date | string | number | null | undefined,
options: { locale?: string; now?: Date } = {},
): string {
const date = toDate(value);
if (!date) return DEFAULT_FALLBACK;
const now = options.now ?? new Date();
const diffSec = (date.getTime() - now.getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(options.locale ?? DEFAULT_LOCALE, { numeric: 'auto' });
const abs = Math.abs(diffSec);
if (abs < 45) return 'just now';
if (abs < 90) return rtf.format(Math.round(diffSec / 60), 'minute');
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
if (abs < 86400 * 30) return rtf.format(Math.round(diffSec / 86400), 'day');
if (abs < 86400 * 365) return rtf.format(Math.round(diffSec / (86400 * 30)), 'month');
return rtf.format(Math.round(diffSec / (86400 * 365)), 'year');
}

View File

@@ -0,0 +1,112 @@
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('—');
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', () => {
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('—');
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('—');
});
});