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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user