Skip to content

dry-violation: relative-time "X minutes ago" formatter duplicated across 4 modals #984

@allenhutchison

Description

@allenhutchison

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–452formatDate(timestamp: number), full justNow / minutes / hours / days / toLocaleDateString ladder, uses ragStatus.* keys.
  • src/ui/background-tasks-modal.ts:618–640formatRagDate(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 ifif early-returns; the logic is identical.
  • src/ui/rag-resume-modal.ts:80–105formatDate(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–198formatAge(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions