diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 22695150ab..624081e3e9 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -242,6 +242,33 @@ test('rows carry the states table: a live grant pulses when used in the last 10 // TASK-164: every {{rel}} in these lines used to be filled with an English // relative time, so a zh grant read "Granted by sam 1小时前." worst of both. +// TASK-649: one timestamp pair, two ages. A grant made 16 minutes before its +// revoke and read 41h later must say the same day count for both lines, not +// "granted 1d" beside "revoked 2d ago". +test('TASK-649: the grant age and the revoke age round the same way on one row', async () => { + const createdAt = iso(-(41 * 3600_000 + 24 * 60_000)); + const revokedAt = iso(-(41 * 3600_000 + 8 * 60_000)); + const grant = { ...grantRevoked, createdAt, revokedAt, revokedBy: 'u1' }; + mockTwoEntries({ grants: { p1: [], p2: [grant] } }); + renderTools(); + const gone = await screen.findByRole('button', { name: 'View GitHub in Ops' }); + expect(within(gone).getByText(/granted 2d$/)).toBeInTheDocument(); + expect(within(gone).getByText('revoked by sam 2d ago')).toBeInTheDocument(); +}); + +// TASK-649: the same rule holds below a day. A grant's own age must round the +// way the revoke line rounds, at the minute and hour boundaries too. +test.each([ + [5 * 60_000 + 40_000, 'granted 6m'], + [100 * 60_000, 'granted 2h'], +])('TASK-649: a grant aged %p ms reads "%s" on its row', async (ageMs, expected) => { + const grant = { ...grantRevoked, createdAt: iso(-ageMs), revokedAt: null, revokedBy: null }; + mockTwoEntries({ grants: { p1: [], p2: [grant] } }); + renderTools(); + const row = await screen.findByRole('button', { name: 'View GitHub in Ops' }); + expect(within(row).getByText(new RegExp(`${expected}$`))).toBeInTheDocument(); +}); + test('TASK-164: the grant, budget and trail times read in zh-CN, not English', async () => { await i18nReady; await act(async () => { await i18n.changeLanguage('zh-CN'); }); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 6dfbe5a669..a5449dea0a 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -14,7 +14,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; import { useRelativeNow } from '../hooks/useRelativeNow'; -import { localizeRelativeTime, localizeWindow } from '../utils/localizeRelativeTime'; +import { localizeRelativeTime, localizeWindow, relativeParts } from '../utils/localizeRelativeTime'; import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; import { PlatformGlyph } from '../icons/platforms'; @@ -139,16 +139,17 @@ const MODE_RANK: Record = { read: 0, 'write-with-confirm // a JSX expression as copy — and these are ids, not words (TASK-164). const WRITE_MODES: GrantWriteMode[] = ['read', 'write-with-confirm', 'write']; -/** The age as a unit-suffixed number ("23d", "5m", "just now"), from the timestamp and keys. */ +/** + * The age as a unit-suffixed number ("23d", "5m", "just now"). Rounds through + * relativeParts, the same rule as the "revoked … ago" line, so one timestamp + * pair never reads "1d" in one place and "2d" in the other (TASK-649). + */ export const shortAge = (date: string | null | undefined, now: number, t: (key: string, options?: Record) => string): string => { - const timestamp = date ? new Date(date).getTime() : NaN; - if (!Number.isFinite(timestamp)) return t('time.age.justNow', { defaultValue: 'just now' }); - const minutes = Math.max(0, Math.floor((now - timestamp) / 60_000)); - if (minutes < 1) return t('time.age.justNow', { defaultValue: 'just now' }); - if (minutes < 60) return t('time.age.minutes', { defaultValue: '{{n}}m', n: minutes }); - const hours = Math.floor(minutes / 60); - if (hours < 24) return t('time.age.hours', { defaultValue: '{{n}}h', n: hours }); - return t('time.age.days', { defaultValue: '{{n}}d', n: Math.floor(hours / 24) }); + const parts = relativeParts(date, { includeFuture: false, now }); + if (parts.kind !== 'rel') return t('time.age.justNow', { defaultValue: 'just now' }); + if (parts.unit === 'minute') return t('time.age.minutes', { defaultValue: '{{n}}m', n: parts.count }); + if (parts.unit === 'hour') return t('time.age.hours', { defaultValue: '{{n}}h', n: parts.count }); + return t('time.age.days', { defaultValue: '{{n}}d', n: parts.count }); }; // Both surfaces used to carry their own copy of this grammar; the locale family