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 { 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

View File

@@ -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).