diff --git a/site/beacon/contract-history.mjs b/site/beacon/contract-history.mjs new file mode 100644 index 000000000..c65e059cf --- /dev/null +++ b/site/beacon/contract-history.mjs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT + +const SECOND_TO_MILLISECOND_CUTOFF = 1_000_000_000_000; + +function text(value, fallback = '') { + const normalized = String(value ?? '').trim(); + return normalized || fallback; +} + +function compareText(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +export function contractTimeMillis(value) { + if (value === null || value === undefined || value === '') return null; + + const numeric = Number(value); + let milliseconds; + if (Number.isFinite(numeric)) { + if (numeric < 0) return null; + milliseconds = numeric < SECOND_TO_MILLISECOND_CUTOFF + ? numeric * 1000 + : numeric; + } else { + milliseconds = Date.parse(String(value)); + } + + if (!Number.isFinite(milliseconds)) return null; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? null : date.getTime(); +} + +export function contractTimestampIso(value) { + const milliseconds = contractTimeMillis(value); + return milliseconds === null ? '' : new Date(milliseconds).toISOString(); +} + +export function formatContractTimestamp(value) { + const iso = contractTimestampIso(value); + return iso ? `${iso.slice(0, 19).replace('T', ' ')} UTC` : 'TIME UNKNOWN'; +} + +export function buildContractHistory(contracts, agentId) { + const selectedId = text(agentId); + if (!selectedId || !Array.isArray(contracts)) return []; + + return contracts + .filter(contract => contract && typeof contract === 'object') + .map(contract => { + const from = text(contract.from); + const to = text(contract.to); + const direction = from === selectedId ? 'outgoing' : 'incoming'; + const counterpartyId = direction === 'outgoing' ? to : from; + const createdAtMillis = contractTimeMillis(contract.created_at); + const type = text(contract.type, 'contract').toLowerCase(); + const state = text(contract.state, 'unknown').toLowerCase(); + const id = text(contract.id, 'untracked'); + const deterministicKey = [ + id, from, to, type, state, + text(contract.amount), text(contract.currency), text(contract.term), + ].join('\u0000'); + + return { + ...contract, + id, + from, + to, + type, + state, + direction, + counterpartyId, + createdAtMillis, + createdAtIso: createdAtMillis === null + ? '' + : new Date(createdAtMillis).toISOString(), + deterministicKey, + }; + }) + .filter(contract => contract.from === selectedId || contract.to === selectedId) + .sort((left, right) => { + if (left.createdAtMillis === null && right.createdAtMillis !== null) return 1; + if (left.createdAtMillis !== null && right.createdAtMillis === null) return -1; + if (left.createdAtMillis !== right.createdAtMillis) { + return right.createdAtMillis - left.createdAtMillis; + } + return compareText(left.deterministicKey, right.deterministicKey); + }) + .map(({ deterministicKey: _deterministicKey, ...contract }) => contract); +} diff --git a/site/beacon/styles.css b/site/beacon/styles.css index 05b060fc1..df800c447 100644 --- a/site/beacon/styles.css +++ b/site/beacon/styles.css @@ -269,19 +269,74 @@ html, body { text-align: right; } -/* Contract rows */ -.contract-row { +/* Contract history timeline */ +.contract-timeline { + margin: 4px 0 8px 6px; +} + +.contract-timeline-item { + position: relative; + margin-left: 5px; + padding: 2px 0 12px 18px; + border-left: 1px solid var(--green-dim); + font-size: 12px; +} + +.contract-timeline-item:last-child { + border-left-color: transparent; + padding-bottom: 2px; +} + +.contract-timeline-marker { + position: absolute; + top: 8px; + left: -5px; + width: 9px; + height: 9px; + border: 1px solid var(--green); + border-radius: 50%; + background: var(--bg-terminal); + box-shadow: 0 0 7px var(--green-glow); +} + +.contract-timeline-time { + display: block; + color: var(--text-dim); + font-size: 10px; + letter-spacing: 0.3px; + margin-bottom: 2px; +} + +.contract-timeline-main { display: flex; align-items: center; - margin: 4px 0; - padding: 4px 6px; - border-left: 2px solid var(--green-dim); - font-size: 12px; + gap: 7px; + min-width: 0; +} + +.contract-direction { + color: var(--text-body); + min-width: 0; + overflow-wrap: anywhere; } -.contract-row.rent { border-color: var(--green); } -.contract-row.buy { border-color: var(--gold); } -.contract-row.lease_to_own { border-color: var(--amber); } +.direction-outgoing { color: var(--cyan); } +.direction-incoming { color: var(--green); } + +.contract-timeline-details { + display: flex; + flex-wrap: wrap; + gap: 3px 10px; + color: var(--text-dim); + font-size: 10px; + margin-top: 3px; +} + +.contract-history-empty { + color: var(--text-dim); + font-size: 11px; + padding: 3px 0 7px; +} .contract-type { text-transform: uppercase; @@ -307,6 +362,9 @@ html, body { .state-listed { color: var(--text-dim); background: rgba(100, 100, 100, 0.1); } .state-expired { color: #888; background: rgba(80, 80, 80, 0.1); } .state-breached { color: var(--red); background: rgba(255, 68, 68, 0.15); } +.state-completed { color: var(--gold); background: rgba(255, 215, 0, 0.1); } +.state-rejected { color: var(--red); background: rgba(255, 68, 68, 0.1); } +.state-unknown { color: var(--text-dim); background: rgba(100, 100, 100, 0.1); } /* Calibration rows */ .cal-row { diff --git a/site/beacon/ui.js b/site/beacon/ui.js index bcc27d986..3e9abcded 100644 --- a/site/beacon/ui.js +++ b/site/beacon/ui.js @@ -11,6 +11,7 @@ import { getAgentPosition, highlightAgent } from './agents.js'; import { getCityCenter } from './cities.js'; import { highlightAgentConnections, addContractLine } from './connections.js'; import { initChat, setCurrentAgent, getChatHTML, bindChatEvents } from './chat.js'; +import { buildContractHistory, formatContractTimestamp } from './contract-history.mjs'; const BEACON_API = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') ? 'http://localhost:8071' @@ -322,21 +323,37 @@ function selectAgent(agentId) { } } - // Contracts - const agentContracts = CONTRACTS.filter(c => c.from === agentId || c.to === agentId); + // Contract history + const agentContracts = buildContractHistory(CONTRACTS, agentId); + html += `
-- CONTRACT HISTORY (${agentContracts.length}) --
`; if (agentContracts.length > 0) { - html += `
-- CONTRACTS --
`; + html += `
`; for (const c of agentContracts) { const other = c.from === agentId ? AGENTS.find(a => a.id === c.to) : AGENTS.find(a => a.id === c.from); - const dir = c.from === agentId ? '->' : '<-'; - html += `
`; - html += `[${escapeHtml(c.type.toUpperCase().replace('_', ' '))}]`; - html += `${dir} ${escapeHtml(other ? other.name : '?')} ${escapeHtml(c.amount)} ${escapeHtml(c.currency)}`; + const direction = c.direction === 'outgoing' ? '->' : '<-'; + const directionLabel = c.direction === 'outgoing' ? 'OUTGOING' : 'INCOMING'; + const typeBackground = CONTRACT_STYLES_CSS[c.type] || 'rgba(100,136,100,0.15)'; + html += `
`; + html += ``; + html += ``; + html += `
`; + html += `[${escapeHtml(c.type.toUpperCase().replaceAll('_', ' '))}]`; + html += `${direction} ${escapeHtml(other ? other.name : '?')}`; html += `${escapeHtml(c.state)}`; html += `
`; + html += `
`; + html += `${escapeHtml(directionLabel)}`; + html += `${escapeHtml(c.amount)} ${escapeHtml(c.currency)}`; + html += `TERM ${escapeHtml(c.term || '?')}`; + html += `ID ${escapeHtml(c.id)}`; + html += `
`; + html += `
`; } + html += `
`; + } else { + html += `
No contracts recorded for this agent.
`; } // Source badges diff --git a/tests/beacon_contract_history.test.mjs b/tests/beacon_contract_history.test.mjs new file mode 100644 index 000000000..750253a18 --- /dev/null +++ b/tests/beacon_contract_history.test.mjs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildContractHistory, + contractTimeMillis, + contractTimestampIso, + formatContractTimestamp, +} from '../site/beacon/contract-history.mjs'; + +test('buildContractHistory filters both directions and sorts newest first', () => { + const history = buildContractHistory([ + { + id: 'ctr-old-incoming', from: 'bcn_bob', to: 'bcn_alice', + type: 'rent', amount: 4, currency: 'RTC', term: '7d', + state: 'completed', created_at: 1_700_000_000, + }, + { + id: 'ctr-unrelated', from: 'bcn_carol', to: 'bcn_dan', + type: 'buy', amount: 9, currency: 'RTC', term: 'once', + state: 'active', created_at: 1_900_000_000, + }, + { + id: 'ctr-new-outgoing', from: 'bcn_alice', to: 'bcn_carol', + type: 'bounty', amount: 15, currency: 'RTC', term: '14d', + state: 'active', created_at: 1_800_000_000, + }, + ], 'bcn_alice'); + + assert.deepEqual(history.map(contract => contract.id), [ + 'ctr-new-outgoing', + 'ctr-old-incoming', + ]); + assert.deepEqual( + history.map(({ direction, counterpartyId }) => ({ direction, counterpartyId })), + [ + { direction: 'outgoing', counterpartyId: 'bcn_carol' }, + { direction: 'incoming', counterpartyId: 'bcn_bob' }, + ], + ); +}); + +test('buildContractHistory is deterministic for tied and unknown timestamps', () => { + const contracts = [ + { id: 'ctr-z', from: 'bcn_alice', to: 'bcn_zed', created_at: null }, + { id: 'ctr-b', from: 'bcn_alice', to: 'bcn_bob', created_at: 1_700_000_000 }, + { id: 'ctr-a', from: 'bcn_ann', to: 'bcn_alice', created_at: 1_700_000_000 }, + ]; + + assert.deepEqual( + buildContractHistory(contracts, 'bcn_alice').map(contract => contract.id), + ['ctr-a', 'ctr-b', 'ctr-z'], + ); + assert.deepEqual(buildContractHistory([...contracts].reverse(), 'bcn_alice').map(contract => contract.id), [ + 'ctr-a', 'ctr-b', 'ctr-z', + ]); +}); + +test('contract timestamps accept API seconds, milliseconds, and ISO text', () => { + const expectedMilliseconds = 1_700_000_000_000; + assert.equal(contractTimeMillis(1_700_000_000), expectedMilliseconds); + assert.equal(contractTimeMillis(expectedMilliseconds), expectedMilliseconds); + assert.equal(contractTimeMillis('2023-11-14T22:13:20Z'), expectedMilliseconds); + assert.equal(contractTimestampIso(1_700_000_000), '2023-11-14T22:13:20.000Z'); + assert.equal(formatContractTimestamp(1_700_000_000), '2023-11-14 22:13:20 UTC'); +}); + +test('invalid inputs fail closed without breaking the selected-agent panel', () => { + assert.equal(contractTimeMillis('not-a-date'), null); + assert.equal(contractTimestampIso(null), ''); + assert.equal(formatContractTimestamp(undefined), 'TIME UNKNOWN'); + assert.deepEqual(buildContractHistory(null, 'bcn_alice'), []); + assert.deepEqual(buildContractHistory([], ''), []); +}); diff --git a/tests/test_beacon_contract_history_ui.py b/tests/test_beacon_contract_history_ui.py new file mode 100644 index 000000000..c01ca5e05 --- /dev/null +++ b/tests/test_beacon_contract_history_ui.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: MIT +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = ROOT / "site" / "beacon" / "ui.js" +STYLES = ROOT / "site" / "beacon" / "styles.css" + + +class TestBeaconContractHistoryUI(unittest.TestCase): + def test_selected_agent_panel_renders_complete_timeline_fields(self): + source = UI_JS.read_text(encoding="utf-8") + + self.assertIn( + "import { buildContractHistory, formatContractTimestamp } " + "from './contract-history.mjs';", + source, + ) + self.assertIn("buildContractHistory(CONTRACTS, agentId)", source) + self.assertIn("-- CONTRACT HISTORY (${agentContracts.length}) --", source) + self.assertIn('class="contract-timeline" role="list"', source) + self.assertIn("formatContractTimestamp(c.created_at)", source) + self.assertIn("direction-${escapeHtml(c.direction)}", source) + self.assertIn("state-${escapeHtml(c.state)}", source) + self.assertIn("${escapeHtml(c.amount)} ${escapeHtml(c.currency)}", source) + self.assertIn("TERM ${escapeHtml(c.term || '?')}", source) + self.assertIn("ID ${escapeHtml(c.id)}", source) + self.assertIn("No contracts recorded for this agent.", source) + + def test_timeline_styles_cover_layout_and_terminal_states(self): + source = STYLES.read_text(encoding="utf-8") + + for selector in ( + ".contract-timeline", + ".contract-timeline-item", + ".contract-timeline-marker", + ".contract-timeline-time", + ".contract-timeline-details", + ".direction-outgoing", + ".direction-incoming", + ".state-completed", + ".state-rejected", + ): + self.assertIn(selector, source) + + +if __name__ == "__main__": + unittest.main()