From 11fe8a52af0ff634c4ced8457204bcf0e8ff3881 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:34:01 -0700 Subject: [PATCH 1/2] fix(connectors): round the grant age and the revoke age the same way shortAge floored days while the revoke line rounded, so one grant made 16 min before its revoke read 'granted 1d' beside 'revoked 2d ago' after 41h. shortAge now goes through relativeParts, the same rule as the revoke line. TASK-649. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- .../v2/__tests__/V2ConnectorTools.test.tsx | 14 +++++++++++++ .../src/v2/components/V2ConnectorTools.tsx | 21 ++++++++++--------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 22695150ab..543784966f 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -242,6 +242,20 @@ 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(); +}); + 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 From 583be15c7e4f7398c68e45d00431bcea570d7502 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:09:02 -0700 Subject: [PATCH 2/2] test(connectors): pin grant age rounding at the minute and hour boundaries Covers 5m40s -> 6m and 100m -> 2h, the sub-day cases the day-only regression test left open. TASK-649. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/v2/__tests__/V2ConnectorTools.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 543784966f..624081e3e9 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -256,6 +256,19 @@ test('TASK-649: the grant age and the revoke age round the same way on one row', 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'); });