Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions site/beacon/contract-history.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
76 changes: 67 additions & 9 deletions site/beacon/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
31 changes: 24 additions & 7 deletions site/beacon/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 += `<div class="t-section">-- CONTRACT HISTORY (${agentContracts.length}) --</div>`;
if (agentContracts.length > 0) {
html += `<div class="t-section">-- CONTRACTS --</div>`;
html += `<div class="contract-timeline" role="list">`;
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 += `<div class="contract-row ${escapeHtml(c.type)}">`;
html += `<span class="contract-type" style="background:${CONTRACT_STYLES_CSS[c.type]}">[${escapeHtml(c.type.toUpperCase().replace('_', ' '))}]</span>`;
html += `<span>${dir} ${escapeHtml(other ? other.name : '?')} ${escapeHtml(c.amount)} ${escapeHtml(c.currency)}</span>`;
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 += `<div class="contract-timeline-item" role="listitem">`;
html += `<span class="contract-timeline-marker" aria-hidden="true"></span>`;
html += `<time class="contract-timeline-time"${c.createdAtIso ? ` datetime="${escapeHtml(c.createdAtIso)}"` : ''}>${escapeHtml(formatContractTimestamp(c.created_at))}</time>`;
html += `<div class="contract-timeline-main">`;
html += `<span class="contract-type" style="background:${typeBackground}">[${escapeHtml(c.type.toUpperCase().replaceAll('_', ' '))}]</span>`;
html += `<span class="contract-direction direction-${escapeHtml(c.direction)}">${direction} ${escapeHtml(other ? other.name : '?')}</span>`;
html += `<span class="contract-state state-${escapeHtml(c.state)}">${escapeHtml(c.state)}</span>`;
html += `</div>`;
html += `<div class="contract-timeline-details">`;
html += `<span>${escapeHtml(directionLabel)}</span>`;
html += `<span>${escapeHtml(c.amount)} ${escapeHtml(c.currency)}</span>`;
html += `<span>TERM ${escapeHtml(c.term || '?')}</span>`;
html += `<span>ID ${escapeHtml(c.id)}</span>`;
html += `</div>`;
html += `</div>`;
}
html += `</div>`;
} else {
html += `<div class="contract-history-empty">No contracts recorded for this agent.</div>`;
}

// Source badges
Expand Down
76 changes: 76 additions & 0 deletions tests/beacon_contract_history.test.mjs
Original file line number Diff line number Diff line change
@@ -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([], ''), []);
});
49 changes: 49 additions & 0 deletions tests/test_beacon_contract_history_ui.py
Original file line number Diff line number Diff line change
@@ -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()