Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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'); });
Expand Down
21 changes: 11 additions & 10 deletions frontend/src/v2/components/V2ConnectorTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -139,16 +139,17 @@ const MODE_RANK: Record<GrantWriteMode, number> = { 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, unknown>) => 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
Expand Down
Loading