Skip to content

Commit 1b33c9e

Browse files
0x00101010ampagent
andauthored
feat(vibenet): display 200ms explorer timestamps (#100)
Co-authored-by: Amp <amp@ampcode.com>
1 parent 7fe3829 commit 1b33c9e

6 files changed

Lines changed: 148 additions & 21 deletions

File tree

app/vibenet/explorer/block/[hash]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ export default function ExplorerBlockPage({ params }: PageProps) {
6262
<Card className="bg-background p-6 dark:bg-white/5">
6363
<DetailList>
6464
<DetailRow label="Number">{num?.toLocaleString() ?? '—'}</DetailRow>
65-
<DetailRow label="Timestamp">{timeFromHex(block.timestamp)?.human ?? '—'}</DetailRow>
65+
<DetailRow label="Timestamp">
66+
{timeFromHex(block.timestamp, block.timestampMs)?.human ?? '—'}
67+
</DetailRow>
6668
<DetailRow label="Miner">
6769
<ExplorerLink
6870
kind="address"

app/vibenet/explorer/page.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ExplorerLink } from '../components/ExplorerLink';
1212
import { ExplorerSearch } from '../components/ExplorerSearch';
1313
import type { BlockRow, StatsRow, TxRow } from '../library/api-types';
1414
import { vibenetApi } from '../library/client';
15-
import { timeAgoFromSeconds } from '../library/explorer';
15+
import { timeAgoFromMilliseconds, timeAgoFromSeconds } from '../library/explorer';
1616

1717
const NEW_ROW_HIGHLIGHT = 'bg-bds-blue-0';
1818
const TH =
@@ -62,12 +62,24 @@ export default function ExplorerPage() {
6262

6363
useEffect(() => {
6464
let cancelled = false;
65+
let inFlight = false;
66+
let pollTimer: number | undefined;
67+
let rowHighlightTimer: number | undefined;
68+
let statHighlightTimer: number | undefined;
69+
const controller = new AbortController();
70+
71+
function schedule() {
72+
if (cancelled || document.hidden) return;
73+
pollTimer = window.setTimeout(() => void load(), 1_000);
74+
}
6575

6676
async function load() {
77+
if (cancelled || document.hidden || inFlight) return;
78+
inFlight = true;
6779
try {
6880
const [blocksRes, statsRes] = await Promise.all([
69-
vibenetApi.explorer.blocks(),
70-
vibenetApi.explorer.stats(),
81+
vibenetApi.explorer.blocks(controller.signal),
82+
vibenetApi.explorer.stats(controller.signal),
7183
]);
7284
if (cancelled) return;
7385

@@ -84,7 +96,8 @@ export default function ExplorerPage() {
8496
});
8597
if (fresh.size > 0) {
8698
setNewKeys(fresh);
87-
window.setTimeout(() => setNewKeys(new Set()), 1300);
99+
window.clearTimeout(rowHighlightTimer);
100+
rowHighlightTimer = window.setTimeout(() => setNewKeys(new Set()), 450);
88101
}
89102
const changed = new Set<string>();
90103
const prev = lastStats.current;
@@ -95,7 +108,8 @@ export default function ExplorerPage() {
95108
}
96109
if (changed.size > 0) {
97110
setStatHighlight(changed);
98-
window.setTimeout(() => setStatHighlight(new Set()), 1300);
111+
window.clearTimeout(statHighlightTimer);
112+
statHighlightTimer = window.setTimeout(() => setStatHighlight(new Set()), 450);
99113
}
100114
}
101115

@@ -113,14 +127,30 @@ export default function ExplorerPage() {
113127
setFetchError('Could not reach the explorer API. Retrying…');
114128
setLoading(false);
115129
}
130+
} finally {
131+
inFlight = false;
132+
schedule();
133+
}
134+
}
135+
136+
function handleVisibilityChange() {
137+
if (document.hidden) {
138+
window.clearTimeout(pollTimer);
139+
} else if (!inFlight) {
140+
window.clearTimeout(pollTimer);
141+
void load();
116142
}
117143
}
118144

145+
document.addEventListener('visibilitychange', handleVisibilityChange);
119146
void load();
120-
const timer = window.setInterval(() => void load(), 5_000);
121147
return () => {
122148
cancelled = true;
123-
window.clearInterval(timer);
149+
controller.abort();
150+
document.removeEventListener('visibilitychange', handleVisibilityChange);
151+
window.clearTimeout(pollTimer);
152+
window.clearTimeout(rowHighlightTimer);
153+
window.clearTimeout(statHighlightTimer);
124154
};
125155
}, []);
126156

@@ -144,7 +174,7 @@ export default function ExplorerPage() {
144174
<Card
145175
key={stat.key}
146176
className={cn(
147-
'bg-background p-4 transition-colors duration-700 dark:bg-white/5',
177+
'bg-background p-4 transition-colors duration-300 motion-reduce:transition-none dark:bg-white/5',
148178
statHighlight.has(stat.key) && NEW_ROW_HIGHLIGHT,
149179
)}
150180
>
@@ -178,7 +208,7 @@ export default function ExplorerPage() {
178208
key={b.hash}
179209
aria-label={`Block ${b.number}`}
180210
className={cn(
181-
'border-b border-bds-gray-10 transition-colors duration-1000 last:border-0 hover:bg-bds-gray-5/50 dark:border-white/10',
211+
'border-b border-bds-gray-10 transition-colors duration-300 motion-reduce:transition-none last:border-0 hover:bg-bds-gray-5/50 dark:border-white/10',
182212
newKeys.has(`block-${b.hash}`) && NEW_ROW_HIGHLIGHT,
183213
)}
184214
>
@@ -192,7 +222,9 @@ export default function ExplorerPage() {
192222
'whitespace-nowrap text-right text-bds-gray-60 dark:text-bds-gray-40',
193223
)}
194224
>
195-
{timeAgoFromSeconds(b.timestamp)}
225+
{b.timestamp_ms != null
226+
? timeAgoFromMilliseconds(b.timestamp_ms)
227+
: timeAgoFromSeconds(b.timestamp)}
196228
</td>
197229
</tr>
198230
))}
@@ -223,7 +255,7 @@ export default function ExplorerPage() {
223255
key={tx.hash}
224256
aria-label={`Transaction ${tx.hash}`}
225257
className={cn(
226-
'border-b border-bds-gray-10 transition-colors duration-1000 last:border-0 hover:bg-bds-gray-5/50 dark:border-white/10',
258+
'border-b border-bds-gray-10 transition-colors duration-300 motion-reduce:transition-none last:border-0 hover:bg-bds-gray-5/50 dark:border-white/10',
227259
newKeys.has(`tx-${tx.hash}`) && NEW_ROW_HIGHLIGHT,
228260
)}
229261
>
@@ -260,7 +292,7 @@ export default function ExplorerPage() {
260292
<div
261293
key={tx.hash}
262294
className={cn(
263-
'flex flex-col gap-1.5 border-b border-bds-gray-10 px-1 py-3 transition-colors duration-1000 last:border-0 dark:border-white/10',
295+
'flex flex-col gap-1.5 border-b border-bds-gray-10 px-1 py-3 transition-colors duration-300 motion-reduce:transition-none last:border-0 dark:border-white/10',
264296
newKeys.has(`tx-${tx.hash}`) && NEW_ROW_HIGHLIGHT,
265297
)}
266298
>

app/vibenet/explorer/tx/[hash]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ type TxBodyProps = {
358358

359359
function TxBody({ tx }: TxBodyProps) {
360360
const blockNum = hexToInt(tx.blockNumber);
361-
const ts = timeFromHex(tx.timestamp);
361+
const ts = timeFromHex(tx.timestamp, tx.blockTimestampMs);
362362
const typeInfo = txTypeLabel(tx.type, tx.typeHex ?? null);
363363
const status = STATUS_STYLE[tx.status];
364364
const memo = decodeMetadata(tx.metadata);

app/vibenet/library/api-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type BlockRow = {
2929
number: number;
3030
hash: string;
3131
timestamp: number;
32+
timestamp_ms?: number | null;
3233
miner: string;
3334
tx_count: number;
3435
gas_used: number;
@@ -148,6 +149,7 @@ export type ExplorerBlockResponse = {
148149
hash: Hex | null;
149150
parentHash: Hex;
150151
timestamp: Hex;
152+
timestampMs?: Hex;
151153
miner: Address;
152154
gasUsed: Hex;
153155
gasLimit: Hex;
@@ -184,6 +186,7 @@ export type ExplorerTxResponse = {
184186
blockHash: Hex;
185187
blockNumber: Hex | null;
186188
timestamp: Hex | null;
189+
blockTimestampMs?: Hex;
187190
from: Address;
188191
to: Address | null;
189192
value: Hex | null;

app/vibenet/library/explorer.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
hexToInt,
1616
scopeChips,
1717
scopeLabel,
18+
timeAgoFromMilliseconds,
19+
timeFromHex,
1820
txTypeLabel,
1921
weiToEth,
2022
} from './explorer';
@@ -35,6 +37,51 @@ const ADDR_A = '0x1111111111111111111111111111111111111111';
3537
const ADDR_B = '0x2222222222222222222222222222222222222222';
3638
const memoHex = padHex(stringToHex('refund #7'), { dir: 'right', size: 32 });
3739

40+
describe('explorer timestamp formatting', () => {
41+
const second = Date.UTC(2026, 0, 2, 3, 4, 5) / 1000;
42+
const secondsHex = `0x${second.toString(16)}`;
43+
44+
it.each([0, 200, 400, 600, 800])('shows the exact .%i millisecond phase', (part) => {
45+
const milliseconds = second * 1000 + part;
46+
const value = timeFromHex(
47+
secondsHex,
48+
`0x${milliseconds.toString(16)}`,
49+
milliseconds + 500,
50+
);
51+
52+
expect(value?.human).toContain(`.${part.toString().padStart(3, '0')}`);
53+
});
54+
55+
it('handles rollover to the next second', () => {
56+
const before = second * 1000 + 800;
57+
const after = (second + 1) * 1000;
58+
const beforeHuman = timeFromHex(secondsHex, `0x${before.toString(16)}`, after)?.human;
59+
const afterHuman = timeFromHex(
60+
`0x${(second + 1).toString(16)}`,
61+
`0x${after.toString(16)}`,
62+
after,
63+
)?.human;
64+
65+
expect(beforeHuman).toMatch(/05\.800/);
66+
expect(afterHuman).toMatch(/06\.000/);
67+
});
68+
69+
it('falls back to seconds without fabricating fractional precision', () => {
70+
const fallback = timeFromHex(secondsHex, undefined, second * 1000 + 800);
71+
const malformed = timeFromHex(secondsHex, 'not-hex', second * 1000 + 800);
72+
73+
expect(fallback?.human).not.toMatch(/\.\d{3}/);
74+
expect(fallback?.age).toBe('0s ago');
75+
expect(malformed).toEqual(fallback);
76+
});
77+
78+
it('formats recent millisecond ages and clamps future timestamps', () => {
79+
expect(timeAgoFromMilliseconds(10_000, 10_250)).toBe('0.2s ago');
80+
expect(timeAgoFromMilliseconds(10_000, 12_499)).toBe('2.4s ago');
81+
expect(timeAgoFromMilliseconds(11_000, 10_000)).toBe('0.0s ago');
82+
});
83+
});
84+
3885
describe('B20 memo calldata decoding', () => {
3986
it('decodes a transferWithMemo transaction', () => {
4087
const data =

app/vibenet/library/explorer.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,62 @@ import type { ExplorerTxLog } from './api-types';
88

99
// --- Time -----------------------------------------------------------------
1010

11-
export function timeAgoFromSeconds(ts: number): string {
12-
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - ts);
11+
export function timeAgoFromSeconds(ts: number, now = Date.now()): string {
12+
const seconds = Math.max(0, Math.floor(now / 1000) - ts);
1313
if (seconds < 60) return `${seconds}s ago`;
1414
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
1515
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
1616
return `${Math.floor(seconds / 86400)}d ago`;
1717
}
1818

19-
export function timeFromHex(hex: string | null): { human: string; age: string } | null {
20-
if (!hex) return null;
21-
const ts = Number.parseInt(hex, 16);
22-
if (Number.isNaN(ts)) return null;
23-
return { human: new Date(ts * 1000).toLocaleString(), age: timeAgoFromSeconds(ts) };
19+
export function timeAgoFromMilliseconds(ts: number, now = Date.now()): string {
20+
const milliseconds = Math.max(0, Math.floor(now - ts));
21+
if (milliseconds < 10_000) return `${(Math.floor(milliseconds / 100) / 10).toFixed(1)}s ago`;
22+
const seconds = Math.floor(milliseconds / 1000);
23+
if (seconds < 60) return `${seconds}s ago`;
24+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
25+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
26+
return `${Math.floor(seconds / 86400)}d ago`;
27+
}
28+
29+
function numberFromHexQuantity(hex: string | null | undefined): number | null {
30+
if (!hex || !/^0x[0-9a-f]+$/i.test(hex)) return null;
31+
try {
32+
const value = Number(BigInt(hex));
33+
return Number.isSafeInteger(value) ? value : null;
34+
} catch {
35+
return null;
36+
}
37+
}
38+
39+
export function timeFromHex(
40+
secondsHex: string | null,
41+
millisecondsHex?: string | null,
42+
now = Date.now(),
43+
): { human: string; age: string } | null {
44+
const milliseconds = numberFromHexQuantity(millisecondsHex);
45+
if (milliseconds !== null) {
46+
const date = new Date(milliseconds);
47+
return {
48+
human: date.toLocaleString(undefined, {
49+
year: 'numeric',
50+
month: 'numeric',
51+
day: 'numeric',
52+
hour: 'numeric',
53+
minute: '2-digit',
54+
second: '2-digit',
55+
fractionalSecondDigits: 3,
56+
}),
57+
age: timeAgoFromMilliseconds(milliseconds, now),
58+
};
59+
}
60+
61+
const seconds = numberFromHexQuantity(secondsHex);
62+
if (seconds === null) return null;
63+
return {
64+
human: new Date(seconds * 1000).toLocaleString(),
65+
age: timeAgoFromSeconds(seconds, now),
66+
};
2467
}
2568

2669
// --- Numbers --------------------------------------------------------------

0 commit comments

Comments
 (0)