diff --git a/dashboard/app/lib/score-series.test.ts b/dashboard/app/lib/score-series.test.ts index b75cf7d..2ca3254 100644 --- a/dashboard/app/lib/score-series.test.ts +++ b/dashboard/app/lib/score-series.test.ts @@ -17,7 +17,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { HistoryEntry, RiskFactorMap } from './contract.ts'; -import { buildScoreSeries, GAP_BREAK_FACTOR, timeTicks } from './score-series.ts'; +import { + buildScoreSeries, + GAP_BREAK_FACTOR, + protocolHistoryExportHref, + protocolHistoryExportLinks, + timeTicks, +} from './score-series.ts'; const T0 = Date.parse('2026-08-14T08:00:00.000Z'); const FIVE_MIN = 5 * 60_000; @@ -244,3 +250,36 @@ describe('timeTicks', () => { assert.ok(ticks[0].step >= 86_400_000, 'a month-wide axis should not tick hourly'); }); }); + +describe('protocolHistoryExportLinks', () => { + it('targets the historical export endpoint for CSV and JSON', () => { + assert.equal( + protocolHistoryExportHref('blend', 'csv'), + '/api/v1/protocol/blend/history/export?format=csv', + ); + assert.equal( + protocolHistoryExportHref('blend', 'json'), + '/api/v1/protocol/blend/history/export?format=json', + ); + assert.deepEqual( + protocolHistoryExportLinks('blend', 'Blend').map((link) => [link.label, link.href]), + [ + ['CSV', '/api/v1/protocol/blend/history/export?format=csv'], + ['JSON', '/api/v1/protocol/blend/history/export?format=json'], + ], + ); + }); + + it('encodes protocol id safely in the URL path', () => { + const url = protocolHistoryExportHref('market/pool#1', 'csv'); + assert.equal(url, '/api/v1/protocol/market%2Fpool%231/history/export?format=csv'); + }); + + it('provides accessible labels naming the protocol history', () => { + for (const link of protocolHistoryExportLinks('blend', 'Blend')) { + assert.match(link.ariaLabel, /^Download Blend score history as (CSV|JSON)$/); + assert.ok(link.href.startsWith('/api/v1/protocol/blend/history/export?format=')); + assert.equal(link.label.length > 0, true); + } + }); +}); diff --git a/dashboard/app/lib/score-series.ts b/dashboard/app/lib/score-series.ts index 45f3adb..6074cfd 100644 --- a/dashboard/app/lib/score-series.ts +++ b/dashboard/app/lib/score-series.ts @@ -22,6 +22,41 @@ import type { HistoryEntry } from './contract'; */ export const GAP_BREAK_FACTOR = 3; +export const PROTOCOL_HISTORY_EXPORT_FORMATS = ['csv', 'json'] as const; +export type ProtocolHistoryExportFormat = (typeof PROTOCOL_HISTORY_EXPORT_FORMATS)[number]; + +export interface ProtocolHistoryExportLink { + format: ProtocolHistoryExportFormat; + label: string; + href: string; + ariaLabel: string; +} + +export function protocolHistoryExportHref(id: string, format: ProtocolHistoryExportFormat): string { + return `/api/v1/protocol/${encodeURIComponent(id)}/history/export?format=${format}`; +} + +export function protocolHistoryExportLinks( + id: string, + protocolName?: string, +): ProtocolHistoryExportLink[] { + const target = protocolName ? `${protocolName} score history` : 'score history'; + return [ + { + format: 'csv', + label: 'CSV', + href: protocolHistoryExportHref(id, 'csv'), + ariaLabel: `Download ${target} as CSV`, + }, + { + format: 'json', + label: 'JSON', + href: protocolHistoryExportHref(id, 'json'), + ariaLabel: `Download ${target} as JSON`, + }, + ]; +} + /** Half-width of the synthetic domain used when every run shares one timestamp. */ const SINGLE_POINT_PAD_MS = 30 * 60_000; diff --git a/dashboard/app/protocol/[id]/page.tsx b/dashboard/app/protocol/[id]/page.tsx index 59a0c6e..2ade520 100644 --- a/dashboard/app/protocol/[id]/page.tsx +++ b/dashboard/app/protocol/[id]/page.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, Boxes, BookText, + Download, ExternalLink, Globe, RefreshCwOff, @@ -14,6 +15,7 @@ import { notesFor, type ProtocolNote } from '../../lib/protocol-notes'; import { getProtocolDetail, type HistoryEntry, type ProtocolDetail } from '../../lib/api'; import { formatTimestamp, freshness } from '../../lib/format'; import { contractExplorerUrl, shortenContractId } from '../../lib/explorer'; +import { protocolHistoryExportLinks } from '../../lib/score-series'; import { MarkAttribution, ProtocolLogo } from '../../../components/protocol-logo'; import { DeploymentBadge, DeploymentNotice } from '../../../components/deployment-badge'; import { OperationalBadge, OperationalNotice } from '../../../components/operational-badge'; @@ -119,7 +121,7 @@ export default async function ProtocolDetailPage({ params }: { params: Promise<{ - + @@ -378,19 +380,52 @@ function RefLink({ * The score over time — the visible form of the "continuous, not static" pitch. * The chart is the reading; the run list below it is the receipts. */ -function ScoreHistory({ history }: { history: HistoryEntry[] }) { +function ScoreHistory({ + history, + protocolId, + protocolName, +}: { + history: HistoryEntry[]; + protocolId: string; + protocolName: string; +}) { + const exportLinks = protocolHistoryExportLinks(protocolId, protocolName); + return (
-
-