From 79f0d848c4962a4263988def2c7f4a2082e41395 Mon Sep 17 00:00:00 2001 From: Lennart van der Molen Date: Sat, 15 Nov 2025 13:10:18 +0100 Subject: [PATCH] Rewire activity balance history updates --- web/app.js | 48 ++++- web/ui/visuals/ActivityBalanceHistory.js | 14 +- web/ui/visuals/RadialUrchin.js | 245 +++++++++++++++-------- 3 files changed, 217 insertions(+), 90 deletions(-) diff --git a/web/app.js b/web/app.js index 89301b1..ddfee2f 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,10 @@ import { DEBUG } from './debug.js'; -import { createRadialUrchin } from './ui/visuals/RadialUrchin.js'; +import { + createRadialUrchin, + createBalanceHistoryEntry, + computeScheduleSignature, + MAX_HISTORY_ENTRIES as BALANCE_HISTORY_LIMIT, +} from './ui/visuals/RadialUrchin.js'; console.info('[app] app.js loaded'); @@ -98,6 +103,7 @@ const visualsState = { metaBar: null, metaSlot: null, runLabel: null, + balanceHistory: [], }; let lastVisualPayload = null; let lastVisualSchedule = null; @@ -315,6 +321,42 @@ function resolveVisualPayload(payload) { return null; } +function applyBalanceHistoryToUrchin() { + if (!visualsState.urchin || typeof visualsState.urchin.setBalanceHistory !== 'function') { + return; + } + visualsState.urchin.setBalanceHistory(visualsState.balanceHistory); +} + +function appendActivityBalanceSnapshot(schedule) { + if (!schedule || typeof schedule !== 'object') { + return; + } + const previous = + visualsState.balanceHistory.length > 0 + ? visualsState.balanceHistory[visualsState.balanceHistory.length - 1] + : null; + const nextRunNumber = + (previous && Number.isFinite(previous.runNumber) + ? previous.runNumber + : visualsState.balanceHistory.length) + 1; + const signature = computeScheduleSignature(schedule); + const entry = createBalanceHistoryEntry(schedule, { + runNumber: nextRunNumber, + highContrast: Boolean(visualsState.urchin?.state?.highContrast), + signature, + }); + if (!entry) { + return; + } + const next = [...visualsState.balanceHistory, entry]; + if (next.length > BALANCE_HISTORY_LIMIT) { + next.splice(0, next.length - BALANCE_HISTORY_LIMIT); + } + visualsState.balanceHistory = next; + applyBalanceHistoryToUrchin(); +} + function updateVisuals(payload) { lastVisualPayload = payload && typeof payload === 'object' ? payload : null; lastVisualSchedule = resolveVisualPayload(payload); @@ -376,6 +418,7 @@ function updateVisuals(payload) { visualsState.metaSlot.hidden = Boolean(visualsState.metaBar?.hidden); } visualsState.urchin.update({ data: lastVisualSchedule }); + applyBalanceHistoryToUrchin(); } catch (error) { console.error('[visuals] failed to update radial urchin:', error); } @@ -444,6 +487,7 @@ function maybeCreateUrchinInstance(schedule) { instance.attachRunMeta(visualsState.metaBar); } visualsState.urchin = instance; + applyBalanceHistoryToUrchin(); } } @@ -2629,6 +2673,8 @@ async function handleGenerate(event) { inputsSnapshot.budget = true; } + appendActivityBalanceSnapshot(result); + recordCalendarHistoryEntry({ archetype, seed: normalizedSeed, diff --git a/web/ui/visuals/ActivityBalanceHistory.js b/web/ui/visuals/ActivityBalanceHistory.js index 613e94b..5787cb9 100644 --- a/web/ui/visuals/ActivityBalanceHistory.js +++ b/web/ui/visuals/ActivityBalanceHistory.js @@ -71,10 +71,14 @@ export class ActivityBalanceHistory { } const fragment = document.createDocumentFragment(); - this.entries.forEach((entry, index) => { + const displayEntries = this.entries.slice().reverse(); + displayEntries.forEach((entry, index) => { + const runNumber = Number.isFinite(entry?.runNumber) + ? entry.runNumber + : this.entries.length - index; const badge = document.createElement('span'); badge.className = 'activity-balance-history__run'; - badge.textContent = entry.label || `Run #${index + 1}`; + badge.textContent = entry.label || `Run #${runNumber}`; if (entry.timestampLabel) { badge.title = entry.timestampLabel; } @@ -96,7 +100,7 @@ export class ActivityBalanceHistory { const bar = document.createElement('div'); bar.className = 'activity-balance-history__bar'; bar.setAttribute('role', 'list'); - bar.dataset.index = String(index); + bar.dataset.index = String(runNumber); bar.addEventListener('mouseleave', this.boundHideTooltip); entry.segments.forEach((segment, segmentIndex) => { @@ -105,7 +109,7 @@ export class ActivityBalanceHistory { element.style.setProperty('--segment-color', segment.color || '#6366f1'); element.style.setProperty('--segment-text-color', computeSegmentTextColor(segment.color)); element.style.flexGrow = String(segment.minutes); - element.dataset.entryIndex = String(index); + element.dataset.entryIndex = String(runNumber); element.dataset.segmentIndex = String(segmentIndex); element.setAttribute('role', 'listitem'); @@ -131,7 +135,7 @@ export class ActivityBalanceHistory { const row = document.createElement('div'); row.className = 'activity-balance-history__row'; - row.dataset.index = String(index); + row.dataset.index = String(runNumber); row.append(labelWrapper, bar); fragment.append(row); diff --git a/web/ui/visuals/RadialUrchin.js b/web/ui/visuals/RadialUrchin.js index 509af53..bdad4bb 100644 --- a/web/ui/visuals/RadialUrchin.js +++ b/web/ui/visuals/RadialUrchin.js @@ -25,7 +25,123 @@ const SPEED_OPTIONS = [ const HOVER_DELAY = 180; -const MAX_HISTORY_ENTRIES = 100; +export const MAX_HISTORY_ENTRIES = 100; + +export function extractScheduleTimestamp(schedule) { + if (!schedule || typeof schedule !== 'object') { + return null; + } + if (typeof schedule.generatedAt === 'string') { + return schedule.generatedAt; + } + const meta = schedule.metadata && typeof schedule.metadata === 'object' ? schedule.metadata : null; + if (meta) { + if (typeof meta.generatedAt === 'string') { + return meta.generatedAt; + } + if (typeof meta.timestamp === 'string') { + return meta.timestamp; + } + } + return null; +} + +export function formatHistoryTimestamp(value) { + if (!value || typeof value !== 'string') { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + try { + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch (error) { + return ''; + } +} + +export function computeScheduleSignature(schedule) { + if (!schedule || typeof schedule !== 'object') { + return null; + } + + const timestamp = extractScheduleTimestamp(schedule); + if (timestamp) { + return `timestamp:${timestamp}`; + } + + const events = Array.isArray(schedule.events) ? schedule.events : []; + if (!events.length) { + return null; + } + + const parts = events.map((event) => { + const label = event?.label || event?.activity || ''; + const start = typeof event?.start === 'string' ? event.start : ''; + const end = typeof event?.end === 'string' ? event.end : ''; + const agent = + typeof event?.agent === 'string' + ? event.agent + : typeof event?.metadata?.agent === 'string' + ? event.metadata.agent + : ''; + return `${label}|${start}|${end}|${agent}`; + }); + + return `events:${parts.join(';')}`; +} + +export function createBalanceHistoryEntry(schedule, options = {}) { + if (!schedule || typeof schedule !== 'object') { + return null; + } + const events = Array.isArray(schedule.events) ? schedule.events : []; + if (events.length === 0) { + return null; + } + const totals = computeLabelTotals(events); + if (!Array.isArray(totals) || totals.length === 0) { + return null; + } + + const highContrast = Boolean(options.highContrast); + const activities = totals.map(({ label, minutes }) => ({ + id: label, + label, + minutes, + color: mapLabelToColor(label, { highContrast }), + })); + const { segments, totalMinutes } = prepareActivityShareSegments(activities); + if (!segments.length || !(totalMinutes > 0)) { + return null; + } + + const previousRunNumber = Number.isFinite(options.runNumber) ? options.runNumber : 1; + const runNumber = previousRunNumber; + const timestamp = extractScheduleTimestamp(schedule); + const entry = { + id: options.id || `${runNumber}-${Date.now()}`, + runNumber, + label: options.label || `Run #${runNumber}`, + timestamp, + timestampLabel: formatHistoryTimestamp(timestamp), + totalMinutes, + segments: segments.map((segment) => ({ ...segment })), + activities: activities.map((activity) => ({ ...activity })), + }; + + if (options.signature) { + entry.signature = options.signature; + } + + return entry; +} function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); @@ -152,9 +268,7 @@ export class RadialUrchin { this.historyView = null; this.balanceHistory = []; this.isHistoryOpen = false; - this.historyRunCounter = 0; this.maxHistoryEntries = MAX_HISTORY_ENTRIES; - this.lastSnapshotSource = null; this.handleResize = this.handleResize.bind(this); this.handlePointerMove = this.handlePointerMove.bind(this); @@ -452,88 +566,59 @@ export class RadialUrchin { this.updateHistoryUi(); } - captureBalanceSnapshot(schedule) { - if (!schedule || typeof schedule !== 'object') { - return; - } - const events = Array.isArray(schedule.events) ? schedule.events : []; - if (events.length === 0) { - return; - } - const totals = computeLabelTotals(events); - if (!Array.isArray(totals) || totals.length === 0) { - return; - } - - const activities = totals.map(({ label, minutes }) => ({ - id: label, - label, - minutes, - color: mapLabelToColor(label, { highContrast: this.state.highContrast }), + setBalanceHistory(entries) { + const next = Array.isArray(entries) ? entries.slice(-this.maxHistoryEntries) : []; + this.balanceHistory = next.map((entry) => ({ + ...entry, + segments: Array.isArray(entry.segments) + ? entry.segments.map((segment) => ({ ...segment })) + : [], + activities: Array.isArray(entry.activities) + ? entry.activities.map((activity) => ({ ...activity })) + : [], })); - const { segments, totalMinutes } = prepareActivityShareSegments(activities); - if (!segments.length || !(totalMinutes > 0)) { - return; - } - - this.historyRunCounter += 1; - const runNumber = this.historyRunCounter; - const timestamp = this.extractScheduleTimestamp(schedule); - const entry = { - id: `${runNumber}-${Date.now()}`, - runNumber, - label: `Run #${runNumber}`, - timestamp, - timestampLabel: this.formatHistoryTimestamp(timestamp), - totalMinutes, - segments: segments.map((segment) => ({ ...segment })), - }; - - this.balanceHistory.unshift(entry); - if (this.balanceHistory.length > this.maxHistoryEntries) { - this.balanceHistory.length = this.maxHistoryEntries; - } - this.updateHistoryUi({ refreshEntries: true }); } - extractScheduleTimestamp(schedule) { - if (!schedule || typeof schedule !== 'object') { - return null; - } - if (typeof schedule.generatedAt === 'string') { - return schedule.generatedAt; + appendBalanceHistoryEntry(entry) { + if (!entry || typeof entry !== 'object') { + return; } - const meta = schedule.metadata && typeof schedule.metadata === 'object' ? schedule.metadata : null; - if (meta) { - if (typeof meta.generatedAt === 'string') { - return meta.generatedAt; - } - if (typeof meta.timestamp === 'string') { - return meta.timestamp; - } + const normalized = { + ...entry, + segments: Array.isArray(entry.segments) + ? entry.segments.map((segment) => ({ ...segment })) + : [], + activities: Array.isArray(entry.activities) + ? entry.activities.map((activity) => ({ ...activity })) + : [], + }; + const next = [...this.balanceHistory, normalized]; + if (next.length > this.maxHistoryEntries) { + next.splice(0, next.length - this.maxHistoryEntries); } - return null; + this.balanceHistory = next; + this.updateHistoryUi({ refreshEntries: true }); } - formatHistoryTimestamp(value) { - if (!value || typeof value !== 'string') { - return ''; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - try { - return date.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch (error) { - return ''; + captureBalanceSnapshot(schedule, signature) { + const previous = + this.balanceHistory.length > 0 + ? this.balanceHistory[this.balanceHistory.length - 1] + : null; + const runNumber = + (previous && Number.isFinite(previous.runNumber) + ? previous.runNumber + : this.balanceHistory.length) + 1; + const entry = createBalanceHistoryEntry(schedule, { + runNumber, + highContrast: this.state.highContrast, + signature, + }); + if (!entry) { + return; } + this.appendBalanceHistoryEntry(entry); } getRunMetaSlot() { @@ -599,7 +684,6 @@ export class RadialUrchin { this.updateLegend(); this.refreshModeButtons(); this.render(); - this.lastSnapshotSource = null; if (this.isHistoryOpen) { this.isHistoryOpen = false; } @@ -607,13 +691,6 @@ export class RadialUrchin { return; } - if (payload && payload !== this.lastSnapshotSource) { - this.captureBalanceSnapshot(payload); - } - if (payload) { - this.lastSnapshotSource = payload; - } - try { this.layout = computeUrchinLayout(this.props.data, { mode: this.state.mode ?? this.props.mode,