diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 1cd818c9..6263fe90 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -1,6 +1,7 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; +import { formatDate } from '@/lib/utils/format-date'; import { documentTemplates, documents, files } 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'; @@ -361,7 +362,7 @@ export async function resolveTemplate( tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? ''; tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? ''; 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 timeline via EoiContext.interest.notes; this @@ -372,10 +373,10 @@ export async function resolveTemplate( // These are never populated by EoiContext - always fill them in. tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? ''; tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned - ? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB') + ? formatDate(interest.dateEoiSigned, 'date.medium', { fallback: '' }) : ''; 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 // the EOI path didn't already populate it. Resolves through the diff --git a/src/lib/services/expense-pdf.service.ts b/src/lib/services/expense-pdf.service.ts index 467ed92e..fd5bfc7b 100644 --- a/src/lib/services/expense-pdf.service.ts +++ b/src/lib/services/expense-pdf.service.ts @@ -44,6 +44,7 @@ import { files } from '@/lib/db/schema/documents'; import { ports } from '@/lib/db/schema/ports'; import { getRate } from '@/lib/services/currency'; import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo'; +import { formatDate } from '@/lib/utils/format-date'; import { formatCurrency } from '@/lib/utils/currency'; import { getStorageBackend } from '@/lib/storage'; import { logger } from '@/lib/logger'; @@ -605,7 +606,7 @@ function addHeader( // Move past the band and render the subheader below in black on white. 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.fillColor('#000000').moveDown(0.8); // Suppress unused-warning on startY (kept as a documentation anchor). diff --git a/src/lib/utils/format-date.ts b/src/lib/utils/format-date.ts new file mode 100644 index 00000000..d13c7df1 --- /dev/null +++ b/src/lib/utils/format-date.ts @@ -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, + 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'); +} diff --git a/tests/unit/format-date.test.ts b/tests/unit/format-date.test.ts new file mode 100644 index 00000000..15683da2 --- /dev/null +++ b/tests/unit/format-date.test.ts @@ -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('—'); + }); +});