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 += `