diff --git a/apps/edge/src/edge.test.ts b/apps/edge/src/edge.test.ts index b138ff2..09abc23 100644 --- a/apps/edge/src/edge.test.ts +++ b/apps/edge/src/edge.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { Wallet, TypedDataEncoder } from 'ethers'; import { buildDeployAuthorizationTypedData } from '@apogee/core'; -import { buildEdgeServer, skillInvokeBootstrapComplete, skillInvokeCanRunAfterBootstrap } from './index.js'; -import type { BillingChainClient, StorageBoundary } from '@apogee/billing'; +import { buildEdgeServer, buildReceiptHeatmapCells, skillInvokeBootstrapComplete, skillInvokeCanRunAfterBootstrap } from './index.js'; +import type { BillingChainClient, ReceiptIndexRow, StorageBoundary } from '@apogee/billing'; const address = '0x0000000000000000000000000000000000000001'; const txHash = '0x'.padEnd(66, '1'); @@ -57,6 +57,20 @@ describe('edge API', () => { expect(skillInvokeCanRunAfterBootstrap('activating', false, 'active')).toBe(true); }); + it('buckets receipt heatmap by UTC calendar day and hour', () => { + const now = Date.parse('2026-05-22T10:30:00.000Z'); + const rows: ReceiptIndexRow[] = [ + { receiptId: 'r1', agentId: '1', actionTag: 'text.summarize', payloadHash: '0x1', storageRoot: '0x2', valueWei: '0', txHash, status: 'minted', createdAt: '2026-05-21T23:15:00.000Z' }, + { receiptId: 'r2', agentId: '1', actionTag: 'text.summarize', payloadHash: '0x3', storageRoot: '0x4', valueWei: '0', txHash, status: 'minted', createdAt: '2026-05-22T09:00:00.000Z' }, + { receiptId: 'r3', agentId: '1', actionTag: 'text.summarize', payloadHash: '0x5', storageRoot: '0x6', valueWei: '0', txHash, status: 'minted', createdAt: '2026-05-15T23:59:00.000Z' }, + ]; + + expect(buildReceiptHeatmapCells(rows, 7, now)).toEqual([ + { day: 5, hour: 23, count: 1 }, + { day: 6, hour: 9, count: 1 }, + ]); + }); + it('serves health, docs, quote, settle, auth-gated agent, memory, receipt, and refund routes', async () => { const app = buildEdgeServer({ chainClient: new FakeChain(), storageClient: new FakeStorage(), signerKey, chainId: 16602, paymentRouterAddress: address, receiptBookAddress: address, jwtSecret: 'test-secret' }); await app.ready(); diff --git a/apps/edge/src/index.ts b/apps/edge/src/index.ts index 9df027d..4fd702d 100644 --- a/apps/edge/src/index.ts +++ b/apps/edge/src/index.ts @@ -539,6 +539,31 @@ const problem = (reply: FastifyReply, status: number, title: string, detail: str }; const sameAddress = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); +export function buildReceiptHeatmapCells(rows: ReceiptIndexRow[], days: number, nowMs = Date.now()): Array<{ day: number; hour: number; count: number }> { + const now = new Date(nowMs); + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const dayIndexByDate = new Map(); + for (let dayIndex = 0; dayIndex < days; dayIndex += 1) { + const date = new Date(todayUtc - (days - 1 - dayIndex) * 86_400_000).toISOString().slice(0, 10); + dayIndexByDate.set(date, dayIndex); + } + + const cells = new Map(); + for (const receipt of rows) { + const created = new Date(receipt.createdAt); + if (!Number.isFinite(created.getTime())) continue; + const day = dayIndexByDate.get(created.toISOString().slice(0, 10)); + if (day === undefined) continue; + const hour = created.getUTCHours(); + const key = `${day}:${hour}`; + const cell = cells.get(key) ?? { day, hour, count: 0 }; + cell.count += 1; + cells.set(key, cell); + } + + return [...cells.values()].sort((a, b) => a.day - b.day || a.hour - b.hour); +} + async function requireAuth(request: FastifyRequest): Promise { await request.jwtVerify(); return { address: getAddress(request.user.address) }; @@ -1680,21 +1705,7 @@ export function buildEdgeServer(options: EdgeServerOptions): FastifyInstance { const ownedAgentIds = new Set([...store.agents.values()].filter((agent) => sameAddress(agent.owner, user.address)).map((agent) => agent.id)); rows = rows.filter((receipt) => ownedAgentIds.has(receipt.agentId)); } - const cells = new Map(); - const now = Date.now(); - for (const receipt of rows) { - const created = new Date(receipt.createdAt).getTime(); - if (!Number.isFinite(created)) continue; - const ageDays = Math.floor((now - created) / 86_400_000); - if (ageDays < 0 || ageDays >= query.days) continue; - const day = query.days - 1 - ageDays; - const hour = new Date(receipt.createdAt).getHours(); - const key = `${day}:${hour}`; - const cell = cells.get(key) ?? { day, hour, count: 0 }; - cell.count += 1; - cells.set(key, cell); - } - return [...cells.values()].sort((a, b) => a.day - b.day || a.hour - b.hour); + return buildReceiptHeatmapCells(rows, query.days); }); // ── Public proofs data (no auth, ISR-friendly) ──────────────────────────── diff --git a/apps/web/src/components/dashboard/DashboardHeatmap.tsx b/apps/web/src/components/dashboard/DashboardHeatmap.tsx index 17ba126..6aa687d 100644 --- a/apps/web/src/components/dashboard/DashboardHeatmap.tsx +++ b/apps/web/src/components/dashboard/DashboardHeatmap.tsx @@ -1,17 +1,19 @@ +import React from 'react'; import type { HeatmapCell } from '@/lib/types'; const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const HOURS = Array.from({ length: 24 }, (_, i) => i); -// Edge returns day 0 = oldest, day 6 = today. Map each index to an actual date. +// Edge returns UTC calendar day 0 = oldest, day 6 = today. Keep labels in +// the same UTC frame as /v1/receipts/heatmap and /v1/proofs bucketing. function buildDayLabels(): { short: string; full: string }[] { const today = new Date(); + const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); return Array.from({ length: 7 }, (_, i) => { - const d = new Date(today); - d.setDate(today.getDate() - (6 - i)); + const d = new Date(todayUtc - (6 - i) * 86_400_000); return { - short: `${WEEKDAYS[d.getDay()]} ${d.getDate()}`, - full: d.toDateString(), + short: `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()}`, + full: d.toISOString().slice(0, 10), }; }); }