Problem
There are two independent, live date-formatting modules:
src/lib/utils.ts (imported by SubscriptionItem.tsx's "Next run" date and EscrowItem.tsx's unlock-time display via formatDateTime):
export function formatDate(iso: string, opts?: Intl.DateTimeFormatOptions): string {
try {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', ...opts })
} catch {
return iso
}
}
export function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
} catch {
return iso
}
}
src/utils/format.ts:
export const formatDate = (d: Date | string) => new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(d))
These are not just duplicated — they're inconsistent in what they actually format: utils/format.ts's formatDate always includes hour/minute (it's really a datetime formatter despite the name), while lib/utils.ts's formatDate is date-only (no time) and its separate formatDateTime is the one that adds time — so a component that imports formatDate from one module gets a full timestamp, while a component importing the identically-named formatDate from the other gets a bare date, with no signal at the call site (import { formatDate } from '@/lib/utils' vs import { formatDate } from '@/utils/format' look almost identical) that they behave differently.
Additionally, lib/utils.ts's versions wrap new Date(iso) in a try/catch that falls back to returning the raw, unformatted iso string on any parse failure, while utils/format.ts's version has no such guard — an invalid/malformed date string passed to it will produce Invalid Date (since Intl.DateTimeFormat.format on an invalid Date typically renders "Invalid Date" rather than throwing) shown directly to the user, rather than either a graceful fallback or a caught error.
Why it matters
- A designer or reviewer auditing "does every date on this page look consistent" has to check which of the two modules each component imports from to know whether they'll see
"Jul 15, 2026" or "Jul 15, 2026, 03:45 PM" — these are used in adjacent, comparable contexts (escrow unlock times, subscription next-run dates, transaction timestamps) where a user reasonably expects consistent formatting across the app.
- The silent-fallback-to-raw-ISO-string behavior only existing in one of the two implementations means the user experience for a malformed date differs depending on which module happened to be imported — one shows a raw
"2026-13-45T99:99:99Z"-style string, the other shows "Invalid Date" — neither is great, but the inconsistency itself is the maintenance hazard: a bug fix applied to one will not apply to the other.
Reproduction
grep -rn "from '@/lib/utils'" src | grep formatDate vs grep -rn "from '@/utils/format'" src | grep formatDate — confirm both are live imports used in different components.
- Pass an invalid ISO string (e.g.
"not-a-date") to both formatDate implementations and observe the differing output ("not-a-date" echoed back vs. "Invalid Date").
Suggested fix (flagging as spike — consolidation direction needs a decision)
- Pick one canonical date-formatting module (likely
lib/utils.ts, given it already has the more defensive try/catch pattern and a clearer formatDate/formatDateTime naming split) and migrate the other's call sites onto it, deleting the duplicate.
- While consolidating, decide on one consistent fallback behavior for unparseable dates (raw string echo vs. a dedicated placeholder like
"—", matching the formatAmount/formatUSD convention elsewhere in lib/stellar.ts which already uses '—' for invalid numeric input — see the separate duplicate-helpers issue in this batch for that pattern) rather than leaving it inconsistent.
Testing strategy
- Snapshot-test both current implementations against a fixed set of inputs (valid ISO date, valid ISO date+time, empty string, malformed string) to document current behavior before consolidating.
- After consolidating, add a single shared test suite covering all former call sites' expectations (date-only contexts like
SubscriptionItem's "Next run" vs. date+time contexts like EscrowItem's unlock display) to confirm no visual regression.
Related issues in this batch
Same root cause and remediation shape as the isValidStellarAddress/truncateAddress/formatAmount duplication issue filed in this batch — both are drift-prone parallel utility modules (lib/* vs utils/*) that should probably be consolidated together as one cleanup effort.
Problem
There are two independent, live date-formatting modules:
src/lib/utils.ts(imported bySubscriptionItem.tsx's "Next run" date andEscrowItem.tsx's unlock-time display viaformatDateTime):src/utils/format.ts:These are not just duplicated — they're inconsistent in what they actually format:
utils/format.ts'sformatDatealways includeshour/minute(it's really a datetime formatter despite the name), whilelib/utils.ts'sformatDateis date-only (no time) and its separateformatDateTimeis the one that adds time — so a component that importsformatDatefrom one module gets a full timestamp, while a component importing the identically-namedformatDatefrom the other gets a bare date, with no signal at the call site (import { formatDate } from '@/lib/utils'vsimport { formatDate } from '@/utils/format'look almost identical) that they behave differently.Additionally,
lib/utils.ts's versions wrapnew Date(iso)in atry/catchthat falls back to returning the raw, unformattedisostring on any parse failure, whileutils/format.ts's version has no such guard — an invalid/malformed date string passed to it will produceInvalid Date(sinceIntl.DateTimeFormat.formaton an invalidDatetypically renders"Invalid Date"rather than throwing) shown directly to the user, rather than either a graceful fallback or a caught error.Why it matters
"Jul 15, 2026"or"Jul 15, 2026, 03:45 PM"— these are used in adjacent, comparable contexts (escrow unlock times, subscription next-run dates, transaction timestamps) where a user reasonably expects consistent formatting across the app."2026-13-45T99:99:99Z"-style string, the other shows"Invalid Date"— neither is great, but the inconsistency itself is the maintenance hazard: a bug fix applied to one will not apply to the other.Reproduction
grep -rn "from '@/lib/utils'" src | grep formatDatevsgrep -rn "from '@/utils/format'" src | grep formatDate— confirm both are live imports used in different components."not-a-date") to bothformatDateimplementations and observe the differing output ("not-a-date"echoed back vs."Invalid Date").Suggested fix (flagging as
spike— consolidation direction needs a decision)lib/utils.ts, given it already has the more defensive try/catch pattern and a clearerformatDate/formatDateTimenaming split) and migrate the other's call sites onto it, deleting the duplicate."—", matching theformatAmount/formatUSDconvention elsewhere inlib/stellar.tswhich already uses'—'for invalid numeric input — see the separate duplicate-helpers issue in this batch for that pattern) rather than leaving it inconsistent.Testing strategy
SubscriptionItem's "Next run" vs. date+time contexts likeEscrowItem's unlock display) to confirm no visual regression.Related issues in this batch
Same root cause and remediation shape as the
isValidStellarAddress/truncateAddress/formatAmountduplication issue filed in this batch — both are drift-prone parallel utility modules (lib/*vsutils/*) that should probably be consolidated together as one cleanup effort.