From e747f2e57c58da3b3313c8336c2ce4ac2b48f949 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:47:47 +0100 Subject: [PATCH] Count down to future Patrol times instead of reading just now formatRelativeTime is past-only: any time ahead of now reads "just now" (compact) or "0s ago", which is right for clock skew on an observed time. Patrol handed it three future times, so each read wrong for its whole duration: a suppressed issue said "Returns to active attention just now.", a will-fix-later finding said "Reminding just now", and a snoozed finding said "snoozed until just now". formatTimeUntil in utils/format.ts is the future counterpart. It counts down ("in 3h", "in 1d"), rounds to the nearest unit so a 24-hour suppression set a moment ago reads "in 1d" rather than "in 23h", and returns "now" (or the caller's dueText) once the time arrives. The three Patrol lines use it with the shared relative-time clock, because none of those times change while the panel stays open, and keep the exact time on hover. The suppression line names the state expiry restores. An open issue reads "Returns to active attention in 1d."; one marked reviewed reads "Suppression ends in 1d. It stays reviewed.", because expiry restores Acknowledged, which is not active attention; a passed expiry reads "Suppression has ended.". The countdown takes its words from getAttentionSuppressionEndPresentation, which already words the early-end button and the duration field by the same reviewed split, so all three agree. A reminder reads "Reminder overdue" once one overdue id list, sampled on the shared clock, holds it. The Overdue commitments chip and filter count the same list, so the label, chip and filter agree even for a row that mounts between ticks (it reads "Reminder due" until the next tick), and the list only notifies when its ids change, so a tick that moves no deadline past now does not rebuild open rows. The reminder hover no longer promises the finding returns only "if it is still tripping": the hourly reminder sweep brings it back regardless. An ended snooze reads "snooze ended" until the next findings re-read drops the snoozed status. The existing countdowns did not fit: CountdownTimer runs its own one-second interval and has no day unit (a 7-day suppression would read "167h 59m"), and the replication Next sync helper is private to that table with sync-specific overdue tones. An audit of every formatRelativeTime and formatPlatformTableRelativeTimeValue caller found no other future timestamps; the autopilot and alert snooze expiries already show an absolute time. This rebuilds PR 2613 on main after PR 2598 landed. PR 2613 was stacked on two early PR 2598 commits whose relative-age contract wording main has since replaced (every read of the shared clock now returns the wall clock), so the future-time note sits under main's wording and before the read-time age exception, and that exception now opens "The relative-age rule" so it does not read as an exception to the future-time note. --- .../v6/internal/subsystems/api-contracts.md | 4 + .../subsystems/frontend-primitives.md | 14 +- .../subsystems/patrol-intelligence.md | 30 ++++ .../82459c28d2633b61.json | 51 ++++++ .../src/components/AI/FindingsPanel.tsx | 95 ++++++---- .../AI/__tests__/FindingsPanel.links.test.tsx | 163 +++++++++++++++++- .../AI/__tests__/FindingsPanel.test.ts | 102 ++++++++++- .../patrol/PatrolAttentionWorkbench.tsx | 35 +++- .../PatrolAttentionWorkbench.test.tsx | 69 ++++++++ .../src/utils/__tests__/format.test.ts | 62 +++++++ .../src/utils/aiFindingPresentation.ts | 50 +++++- frontend-modern/src/utils/format.ts | 65 +++++-- 12 files changed, 675 insertions(+), 65 deletions(-) create mode 100644 frontend-modern/browser-verification/82459c28d2633b61.json diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 00536ef201..dd805685a4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4860,6 +4860,10 @@ transition and evidence times) from the frontend-primitives shared relative-time clock. That is presentation over the same `GET /api/ai/patrol/status`, run history and `GET /api/ai/patrol/attention/{id}` payloads: no field, request or polling cadence changes. +The attention detail's suppression `expiresAt` and a finding's `remind_at` and +`snoozed_until` from `GET /api/ai/patrol/findings` are future times, so the +page presents them as countdowns from the same clock ("in 1d") instead of +ages. That is presentation only: no field, request or polling cadence changes. ### Resource-list facets preserve scoped navigation evidence diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bef67c7a83..eafffe0d00 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5276,7 +5276,19 @@ too. Every read of the clock returns the wall clock; the 30-second tick only tells readers to re-read, so a cell that mounts between ticks never measures from a stale time and a timestamp from the last few seconds never reads as a future time. -The rule has one deliberate exception. The age of a latest reading (last used, +Future times have their own formatter. `formatRelativeTime` is past-only: it +reads a time ahead of `now` as "just now" (compact) or "0s ago", which is right +for clock skew on something already observed and wrong for an expiry, a +reminder or a schedule. +Those go through `formatTimeUntil` (`frontend-modern/src/utils/format.ts`), +which counts down ("in 3h", "in 1d"), rounds to the nearest unit so a duration +just chosen reads as chosen, and returns `dueText` (default "now") once the +time arrives. A countdown target does not change while its surface stays open, +so the countdown passes `now` from the shared clock, as the Patrol suppression +expiry and the Patrol findings reminder and snooze lines do. The replication +Next sync column and the Patrol header's next-check `CountdownTimer` keep their +own minute- and second-precision countdowns. +The relative-age rule has one deliberate exception. The age of a latest reading (last used, last seen, last success, last checked) on data the surface reads once and does not re-read stays the age at read time, because a moving age over a snapshot that never refreshes claims the reading stopped when it may not have. Such a diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 5cb104ab00..5e6eb4dac3 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -2679,6 +2679,36 @@ clock while it stays open. Its `Last seen` and flapping window are latest readings of a record that may have moved since, so they stay the ages at read time, and the queue rows, summary, recent work, run history and digest re-derive on their own 30- or 60-second re-reads. +Future times in Patrol count down instead of reading as ages, because the +past-only `formatRelativeTime` reads each of them as "just now" for their whole +duration. The attention detail's suppression line counts down to the expiry +with the exact time on hover and names the state expiry restores in the +early-end control's words, from the same +`getAttentionSuppressionEndPresentation` split on +`operationalRecord.acknowledgement`: an open issue reads `Returns to active +attention in 1d.`, one marked reviewed reads `Suppression ends in 1d. It stays +reviewed.` (expiry restores Acknowledged, which is not active attention), and a +passed expiry reads `Suppression has ended.` +(`getAttentionSuppressionExpiryLabel`). The findings list reads `Reminding in +6d` for a will_fix_later reminder and `snoozed, returns in 3h` for a snooze, +both through `formatTimeUntil` on the shared clock with the exact time on +hover. A reminder reads `Reminder overdue` once the panel's overdue id list +holds it. That list is sampled on the shared clock and is what the Overdue +commitments chip and filter count, so the label, chip and filter agree: a row +mounted or re-read between the deadline and the tick that adds it reads +`Reminder due`, and a row already on screen keeps its last countdown until that +tick. The list only notifies when its ids change, so a clock tick that moves no +deadline past now does not rebuild the open Overdue rows. The hourly reminder +sweep then brings the finding back whether or not it still trips. An ended +snooze reads `snooze ended` until the next findings re-read drops the snoozed +status: `GET /api/ai/patrol/findings` sends no status, so the frontend +`aiIntelligence` store (`normalizeFindingStatus`) derives `snoozed` on each +read only while `snoozed_until` is ahead, and the finding then shows as Active +unless it has since resolved or been dismissed. +`getFindingReminderPresentation` and `getFindingSnoozePresentation` in +`aiFindingPresentation.ts` own the findings copy. +`PatrolAttentionWorkbench.test.tsx`, `FindingsPanel.links.test.tsx` and +`FindingsPanel.test.ts` pin it. ### This week card answers what Patrol did for the customer diff --git a/frontend-modern/browser-verification/82459c28d2633b61.json b/frontend-modern/browser-verification/82459c28d2633b61.json new file mode 100644 index 0000000000..729aa732ab --- /dev/null +++ b/frontend-modern/browser-verification/82459c28d2633b61.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "base_sha": "7eb32f318100fef045d63414fd63304c62acfdef", + "verified_at": "2026-10-07T16:47:38Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/AI/FindingsPanel.tsx", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", + "frontend-modern/src/utils/aiFindingPresentation.ts", + "frontend-modern/src/utils/format.ts" + ], + "content_sha256": { + "frontend-modern/src/components/AI/FindingsPanel.tsx": "6b50ebbf3bbe24e31eebdb3c8be8ac6ca9c08405352facbfcd294a1361d8a2f5", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "9055b69986cd4d320fd2194dba2819c0643e63326724952fd1021325530e9f5b", + "frontend-modern/src/utils/aiFindingPresentation.ts": "29e212a8d0aed4c1eb17697bbed86519ed82f64bc2940239db4a79470a23a090", + "frontend-modern/src/utils/format.ts": "abaec8a1f40c159e9a58702b726ccaea196bf6b72825c97addbee00af3bd7809" + }, + "routes": [ + "/patrol", + "/patrol/activity" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (backend :7793 built from this tree on origin/main 7eb32f3181, Vite :5303 serving it) with Playwright page.clock installed before load and the data held: the /ws proxy dropped every server frame and answered the client heartbeat with a data-free pong, every GET /api/ read was replayed from its cached body (Patrol findings reads left pending), and the clock was moved with setSystemTime plus a 31-second runFor so only the shared 30-second tick fired; one websocket connection and no attention detail re-read for the whole hold", + "Unreviewed suppressed issue (ops-prometheus Docker service health, suppressed through the real form for the default 24 hours; the form's duration read 'Return it to active attention after'): Lifecycle reads 'Returns to active attention in 1d.' with hover 'Suppressed until 8 Oct 17:36' and button 'Return to active attention', then 'in 20h.' at +4h, 'in 29m.' at +23h20m (28m at 390) and 'Suppression has ended.' at +25h, at 1440 and 390", + "Reviewed then suppressed issue (esxi-07.lab.local VMware host health, Mark reviewed then suppressed for 24 hours from its detail; the form's duration read 'End suppression after'): Lifecycle reads 'Suppression ends in 1d. It stays reviewed.' with button 'End suppression', then 'in 20h' at +4h, 'in 29m' at +23h20m (28m at 390) and 'Suppression has ended.' at +25h, at 1440 and 390", + "Patrol activity, Finding options and history, Resolved: mail-gateway-us dismissed through Finding options, Dismiss: Later reads '(will fix later) \u00b7 Reminding in 7d' with hover 'Reminder due 14 Oct 17:36. Pulse brings this finding back on its first reminder check after that.'; at +7d1m it reads 'Reminder overdue' and the 'Overdue commitments (1)' chip appears on that tick with findings reads still pending, and the chip filters to that finding, at 1440 and 390", + "Between ticks: page clock set 5 seconds past that reminder's deadline with no timer fired, then switching from Active to Resolved mounts the row reading 'Reminder due' with no Overdue chip; one 31-second runFor later the row reads 'Reminder overdue' and the 'Overdue commitments (1)' chip appears together, at 1440 and 390", + "Same list, dr-vault snoozed for 1 hour through POST /api/ai/patrol/snooze (Patrol rows hide the snooze buttons): reads 'snoozed, returns in 49m' with hover 'Snoozed until 7 Oct 18:36', 'in 9m' at +40m (8m at 390) and 'snooze ended' at +70m; after releasing the hold the next findings read lists dr-vault under Active, at 1440 and 390", + "No 'just now' on any future time at either viewport (the only 'just now' left is the attention detail's read-time Last seen age)", + "Same-session controls under the running Vite: with FindingsPanel.tsx and aiFindingPresentation.ts swapped to the first draft of this change, the between-ticks row read 'Reminder overdue' with no Overdue chip; with FindingsPanel.tsx, PatrolAttentionWorkbench.tsx, aiFindingPresentation.ts and format.ts swapped back to origin/main, the reviewed issue read 'Returns to active attention just now.' before and after +4h, the reminder 'Reminding just now' before and after +7d with no Overdue chip, and the snooze 'snoozed until just now'" + ], + "interactions": [ + "Opened a decision by its Patrol attention link, filled the suppression reason and submitted Suppress temporarily with the default 24 hours", + "Opened a second decision by its attention link, clicked Mark reviewed, reopened it and suppressed it for 24 hours", + "Opened Finding options and history, opened Finding options on a finding, chose Dismiss: Later and Confirm Dismiss", + "Opened Reviewed and suppressed, opened each suppressed issue from the list, read the Lifecycle line, its hover title and the early-end button", + "Switched the findings filter between Active and Resolved, read reminder and snooze hover titles, stepped the page clock and clicked the Overdue commitments chip", + "Released the hold and switched to Active to see the ended snooze re-derived", + "Repeated every pass at 1440x900 and 390x844, then reran with the first draft and with origin/main's components as controls" + ] +} diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index c5aaaaeae8..1aa567efa9 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -37,6 +37,7 @@ import { AIAPI, type RemediationPlan } from '@/api/ai'; import { createSuppressionRuleFromFinding, reinvestigateFinding } from '@/api/patrol'; import type { PatrolRunRecord, PatrolRuntimeState, PatrolAutonomyLevel } from '@/api/patrol'; import { formatRelativeTime } from '@/utils/format'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { getFindingAlertIdentifier, hasTriggeringAlert, @@ -82,6 +83,8 @@ import { getFindingSubjectPresentation, getFindingTitlePresentation, getFindingRecencyPresentation, + getFindingReminderPresentation, + getFindingSnoozePresentation, getFindingEvidencePresentation, hasFindingInvestigationDetails, hasFindingInvestigationHandoffPointer, @@ -192,6 +195,10 @@ function getFindingSuppressionRuleScope(finding: UnifiedFinding) { export const FindingsPanel: Component = (props) => { const location = useLocation(); const [filter, setFilter] = createSignal(props.filterOverride ?? 'active'); + // A reminder or snooze end never changes while its row stays on screen, so + // the countdown to it, and whether a reminder is overdue, read the shared + // clock. + const now = useRelativeTimeNow(); const [sortBy, setSortBy] = createSignal<'severity' | 'time'>('severity'); const [expandedId, setExpandedId] = createSignal(null); let panelRoot: HTMLDivElement | undefined; @@ -336,6 +343,32 @@ export const FindingsPanel: Component = (props) => { } }); + // Will_fix_later commitments whose RemindAt deadline has passed. The Go + // store's proactive sweep (SweepWillFixLaterReminders) wakes these on a 1h + // timer; until then the Overdue commitments chip and filter surface them on + // demand. The list is sampled on the shared clock and notifies only when its + // ids change, so a tick that moves no deadline past now does not rebuild the + // filtered rows. Each row's "Reminder overdue" label reads the same list, so + // a row that mounts between ticks agrees with the chip and filter. + const overdueFindingIds = createMemo( + () => { + const nowMs = now(); + return sourceFindings() + .filter((f) => { + if (f.dismissedReason !== 'will_fix_later' || !f.remindAt) return false; + const due = Date.parse(f.remindAt); + return Number.isFinite(due) && due <= nowMs; + }) + .map((f) => f.id); + }, + [], + { + equals: (prev, next) => + prev.length === next.length && prev.every((id, index) => id === next[index]), + }, + ); + const overdueFindingIdSet = createMemo(() => new Set(overdueFindingIds())); + // Filter and sort findings const filteredFindings = createMemo(() => { if (hasUnknownRunSnapshot()) { @@ -363,22 +396,8 @@ export const FindingsPanel: Component = (props) => { const approvalFindingIds = new Set(sourceFindingsWithPendingApprovals().map((f) => f.id)); findings = findings.filter((f) => approvalFindingIds.has(f.id)); } else if (filter() === 'overdue') { - // Overdue surfaces will_fix_later commitments whose RemindAt - // deadline has already passed. The Go store's proactive sweep - // (SweepWillFixLaterReminders) wakes these on a 1h timer, but - // this filter lets the operator see them on demand — including - // any that the sweep has not yet promoted on this cycle. - const nowMs = Date.now(); - findings = findings.filter((f) => { - if (f.dismissedReason !== 'will_fix_later') { - return false; - } - if (!f.remindAt) { - return false; - } - const due = Date.parse(f.remindAt); - return Number.isFinite(due) && due <= nowMs; - }); + const overdueIds = overdueFindingIdSet(); + findings = findings.filter((f) => overdueIds.has(f.id)); } // Filter by specific finding IDs if provided @@ -466,17 +485,8 @@ export const FindingsPanel: Component = (props) => { ? scopedPendingApprovalCount() : sourceFindingsWithPendingApprovals().length, })); - // Count of will_fix_later commitments whose RemindAt deadline has - // already passed. Drives the Overdue commitments chip below. - const overdueCount = createMemo(() => { - const nowMs = Date.now(); - return sourceFindings().filter((f) => { - if (f.dismissedReason !== 'will_fix_later') return false; - if (!f.remindAt) return false; - const due = Date.parse(f.remindAt); - return Number.isFinite(due) && due <= nowMs; - }).length; - }); + // Drives the Overdue commitments chip below. + const overdueCount = createMemo(() => overdueFindingIds().length); // Active findings that restate an active alert (backend-stamped mirror). // They are demoted into a collapsed group below the list rather than shown // as separate items: the alert already owns that condition on the Patrol @@ -1277,18 +1287,27 @@ export const FindingsPanel: Component = (props) => { {formatOperatorStateDismissCauseLabel(getOperatorStateDismissCause(finding))} - - - {' · '}Reminding {formatTime(finding.remindAt!)} - + + {(reminder) => ( + + {' · '} + {reminder().label} + + )} - - - {' · '}snoozed until {formatTime(finding.snoozedUntil!)} - + + {(snooze) => ( + + {' · '} + {snooze().label} + + )} diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx b/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx index f97849bbb8..9040e254d2 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx @@ -1,8 +1,9 @@ import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; -import type { JSX } from 'solid-js'; +import { createSignal, type JSX } from 'solid-js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FindingsPanel } from '../FindingsPanel'; +import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock'; const mockState = vi.hoisted(() => { const loadFindings = vi.fn(); @@ -771,3 +772,163 @@ describe('FindingsPanel investigate action gating', () => { expect(busyButton).toBeDisabled(); }); }); + +describe('FindingsPanel future times', () => { + const hour = 60 * 60 * 1000; + + beforeEach(() => { + mockState.findings = [...mockState.initialFindings]; + mockState.loadPatrolFindings.mockClear(); + mockState.findingsLoading = false; + mockState.findingsError = null; + mockState.patrolFindingsLoading = false; + mockState.patrolFindingsError = null; + mockState.patrolPendingApprovals = []; + }); + + it('counts reminders and snoozes down on the shared clock instead of reading just now', () => { + vi.useFakeTimers({ + toFake: ['Date', 'setInterval', 'clearInterval'], + now: Date.parse('2026-03-30T12:00:00Z'), + }); + mockState.patrolFindings = [ + { + ...mockState.initialPatrolFindings[0], + id: 'finding-later', + title: 'Backup job keeps slipping', + status: 'dismissed', + dismissedReason: 'will_fix_later', + remindAt: '2026-04-06T12:00:00Z', + }, + { + ...mockState.initialPatrolFindings[0], + id: 'finding-snoozed', + title: 'Swap pressure on vm-100', + status: 'snoozed', + snoozedUntil: '2026-03-30T15:00:00Z', + }, + ]; + + try { + render(() => ); + + const reminder = screen.getByText(/Reminding in 7d/); + const snooze = screen.getByText(/snoozed, returns in 3h/); + expect(reminder).toHaveAttribute( + 'title', + expect.stringMatching( + /^Reminder due .+\. Pulse brings this finding back on its first reminder check after that\.$/, + ), + ); + expect(snooze).toHaveAttribute('title', expect.stringMatching(/^Snoozed until /)); + expect(screen.queryByText(/just now/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Overdue commitments/ })).not.toBeInTheDocument(); + + // The findings are not re-read: only the shared clock moves. + vi.advanceTimersByTime(2 * hour); + expect(snooze).toHaveTextContent('snoozed, returns in 1h'); + + vi.advanceTimersByTime(hour); + expect(snooze).toHaveTextContent('snooze ended'); + + vi.setSystemTime(Date.parse('2026-04-06T12:30:00Z')); + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); + expect(reminder).toHaveTextContent('Reminder overdue'); + // The Overdue commitments chip reads the same clock as the label, with no + // findings re-read. + expect(screen.getByRole('button', { name: /Overdue commitments \(1\)/ })).toBeInTheDocument(); + expect(reminder).toHaveAttribute('title', expect.stringMatching(/^Reminder was due /)); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps overdue rows mounted across clock ticks and adds a reminder once it falls due', () => { + vi.useFakeTimers({ + toFake: ['Date', 'setInterval', 'clearInterval'], + now: Date.parse('2026-03-30T12:00:00Z'), + }); + mockState.patrolFindings = [ + { + ...mockState.initialPatrolFindings[0], + id: 'finding-overdue', + title: 'Certificate renewal keeps slipping', + status: 'dismissed', + dismissedReason: 'will_fix_later', + remindAt: '2026-03-30T11:00:00Z', + }, + { + ...mockState.initialPatrolFindings[0], + id: 'finding-due-soon', + title: 'Backup job keeps slipping', + status: 'dismissed', + dismissedReason: 'will_fix_later', + remindAt: '2026-03-30T12:10:00Z', + }, + ]; + + try { + render(() => ); + fireEvent.click(screen.getByRole('button', { name: /Overdue commitments \(1\)/ })); + + const review = screen.getByRole('button', { + name: 'Review issue for Certificate renewal keeps slipping', + }); + review.focus(); + expect(screen.queryByText('Backup job keeps slipping')).not.toBeInTheDocument(); + + // A tick that moves no deadline past now leaves the rows alone. + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); + expect(review.isConnected).toBe(true); + expect(document.activeElement).toBe(review); + + // Once the second reminder falls due it joins the open Overdue filter + // and the chip count, on the same clock as the row labels. + vi.advanceTimersByTime(10 * 60 * 1000); + expect(screen.getByText('Backup job keeps slipping')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Overdue commitments \(2\)/ })).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('calls a reminder overdue only once the Overdue commitments list holds it', () => { + vi.useFakeTimers({ + toFake: ['Date', 'setInterval', 'clearInterval'], + now: Date.parse('2026-03-30T12:00:00Z'), + }); + mockState.patrolFindings = [ + { + ...mockState.initialPatrolFindings[0], + id: 'finding-due', + title: 'Certificate renewal keeps slipping', + status: 'dismissed', + dismissedReason: 'will_fix_later', + remindAt: '2026-03-30T12:00:05Z', + }, + ]; + const [filter, setFilter] = createSignal<'active' | 'resolved'>('active'); + + try { + render(() => ); + expect(screen.queryByText('Certificate renewal keeps slipping')).not.toBeInTheDocument(); + + // The deadline passes between ticks of the shared clock, and the row + // first mounts before the next tick: it must not call itself overdue + // while the chip and filter do not count it. + vi.setSystemTime(Date.parse('2026-03-30T12:00:10Z')); + setFilter('resolved'); + expect(screen.getByText('Certificate renewal keeps slipping')).toBeInTheDocument(); + expect(screen.getByText(/Reminder due/)).toBeInTheDocument(); + expect(screen.queryByText(/Reminder overdue/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Overdue commitments/ })).not.toBeInTheDocument(); + + // The next tick moves the label, the chip and the filter together. + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); + expect(screen.getByText(/Reminder overdue/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Overdue commitments \(1\)/ })).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index d5f0381115..ebc272e95c 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -22,6 +22,8 @@ import { getFindingSeveritySortOrder, getFindingResolutionReason, getFindingRecencyPresentation, + getFindingReminderPresentation, + getFindingSnoozePresentation, getFindingLoopStateBadgeClasses, getFindingLoopStateBadgeTone, getFindingSeverityBadgeClasses, @@ -369,11 +371,14 @@ describe('FindingsPanel assistant handoff', () => { // Once a finding is dismissed as will_fix_later, the row must surface the // pending reminder so the operator knows the commitment exists; otherwise // the only place the deadline lives is the lifecycle log. The amber tone - // signals "pending operator action" rather than a generic muted note. - expect(findingsPanelSource).toContain( - "finding.dismissedReason === 'will_fix_later' && finding.remindAt", + // signals "pending operator action" rather than a generic muted note. The + // deadline is in the future, so it counts down on the shared clock rather + // than going through the past-only age formatter, and it reads overdue from + // the same list as the Overdue commitments chip. + expect(findingsPanelSource).toMatch( + /getFindingReminderPresentation\(\s*finding,\s*now\(\),\s*overdueFindingIdSet\(\)\.has\(finding\.id\),?\s*\)/, ); - expect(findingsPanelSource).toContain('Reminding {formatTime(finding.remindAt!)}'); + expect(findingsPanelSource).not.toContain('formatTime(finding.remindAt'); expect(findingsPanelSource).toContain('text-amber-600 dark:text-amber-400'); }); @@ -652,6 +657,92 @@ describe('aiFindingPresentation', () => { }); }); + describe('findingReminderAndSnoozePresentation', () => { + const now = Date.parse('2026-03-25T12:00:00Z'); + + it('counts down to a will_fix_later reminder instead of reading it as just now', () => { + const reminder = getFindingReminderPresentation( + { dismissedReason: 'will_fix_later', remindAt: '2026-03-31T12:00:00Z' }, + now, + false, + ); + expect(reminder?.label).toBe('Reminding in 6d'); + expect(reminder?.title).toMatch( + /^Reminder due .+\. Pulse brings this finding back on its first reminder check after that\.$/, + ); + expect(reminder?.title).not.toContain('still tripping'); + expect( + getFindingReminderPresentation( + { dismissedReason: 'will_fix_later', remindAt: '2026-03-25T15:00:00Z' }, + now, + false, + )?.label, + ).toBe('Reminding in 3h'); + }); + + it('calls a reminder overdue once the Overdue commitments list holds it', () => { + const reminder = getFindingReminderPresentation( + { dismissedReason: 'will_fix_later', remindAt: '2026-03-25T11:00:00Z' }, + now, + true, + ); + expect(reminder?.label).toBe('Reminder overdue'); + expect(reminder?.title).toMatch( + /^Reminder was due .+\. Pulse brings this finding back on its next reminder check\.$/, + ); + // Past the deadline but not yet on the list (it is sampled on the next + // clock tick), so the chip and filter would not count it yet. + expect( + getFindingReminderPresentation( + { dismissedReason: 'will_fix_later', remindAt: '2026-03-25T11:59:50Z' }, + now, + false, + )?.label, + ).toBe('Reminder due'); + }); + + it('shows no reminder for other dismissals or an unusable remind time', () => { + expect( + getFindingReminderPresentation( + { dismissedReason: 'not_an_issue', remindAt: '2026-03-31T12:00:00Z' }, + now, + false, + ), + ).toBeNull(); + expect( + getFindingReminderPresentation({ dismissedReason: 'will_fix_later' }, now, false), + ).toBeNull(); + expect( + getFindingReminderPresentation( + { dismissedReason: 'will_fix_later', remindAt: 'not a date' }, + now, + true, + ), + ).toBeNull(); + }); + + it('counts down to the end of a snooze and keeps the exact time on hover', () => { + const snooze = getFindingSnoozePresentation( + { status: 'snoozed', snoozedUntil: '2026-03-25T15:00:00Z' }, + now, + ); + expect(snooze?.label).toBe('snoozed, returns in 3h'); + expect(snooze?.title).toMatch(/^Snoozed until .+/); + expect( + getFindingSnoozePresentation( + { status: 'snoozed', snoozedUntil: '2026-03-25T11:00:00Z' }, + now, + )?.label, + ).toBe('snooze ended'); + expect( + getFindingSnoozePresentation( + { status: 'active', snoozedUntil: '2026-03-25T15:00:00Z' }, + now, + ), + ).toBeNull(); + }); + }); + describe('patrolFindingClassification', () => { it('describes grouped Patrol findings as issues instead of internal signals', () => { expect(getPatrolFindingIssueCountLabel(0)).toBe('0 issues'); @@ -997,7 +1088,8 @@ describe('aiFindingPresentation', () => { it('uses explicit textual separators in finding metadata instead of relying on visual spacing', () => { expect(findingsPanelSource).toContain("{' · '}acknowledged"); expect(findingsPanelSource).toContain("{' · '}last investigated"); - expect(findingsPanelSource).toContain("{' · '}snoozed until"); + expect(findingsPanelSource).toMatch(/\{' · '\}\s*\{snooze\(\)\.label\}/); + expect(findingsPanelSource).toMatch(/\{' · '\}\s*\{reminder\(\)\.label\}/); }); it('keeps the Open work heading free of a duplicate count badge', () => { diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index 933012a2a6..7101220abd 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -63,7 +63,7 @@ import { import type { EvidenceEnvelope } from '@/types/operationalTrust'; import type { ActionDetailResponse } from '@/types/actionAudit'; import { getAlertResourceIncidentAcknowledgedByLabel } from '@/utils/alertIncidentPresentation'; -import { formatRelativeTime } from '@/utils/format'; +import { formatAbsoluteTime, formatRelativeTime, formatTimeUntil } from '@/utils/format'; import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { copyToClipboard } from '@/utils/clipboard'; import type { PatrolAutonomyLevel } from '@/api/patrol'; @@ -152,14 +152,31 @@ function getAttentionSuppressionEndPresentation(reviewed: boolean) { durationLabel: 'End suppression after', actionLabel: 'End suppression', successLabel: 'Suppression ended. It stays reviewed', + countdownLabel: (remaining: string) => `Suppression ends ${remaining}. It stays reviewed.`, } : { durationLabel: 'Return it to active attention after', actionLabel: 'Return to active attention', successLabel: 'Returned to decision inbox', + countdownLabel: (remaining: string) => `Returns to active attention ${remaining}.`, }; } +// The suppression line counts down to the expiry in the same words as the +// early-end button, and says so once the expiry has passed. +export function getAttentionSuppressionExpiryLabel( + expiresAt: string, + reviewed: boolean, + now: number, +): string { + const expiry = Date.parse(expiresAt); + if (!Number.isFinite(expiry)) return ''; + if (expiry <= now) return 'Suppression has ended.'; + return getAttentionSuppressionEndPresentation(reviewed).countdownLabel( + formatTimeUntil(expiry, { compact: true, now }), + ); +} + export function PatrolAttentionWorkbench( props: { autonomyLevel?: PatrolAutonomyLevel; @@ -1284,8 +1301,9 @@ function AttentionLifecycleControls(props: { onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise; onUnsuppress: (itemId: string, reviewed: boolean) => Promise; }) { - // The acknowledgement time never changes while the detail stays open, so its - // age reads the shared clock. + // The acknowledgement and suppression expiry times never change while the + // detail stays open, so the acknowledgement age and the countdown to the + // expiry read the shared clock. const now = useRelativeTimeNow(); const [showSuppression, setShowSuppression] = createSignal(false); const [reason, setReason] = createSignal(''); @@ -1328,10 +1346,13 @@ function AttentionLifecycleControls(props: {

{(expiresAt) => ( -

- Returns to active attention {formatRelativeTime(expiresAt(), { compact: true })} - . -

+ + {(label) => ( +

+ {label()} +

+ )} +
)}
diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx index 5feb869b96..d347ccf974 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx @@ -91,6 +91,7 @@ vi.mock('@/features/actions/ActionReviewDialog', async () => { }); import { + getAttentionSuppressionExpiryLabel, getDistinctPatrolImpact, getPatrolDecisionDisplayTitle, PatrolAttentionWorkbench, @@ -519,6 +520,74 @@ describe('PatrolAttentionWorkbench', () => { } }); + it('counts down to when a suppressed issue returns to active attention', async () => { + vi.useFakeTimers({ + toFake: ['Date', 'setInterval', 'clearInterval'], + now: Date.parse('2026-07-19T08:10:00Z'), + }); + const suppressed = item({ state: 'suppressed' }); + const suppressedDetail = detail(suppressed); + // A 24-hour suppression set just now. + suppressedDetail.operationalRecord.suppression = { + at: '2026-07-19T08:10:00Z', + by: 'operator', + reason: 'Planned storage maintenance', + expiresAt: '2026-07-20T08:10:00Z', + }; + const handled = summary({ suppressedCount: 1 }); + apiMocks.getList.mockImplementation((scope: string) => + Promise.resolve(listResponse(scope === 'all' ? [suppressed] : [], handled)), + ); + apiMocks.getDetail.mockResolvedValue(suppressedDetail); + + try { + renderWorkbench(); + fireEvent.click(await screen.findByRole('button', { name: 'Reviewed and suppressed (1)' })); + fireEvent.click( + await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }), + ); + const detailRegion = await screen.findByRole('complementary', { + name: 'Database VM · Disk pressure', + }); + const expiry = await within(detailRegion).findByText(/Returns to active attention/); + expect(expiry).toHaveTextContent('Returns to active attention in 1d.'); + expect(expiry).toHaveAttribute('title', expect.stringMatching(/^Suppressed until /)); + + // The detail is not re-read: its suppression never changes and only the + // clock moves. + vi.advanceTimersByTime(4 * 60 * 60 * 1000); + expect(expiry).toHaveTextContent('Returns to active attention in 20h.'); + + vi.setSystemTime(Date.parse('2026-07-20T07:30:00Z')); + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); + expect(expiry).toHaveTextContent('Returns to active attention in 40m.'); + + vi.setSystemTime(Date.parse('2026-07-20T08:11:00Z')); + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); + expect(expiry).toHaveTextContent('Suppression has ended.'); + expect(detailRegion).not.toHaveTextContent('just now'); + expect(apiMocks.getDetail).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('says a reviewed issue stays reviewed when its suppression ends', () => { + const now = Date.parse('2026-07-19T08:10:00Z'); + // Expiry restores Acknowledged for a reviewed alert, which is not active + // attention, so only an unreviewed issue "returns to active attention". + expect(getAttentionSuppressionExpiryLabel('2026-07-20T08:10:00Z', true, now)).toBe( + 'Suppression ends in 1d. It stays reviewed.', + ); + expect(getAttentionSuppressionExpiryLabel('2026-07-19T11:10:00Z', false, now)).toBe( + 'Returns to active attention in 3h.', + ); + expect(getAttentionSuppressionExpiryLabel('2026-07-19T08:00:00Z', true, now)).toBe( + 'Suppression has ended.', + ); + expect(getAttentionSuppressionExpiryLabel('not a date', false, now)).toBe(''); + }); + it('keeps the detail header and queue controls pinned while a long decision scrolls', async () => { const active = item(); apiMocks.getList.mockResolvedValue( diff --git a/frontend-modern/src/utils/__tests__/format.test.ts b/frontend-modern/src/utils/__tests__/format.test.ts index 944ccdc882..422886dd12 100644 --- a/frontend-modern/src/utils/__tests__/format.test.ts +++ b/frontend-modern/src/utils/__tests__/format.test.ts @@ -13,6 +13,7 @@ import { formatUptime, formatAbsoluteTime, formatRelativeTime, + formatTimeUntil, getBackupInfo, } from '@/utils/format'; @@ -263,6 +264,67 @@ describe('formatRelativeTime', () => { }); }); +describe('formatTimeUntil', () => { + const now = Date.parse('2024-03-15T12:00:00Z'); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + + it('returns empty text for falsy or unparseable input', () => { + expect(formatTimeUntil(undefined, { now })).toBe(''); + expect(formatTimeUntil(0, { now, emptyText: '-' })).toBe('-'); + expect(formatTimeUntil('not a date', { now, emptyText: '-' })).toBe('-'); + }); + + it('counts down to a future time instead of reading it as just now', () => { + expect(formatTimeUntil(now + 30 * 1000, { now })).toBe('in under a minute'); + expect(formatTimeUntil(now + 5 * minute, { now, compact: true })).toBe('in 5m'); + expect(formatTimeUntil(now + 3 * hour, { now, compact: true })).toBe('in 3h'); + expect(formatTimeUntil(now + 6 * day, { now, compact: true })).toBe('in 6d'); + expect(formatTimeUntil(now + 45 * day, { now, compact: true })).toBe('in 45d'); + }); + + it('spells units out in the long form', () => { + expect(formatTimeUntil(now + minute, { now })).toBe('in 1 min'); + expect(formatTimeUntil(now + 5 * minute, { now })).toBe('in 5 mins'); + expect(formatTimeUntil(now + hour, { now })).toBe('in 1 hour'); + expect(formatTimeUntil(now + 5 * hour, { now })).toBe('in 5 hours'); + expect(formatTimeUntil(now + day, { now })).toBe('in 1 day'); + expect(formatTimeUntil(now + 7 * day, { now })).toBe('in 7 days'); + expect(formatTimeUntil(now + 60 * day, { now })).toBe('in 2 months'); + expect(formatTimeUntil(now + 365 * day, { now })).toBe('in 1 year'); + }); + + it('rounds to the nearest unit, so a duration just set reads as chosen', () => { + // A 24-hour, 1-hour and 7-day suppression set a few seconds ago. + expect(formatTimeUntil(now + day - 5000, { now, compact: true })).toBe('in 1d'); + expect(formatTimeUntil(now + hour - 5000, { now, compact: true })).toBe('in 1h'); + expect(formatTimeUntil(now + 7 * day - 5000, { now, compact: true })).toBe('in 7d'); + expect(formatTimeUntil(now + 2 * hour + 10 * minute, { now, compact: true })).toBe('in 2h'); + expect(formatTimeUntil(now + 2 * hour + 50 * minute, { now, compact: true })).toBe('in 3h'); + // Months round from the remaining time itself, not from rounded days. + expect(formatTimeUntil(now + 44 * day + 12 * hour, { now })).toBe('in 1 month'); + expect(formatTimeUntil(now + 45 * day + hour, { now })).toBe('in 2 months'); + }); + + it('reads as due once the time has arrived or passed', () => { + expect(formatTimeUntil(now, { now })).toBe('now'); + expect(formatTimeUntil(now - hour, { now, compact: true })).toBe('now'); + expect(formatTimeUntil(now - hour, { now, dueText: 'overdue' })).toBe('overdue'); + }); + + it('accepts ISO strings and Date objects and defaults now to the current time', () => { + vi.useFakeTimers(); + vi.setSystemTime(now); + try { + expect(formatTimeUntil('2024-03-15T15:00:00Z', { compact: true })).toBe('in 3h'); + expect(formatTimeUntil(new Date(now + 10 * minute), { compact: true })).toBe('in 10m'); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('getBackupInfo', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/frontend-modern/src/utils/aiFindingPresentation.ts b/frontend-modern/src/utils/aiFindingPresentation.ts index 5319f8e9eb..5327d14272 100644 --- a/frontend-modern/src/utils/aiFindingPresentation.ts +++ b/frontend-modern/src/utils/aiFindingPresentation.ts @@ -5,7 +5,7 @@ import type { MetadataBadgeTone } from '@/components/shared/MetadataBadge'; import { isLivePendingApproval } from '@/utils/approvalState'; import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions'; import { formatIdentifierLabel, titleCaseDelimitedLabel } from '@/utils/textPresentation'; -import { formatBytes } from '@/utils/format'; +import { formatAbsoluteTime, formatBytes, formatTimeUntil } from '@/utils/format'; const DEFAULT_BADGE_CLASSES = 'border-border bg-surface-alt text-muted'; const DEFAULT_LOOP_STATE_CLASSES = 'border-border bg-surface-alt text-muted'; @@ -275,6 +275,11 @@ export interface FindingRecencyPresentation { timestamp: string; } +export interface FindingCountdownPresentation { + label: string; + title: string; +} + export interface PatrolFindingClassification { kind: 'runtime' | 'infrastructure'; label: string; @@ -913,6 +918,49 @@ export const getFindingRecencyPresentation = ( }; }; +// A will_fix_later reminder and the end of a snooze are future times, so the +// row counts down to them ("Reminding in 6d") from the `now` the panel passes +// from the shared relative-time clock, and keeps the exact time on hover. A +// reminder reads overdue when `overdue` says the panel's overdue list holds it, +// the same list the Overdue commitments chip and filter count, so the three +// agree; read between the deadline and the clock tick that adds it there, it +// says "Reminder due". It stays overdue until the hourly reminder sweep brings +// the finding back, whether or not it still trips. A passed snooze has ended, +// so neither keeps counting. +export const getFindingReminderPresentation = ( + finding: Pick, + now: number, + overdue: boolean, +): FindingCountdownPresentation | null => { + if (finding.dismissedReason !== 'will_fix_later' || !finding.remindAt) return null; + const due = Date.parse(finding.remindAt); + if (!Number.isFinite(due)) return null; + const at = formatAbsoluteTime(due); + if (overdue) { + return { + label: 'Reminder overdue', + title: `Reminder was due ${at}. Pulse brings this finding back on its next reminder check.`, + }; + } + return { + label: + due <= now ? 'Reminder due' : `Reminding ${formatTimeUntil(due, { compact: true, now })}`, + title: `Reminder due ${at}. Pulse brings this finding back on its first reminder check after that.`, + }; +}; + +export const getFindingSnoozePresentation = ( + finding: Pick, + now: number, +): FindingCountdownPresentation | null => { + if (finding.status !== 'snoozed' || !finding.snoozedUntil) return null; + const until = Date.parse(finding.snoozedUntil); + if (!Number.isFinite(until)) return null; + const title = `Snoozed until ${formatAbsoluteTime(until)}`; + if (until <= now) return { label: 'snooze ended', title }; + return { label: `snoozed, returns ${formatTimeUntil(until, { compact: true, now })}`, title }; +}; + export const getInvestigationStatusBadgeClasses = (status: InvestigationStatus): string => INVESTIGATION_STATUS_CLASSES[status] || DEFAULT_BADGE_CLASSES; diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 147c64ef8e..2cae0d53c0 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -169,9 +169,18 @@ export function formatAbsoluteTime(timestamp: number | undefined): string { return `${day} ${month} ${hours}:${minutes}`; } +function relativeTimeInputMs(timestamp: number | string | Date): number { + if (typeof timestamp === 'number') return timestamp; + if (typeof timestamp === 'string') return new Date(timestamp).getTime(); + return timestamp.getTime(); +} + /** - * Format a timestamp as a human-readable relative time string. - * ALL relative time formatting MUST use this function. + * Format a timestamp as a human-readable relative age ("5m ago"). + * ALL relative ages MUST use this function. It is past-only: a time ahead of + * `now` is clock skew on something already observed and reads as "just now". + * A time meant to be in the future (an expiry, a reminder, a schedule) uses + * formatTimeUntil instead. * * @param timestamp - Unix ms number, ISO date string, Date object, or undefined * @param options.compact - Use short format: "5m ago" instead of "5 mins ago" @@ -185,19 +194,51 @@ export function formatRelativeTime( ): string { if (!timestamp) return options?.emptyText ?? ''; - let ms: number; - if (typeof timestamp === 'number') { - ms = timestamp; - } else if (typeof timestamp === 'string') { - ms = new Date(timestamp).getTime(); - } else { - ms = timestamp.getTime(); - } - - const diffMs = (options?.now ?? Date.now()) - ms; + const diffMs = (options?.now ?? Date.now()) - relativeTimeInputMs(timestamp); return formatTimeDiff(diffMs, options?.compact); } +/** + * Format a future timestamp as a countdown: "in 3h" (compact) or "in 3 hours". + * ALL relative future times (expiries, reminders, schedules) MUST use this + * function, because formatRelativeTime reads any future time as "just now". + * + * Unlike an age, a countdown rounds to the nearest unit, so a 24-hour + * suppression set a moment ago reads "in 1d" rather than "in 23h". + * + * @param timestamp - Unix ms number, ISO date string, Date object, or undefined + * @param options.compact - Use short format: "in 5m" instead of "in 5 mins" + * @param options.emptyText - Text for falsy or unparseable input (default: '') + * @param options.dueText - Text once the time has arrived or passed (default: 'now') + * @param options.now - The time to count down from (default: Date.now()). A + * countdown that must keep moving passes the shared relative-time clock. + */ +export function formatTimeUntil( + timestamp: number | string | Date | undefined, + options?: { compact?: boolean; emptyText?: string; dueText?: string; now?: number }, +): string { + if (!timestamp) return options?.emptyText ?? ''; + + const remainingMs = relativeTimeInputMs(timestamp) - (options?.now ?? Date.now()); + if (!Number.isFinite(remainingMs)) return options?.emptyText ?? ''; + if (remainingMs <= 0) return options?.dueText ?? 'now'; + if (remainingMs < 60_000) return 'in under a minute'; + + const compact = options?.compact; + const inUnits = (value: number, short: string, singular: string, plural: string) => + compact ? `in ${value}${short}` : `in ${value} ${value === 1 ? singular : plural}`; + + const minutes = Math.round(remainingMs / 60_000); + if (minutes < 60) return inUnits(minutes, 'm', 'min', 'mins'); + const hours = Math.round(remainingMs / 3_600_000); + if (hours < 24) return inUnits(hours, 'h', 'hour', 'hours'); + const days = Math.round(remainingMs / 86_400_000); + if (compact || days < 30) return inUnits(days, 'd', 'day', 'days'); + const months = Math.round(remainingMs / (30 * 86_400_000)); + if (months < 12) return inUnits(months, '', 'month', 'months'); + return inUnits(Math.round(remainingMs / (365 * 86_400_000)), '', 'year', 'years'); +} + /** * Formats a time difference in milliseconds to a human-readable string. * Internal helper used by formatRelativeTime and formatBackupAge.