What
Four UI modals each carry their own private "X minutes / hours / days ago" formatter that walks the same ladder (justNow → minutes → hours → days → toLocaleDateString()). Three of them are near-identical (same Math.floor(diffMs / 60000) math, same fall-through, same singular/plural i18n branching); the fourth is a simpler variant. Two of the three even share the same ragStatus.* translation keys — one is just a copy of the other in a different file.
The smell: every time a translation maintainer touches ragStatus.minutesAgoPlural, they have to find both copies; every time someone wants to add a "weeks ago" or "yesterday" bucket, they have four places to edit. This is also the kind of code where bug fixes drift (e.g. someone fixes the singular/plural threshold in one file and not the others).
Evidence
src/ui/rag-status-modal.ts:427–452 — formatDate(timestamp: number), full justNow / minutes / hours / days / toLocaleDateString ladder, uses ragStatus.* keys.
src/ui/background-tasks-modal.ts:618–640 — formatRagDate(timestamp: number), same ladder, also uses ragStatus.* keys (literally the same translation keys as rag-status-modal). The only diff vs. rag-status is else if → if early-returns; the logic is identical.
src/ui/rag-resume-modal.ts:80–105 — formatDate(timestamp: number), same ladder, uses ragResume.* keys with the same minuteAgoSingular/minutesAgoPlural/hourAgoSingular/… shape. The ragResume and ragStatus namespaces define what look like the same strings twice in src/i18n/en.ts.
src/ui/catch-up-modal.ts:190–198 — formatAge(date: Date), simpler 3-bucket variant (minutes → hours → days, no singular/plural split, no "just now", no toLocaleDateString() fallback), uses catchUp.* keys.
Closed automated issues #912 and #913 cleaned up adjacent DRY patterns (error-message extraction, folder traversal) but this formatter cluster was missed.
Proposed unit of work
- Add
formatRelativeTime(timestamp: number | Date, opts?: { fallbackToLocaleDate?: boolean }): string to a small utility module — src/utils/format-relative-time.ts is the natural home — that implements the 5-bucket ladder (justNow / minutes / hours / days / toLocaleDateString) and reads from a single set of i18n keys under a new namespace (e.g. time.justNow, time.minuteAgoSingular, time.minutesAgoPlural, time.hourAgoSingular, …).
- Migrate the 3 near-identical sites (
rag-status-modal.ts, background-tasks-modal.ts, rag-resume-modal.ts) to call the new helper; delete their private formatters.
- Consolidate the redundant
ragStatus.{just,minute,hour,day}* and ragResume.{minute,hour,day}* translation keys into the single time.* namespace in src/i18n/en.ts; remove the dead copies; run npm run translate to regenerate the other locale files (the unchanged English keys won't be retranslated thanks to the hash gate in translation-state.json).
- Decide separately whether
catch-up-modal.ts's formatAge should adopt the same helper (it currently uses simpler plural keys via i18n's count-based pluralization and skips the "just now" bucket). If the catch-up UX wants the simpler bucketing kept, leave it alone and document why in the helper's doc-comment; otherwise migrate it too in the same PR.
- Add a unit test at
test/utils/format-relative-time.test.ts covering each bucket boundary (0 s, 59 s, 60 s, 59 min, 60 min, 23 h, 24 h, 6 d, 7 d) and singular-vs-plural switching at count === 1.
Out of scope
- Reworking the broader i18n key naming convention (only the keys this helper reads from move; everything else stays).
- Touching
formatLocalTimestamp in src/utils/datetime.ts or any absolute-timestamp formatter; this is purely about relative-time output.
- Restructuring any of the modals beyond swapping the private formatter for a call to the helper.
Filed by the architecture-audit skill. If this isn't worth doing, close with wontfix — the skill checks closed-with-wontfix and won't refile.
What
Four UI modals each carry their own private "X minutes / hours / days ago" formatter that walks the same ladder (justNow → minutes → hours → days →
toLocaleDateString()). Three of them are near-identical (sameMath.floor(diffMs / 60000)math, same fall-through, same singular/plural i18n branching); the fourth is a simpler variant. Two of the three even share the sameragStatus.*translation keys — one is just a copy of the other in a different file.The smell: every time a translation maintainer touches
ragStatus.minutesAgoPlural, they have to find both copies; every time someone wants to add a "weeks ago" or "yesterday" bucket, they have four places to edit. This is also the kind of code where bug fixes drift (e.g. someone fixes the singular/plural threshold in one file and not the others).Evidence
src/ui/rag-status-modal.ts:427–452—formatDate(timestamp: number), fulljustNow / minutes / hours / days / toLocaleDateStringladder, usesragStatus.*keys.src/ui/background-tasks-modal.ts:618–640—formatRagDate(timestamp: number), same ladder, also usesragStatus.*keys (literally the same translation keys as rag-status-modal). The only diff vs. rag-status iselse if→ifearly-returns; the logic is identical.src/ui/rag-resume-modal.ts:80–105—formatDate(timestamp: number), same ladder, usesragResume.*keys with the sameminuteAgoSingular/minutesAgoPlural/hourAgoSingular/… shape. TheragResumeandragStatusnamespaces define what look like the same strings twice insrc/i18n/en.ts.src/ui/catch-up-modal.ts:190–198—formatAge(date: Date), simpler 3-bucket variant (minutes → hours → days, no singular/plural split, no "just now", notoLocaleDateString()fallback), usescatchUp.*keys.Closed automated issues #912 and #913 cleaned up adjacent DRY patterns (error-message extraction, folder traversal) but this formatter cluster was missed.
Proposed unit of work
formatRelativeTime(timestamp: number | Date, opts?: { fallbackToLocaleDate?: boolean }): stringto a small utility module —src/utils/format-relative-time.tsis the natural home — that implements the 5-bucket ladder (justNow / minutes / hours / days /toLocaleDateString) and reads from a single set of i18n keys under a new namespace (e.g.time.justNow,time.minuteAgoSingular,time.minutesAgoPlural,time.hourAgoSingular, …).rag-status-modal.ts,background-tasks-modal.ts,rag-resume-modal.ts) to call the new helper; delete their private formatters.ragStatus.{just,minute,hour,day}*andragResume.{minute,hour,day}*translation keys into the singletime.*namespace insrc/i18n/en.ts; remove the dead copies; runnpm run translateto regenerate the other locale files (the unchanged English keys won't be retranslated thanks to the hash gate intranslation-state.json).catch-up-modal.ts'sformatAgeshould adopt the same helper (it currently uses simpler plural keys via i18n's count-based pluralization and skips the "just now" bucket). If the catch-up UX wants the simpler bucketing kept, leave it alone and document why in the helper's doc-comment; otherwise migrate it too in the same PR.test/utils/format-relative-time.test.tscovering each bucket boundary (0 s, 59 s, 60 s, 59 min, 60 min, 23 h, 24 h, 6 d, 7 d) and singular-vs-plural switching atcount === 1.Out of scope
formatLocalTimestampinsrc/utils/datetime.tsor any absolute-timestamp formatter; this is purely about relative-time output.Filed by the
architecture-auditskill. If this isn't worth doing, close withwontfix— the skill checks closed-with-wontfix and won't refile.