From fe1419e62e14c149c0da85dd243a0e5fa4c6f53b Mon Sep 17 00:00:00 2001 From: mysma-9403 <64923976+mysma-9403@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:28:11 +0200 Subject: [PATCH] refactor(agentworld): extract shared relativeTime helper (#4427) (#4518) --- app/src/agentworld/pages/BountiesSection.tsx | 12 +--- .../agentworld/pages/ExploreSection/index.tsx | 11 +--- app/src/agentworld/pages/FeedSection.tsx | 12 +--- app/src/agentworld/pages/JobsSection.tsx | 13 +---- app/src/agentworld/pages/LedgerSection.tsx | 12 +--- app/src/agentworld/pages/relativeTime.test.ts | 56 +++++++++++++++++++ app/src/agentworld/pages/relativeTime.ts | 29 ++++++++++ 7 files changed, 90 insertions(+), 55 deletions(-) create mode 100644 app/src/agentworld/pages/relativeTime.test.ts create mode 100644 app/src/agentworld/pages/relativeTime.ts diff --git a/app/src/agentworld/pages/BountiesSection.tsx b/app/src/agentworld/pages/BountiesSection.tsx index afd416934..6849c1ae2 100644 --- a/app/src/agentworld/pages/BountiesSection.tsx +++ b/app/src/agentworld/pages/BountiesSection.tsx @@ -32,6 +32,7 @@ import type { ToastNotification } from '../../types/intelligence'; import { apiClient } from '../AgentWorldShell'; import { decimalsForAsset, resolveAssetSymbol } from '../assets'; import X402ConfirmDialog, { formatUnits } from '../components/X402ConfirmDialog'; +import { relativeTime } from './relativeTime'; // ── State types ─────────────────────────────────────────────────────────────── @@ -42,17 +43,6 @@ type BountiesState = // ── Helpers ─────────────────────────────────────────────────────────────────── -function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(ms / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - /** Group the integer part of a numeric amount with thousands separators. */ function formatAmount(amount: string): string { if (!Number.isFinite(Number(amount))) return amount; diff --git a/app/src/agentworld/pages/ExploreSection/index.tsx b/app/src/agentworld/pages/ExploreSection/index.tsx index 4d771b91a..286d18ebf 100644 --- a/app/src/agentworld/pages/ExploreSection/index.tsx +++ b/app/src/agentworld/pages/ExploreSection/index.tsx @@ -29,6 +29,7 @@ import { useT } from '../../../lib/i18n/I18nContext'; import { apiClient } from '../../AgentWorldShell'; import { decimalsForAsset, resolveAssetSymbol } from '../../assets'; import { formatUnits } from '../../components/X402ConfirmDialog'; +import { relativeTime } from '../relativeTime'; const debug = debugFactory('agentworld:explore'); @@ -405,16 +406,6 @@ function JobSkeletonList() { ); } -function relativeTime(isoDate: string): string { - const delta = Date.now() - new Date(isoDate).getTime(); - const mins = Math.floor(delta / 60_000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - function JobRow({ job }: { job: GqlJobPosting }) { const skills = job.skills ?? []; return ( diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx index 63bd63379..36ae93cfe 100644 --- a/app/src/agentworld/pages/FeedSection.tsx +++ b/app/src/agentworld/pages/FeedSection.tsx @@ -32,6 +32,7 @@ import { import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; import ConfirmDialog from '../components/ConfirmDialog'; +import { relativeTime } from './relativeTime'; const log = debug('agentworld:feed'); @@ -65,17 +66,6 @@ type WalletResolution = { agentId: string | null; configured: WalletConfigured } // ── Helpers ─────────────────────────────────────────────────────────────────── -function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(ms / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - function isWalletLocked(message: string): boolean { return ( message.includes('wallet is not configured') || diff --git a/app/src/agentworld/pages/JobsSection.tsx b/app/src/agentworld/pages/JobsSection.tsx index e6ca1b825..c3218d896 100644 --- a/app/src/agentworld/pages/JobsSection.tsx +++ b/app/src/agentworld/pages/JobsSection.tsx @@ -29,6 +29,7 @@ import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; import { explorerTxUrl } from '../hooks/useX402Buy'; +import { relativeTime } from './relativeTime'; // ── State types ─────────────────────────────────────────────────────────────── @@ -39,18 +40,6 @@ type JobsState = // ── Helpers ─────────────────────────────────────────────────────────────────── -// TODO: extract shared relativeTime helper once Feed/Ledger/Jobs all use it. -function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(ms / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - /** * Group the integer part of a numeric amount with thousands separators while * preserving the original decimals ("1000000" → "1,000,000", "0.50" → "0.50"). diff --git a/app/src/agentworld/pages/LedgerSection.tsx b/app/src/agentworld/pages/LedgerSection.tsx index 51e27989f..efb5397a7 100644 --- a/app/src/agentworld/pages/LedgerSection.tsx +++ b/app/src/agentworld/pages/LedgerSection.tsx @@ -17,6 +17,7 @@ import { apiClient } from '../AgentWorldShell'; import { decimalsForAsset, resolveAssetSymbol } from '../assets'; import { formatUnits, friendlyNetwork } from '../components/X402ConfirmDialog'; import { explorerTxUrl } from '../hooks/useX402Buy'; +import { relativeTime } from './relativeTime'; // ── State types ─────────────────────────────────────────────────────────────── @@ -27,17 +28,6 @@ type LedgerState = // ── Helpers ─────────────────────────────────────────────────────────────────── -function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(ms / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - export function abbreviateAddress(addr: string | undefined): string { if (!addr) return '—'; if (addr.length <= 12) return addr; diff --git a/app/src/agentworld/pages/relativeTime.test.ts b/app/src/agentworld/pages/relativeTime.test.ts new file mode 100644 index 000000000..3e14ed963 --- /dev/null +++ b/app/src/agentworld/pages/relativeTime.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { relativeTime } from './relativeTime'; + +describe('relativeTime', () => { + const NOW = 1_700_000_000_000; + + const freezeAt = (now: number) => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }; + + const ago = (ms: number) => new Date(NOW - ms).toISOString(); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders sub-minute deltas as "just now"', () => { + freezeAt(NOW); + expect(relativeTime(ago(0))).toBe('just now'); + expect(relativeTime(ago(30_000))).toBe('just now'); + }); + + it('renders whole minutes', () => { + freezeAt(NOW); + expect(relativeTime(ago(60_000))).toBe('1m ago'); + expect(relativeTime(ago(5 * 60_000))).toBe('5m ago'); + expect(relativeTime(ago(59 * 60_000))).toBe('59m ago'); + }); + + it('renders whole hours', () => { + freezeAt(NOW); + expect(relativeTime(ago(60 * 60_000))).toBe('1h ago'); + expect(relativeTime(ago(3 * 60 * 60_000))).toBe('3h ago'); + expect(relativeTime(ago(23 * 60 * 60_000))).toBe('23h ago'); + }); + + it('renders whole days', () => { + freezeAt(NOW); + expect(relativeTime(ago(24 * 60 * 60_000))).toBe('1d ago'); + expect(relativeTime(ago(2 * 24 * 60 * 60_000))).toBe('2d ago'); + }); + + it('treats small negative deltas from clock skew as "just now"', () => { + freezeAt(NOW); + // Timestamp 5s in the future (server clock ahead of client). + expect(relativeTime(new Date(NOW + 5_000).toISOString())).toBe('just now'); + }); + + it('returns "just now" for an unparseable date instead of "NaNd ago"', () => { + freezeAt(NOW); + expect(relativeTime('not-a-date')).toBe('just now'); + expect(relativeTime('')).toBe('just now'); + }); +}); diff --git a/app/src/agentworld/pages/relativeTime.ts b/app/src/agentworld/pages/relativeTime.ts new file mode 100644 index 000000000..1a150273a --- /dev/null +++ b/app/src/agentworld/pages/relativeTime.ts @@ -0,0 +1,29 @@ +/** + * Format an ISO-8601 timestamp as a short, human-readable relative time + * (`just now`, `5m ago`, `3h ago`, `2d ago`). + * + * Single source of truth for the Agent World sections. Feed, Ledger, Jobs and + * Bounties each carried a byte-identical copy, and Explore a near-identical one + * (see issue #4427). Duplication meant any future change — adding weeks, + * localisation, or just a unit test — had to be applied in several places, and + * a missed update would silently produce inconsistent timestamps across + * sections. + * + * Sub-minute deltas, including the small negative ones produced by client/server + * clock skew, collapse to `just now`. An unparseable `iso` (whose `getTime()` + * is `NaN`) also collapses to `just now` rather than rendering `NaNd ago`. + */ +export function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + // `new Date('garbage').getTime()` is NaN; without this guard every `<` + // comparison below is false and it falls through to `NaN d ago`. Treat an + // unparseable timestamp as the safe default. + if (!Number.isFinite(ms)) return 'just now'; + const mins = Math.floor(ms / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +}