From 257d7a46ee0f464486edac9d1ad49dea3c1c9537 Mon Sep 17 00:00:00 2001 From: Lennart van der Molen Date: Fri, 14 Nov 2025 14:19:27 +0100 Subject: [PATCH] Add calendar run history panel on visuals tab --- web/app.js | 316 +++++++++++++++++++++++++++++++++++++++++++++++++- web/style.css | 112 ++++++++++++++++++ 2 files changed, 426 insertions(+), 2 deletions(-) diff --git a/web/app.js b/web/app.js index 2ee73e6..8f61e29 100644 --- a/web/app.js +++ b/web/app.js @@ -85,6 +85,8 @@ let currentJsonMetadata = { variant: '', rig: '', week: '', events: null }; const VISUALS_LEGACY_FLAG = 'wyrd.visuals.legacy'; const visualsState = { container: null, + layout: null, + mainPanel: null, mount: null, fallback: null, fallbackImg: null, @@ -94,6 +96,14 @@ const visualsState = { }; let lastVisualPayload = null; +const CALENDAR_HISTORY_LIMIT = 20; +const calendarHistoryState = { + runHistory: [], + panel: null, + list: null, + activeId: null, +}; + function readVisualsLegacyFlag() { try { return localStorage.getItem(VISUALS_LEGACY_FLAG) === '1'; @@ -210,9 +220,19 @@ function initVisualsMount() { visualsState.container = container; visualsState.useLegacy = readVisualsLegacyFlag(); + const layout = document.createElement('div'); + layout.className = 'visuals-layout'; + container.append(layout); + visualsState.layout = layout; + + const mainPanel = document.createElement('div'); + mainPanel.className = 'visuals-main'; + layout.append(mainPanel); + visualsState.mainPanel = mainPanel; + const mount = document.createElement('div'); mount.className = 'visuals-mount'; - container.append(mount); + mainPanel.append(mount); visualsState.mount = mount; const fallback = document.createElement('div'); @@ -226,11 +246,16 @@ function initVisualsMount() { fallbackMessage.textContent = 'Legacy visuals preview unavailable.'; fallbackMessage.hidden = true; fallback.append(fallbackImg, fallbackMessage); - container.append(fallback); + mainPanel.append(fallback); visualsState.fallback = fallback; visualsState.fallbackImg = fallbackImg; visualsState.fallbackMessage = fallbackMessage; + const historyPanel = createCalendarHistoryPanel(); + if (historyPanel) { + layout.append(historyPanel); + } + if (typeof window !== 'undefined') { window.WYRD_SET_VISUALS_LEGACY = (enabled) => { const flag = Boolean(enabled); @@ -260,6 +285,279 @@ function safeInitVisuals(initialData) { } } +function generateCalendarHistoryId() { + try { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + } catch (error) { + // ignore + } + const random = Math.random().toString(16).slice(2); + return `calendar-${Date.now()}-${random}`; +} + +function parseHistoryTime(value) { + if (typeof value !== 'string') { + return null; + } + const [hoursPart, minutesPart] = value.split(':'); + const hours = Number.parseInt(hoursPart, 10); + const minutes = Number.parseInt(minutesPart, 10); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) { + return null; + } + const total = hours * 60 + minutes; + return Number.isFinite(total) ? ((total % (24 * 60)) + 24 * 60) % (24 * 60) : null; +} + +function computeEventDurationMinutes(event) { + if (!event || typeof event !== 'object') { + return 0; + } + const start = parseHistoryTime(event.start); + const end = parseHistoryTime(event.end); + if (start === null || end === null) { + return 0; + } + if (end >= start) { + return end - start; + } + return 24 * 60 - start + end; +} + +function computeCalendarHistorySummary(events) { + if (!Array.isArray(events) || events.length === 0) { + return { totalEvents: Array.isArray(events) ? events.length : 0 }; + } + + let sleepMinutes = 0; + let workMinutes = 0; + + events.forEach((event) => { + const duration = computeEventDurationMinutes(event); + if (duration <= 0) { + return; + } + const activity = (event?.activity || event?.label || '').toString().toLowerCase(); + if (activity.includes('sleep')) { + sleepMinutes += duration; + } + if (activity.includes('work')) { + workMinutes += duration; + } + }); + + const summary = { totalEvents: events.length }; + if (sleepMinutes > 0) { + summary.totalSleepHours = Math.round((sleepMinutes / 60) * 10) / 10; + } + if (workMinutes > 0) { + summary.totalWorkHours = Math.round((workMinutes / 60) * 10) / 10; + } + return summary; +} + +function cloneCalendarHistoryPayload(payload) { + try { + return JSON.parse(JSON.stringify(payload ?? {})); + } catch (error) { + console.warn('Unable to clone calendar history payload:', error); + return null; + } +} + +function formatHistoryTimestamp(value) { + try { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error('Invalid date'); + } + const pad = (num) => String(num).padStart(2, '0'); + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + return `${year}-${month}-${day} ${hours}:${minutes}`; + } catch (error) { + return 'Unknown time'; + } +} + +function formatHistoryHours(value) { + if (!Number.isFinite(value)) { + return null; + } + const rounded = Math.round(value * 10) / 10; + return rounded.toFixed(1); +} + +function renderCalendarRunHistory() { + const list = calendarHistoryState.list; + if (!list) { + return; + } + + list.innerHTML = ''; + if (calendarHistoryState.runHistory.length === 0) { + const emptyItem = document.createElement('li'); + emptyItem.className = 'visuals-history-empty'; + emptyItem.textContent = 'No runs yet. Generate a schedule to build history.'; + list.append(emptyItem); + return; + } + + const fragment = document.createDocumentFragment(); + calendarHistoryState.runHistory.forEach((entry) => { + const item = document.createElement('li'); + item.className = 'visuals-history-item'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'visuals-history-entry'; + if (entry.id === calendarHistoryState.activeId) { + button.classList.add('is-active'); + } + + const headline = document.createElement('span'); + headline.className = 'visuals-history-entry__headline'; + const parts = [`[${formatHistoryTimestamp(entry.timestamp)}]`]; + if (entry.archetype) { + parts.push(`archetype=${entry.archetype}`); + } + if (entry.seed !== undefined && entry.seed !== null && entry.seed !== '') { + parts.push(`seed=${entry.seed}`); + } + const variantLabel = [entry.variant, entry.rig].filter(Boolean).join('/'); + if (variantLabel) { + parts.push(variantLabel); + } + headline.textContent = parts.join(' '); + + const meta = document.createElement('span'); + meta.className = 'visuals-history-entry__meta'; + const metaParts = []; + if (entry.weekStart) { + metaParts.push(`week=${entry.weekStart}`); + } + if (entry.summary && Number.isFinite(entry.summary.totalEvents)) { + metaParts.push(`events=${entry.summary.totalEvents}`); + } + if (entry.summary && Number.isFinite(entry.summary.totalSleepHours)) { + const value = formatHistoryHours(entry.summary.totalSleepHours); + if (value) { + metaParts.push(`sleep≈${value}h`); + } + } + if (entry.summary && Number.isFinite(entry.summary.totalWorkHours)) { + const value = formatHistoryHours(entry.summary.totalWorkHours); + if (value) { + metaParts.push(`work≈${value}h`); + } + } + meta.textContent = metaParts.join(' • ') || 'No summary available'; + + button.append(headline, meta); + button.addEventListener('click', () => { + restoreCalendarHistoryEntry(entry.id); + }); + + item.append(button); + fragment.append(item); + }); + + list.append(fragment); +} + +function createCalendarHistoryPanel() { + if (calendarHistoryState.panel) { + return calendarHistoryState.panel; + } + const panel = document.createElement('aside'); + panel.className = 'visuals-history-panel'; + + const header = document.createElement('div'); + header.className = 'visuals-history-header'; + + const title = document.createElement('h3'); + title.className = 'visuals-history-title'; + title.textContent = 'History'; + + header.append(title); + panel.append(header); + + const list = document.createElement('ul'); + list.className = 'visuals-history-list'; + panel.append(list); + + calendarHistoryState.panel = panel; + calendarHistoryState.list = list; + + renderCalendarRunHistory(); + + return panel; +} + +function recordCalendarHistoryEntry(entry) { + if (!entry || !entry.rawResult) { + return; + } + const normalized = { + id: entry.id || generateCalendarHistoryId(), + timestamp: entry.timestamp || new Date().toISOString(), + archetype: entry.archetype || '', + seed: + Number.isFinite(entry.seed) + ? entry.seed + : Number.isFinite(Number.parseInt(entry.seed, 10)) + ? Number.parseInt(entry.seed, 10) + : undefined, + variant: entry.variant || '', + rig: entry.rig || '', + weekStart: entry.weekStart || '', + summary: entry.summary ? { ...entry.summary } : null, + rawResult: cloneCalendarHistoryPayload(entry.rawResult) || entry.rawResult, + }; + + calendarHistoryState.runHistory = [normalized, ...calendarHistoryState.runHistory].slice( + 0, + CALENDAR_HISTORY_LIMIT, + ); + calendarHistoryState.activeId = normalized.id; + renderCalendarRunHistory(); +} + +function restoreCalendarHistoryEntry(entryId) { + if (!entryId) { + return; + } + const entry = calendarHistoryState.runHistory.find((item) => item.id === entryId); + if (!entry) { + return; + } + const payload = cloneCalendarHistoryPayload(entry.rawResult) || entry.rawResult; + if (!payload || typeof payload !== 'object') { + return; + } + + calendarHistoryState.activeId = entry.id; + + setJsonPayload(payload, { + variant: entry.variant, + rig: entry.rig, + weekStart: entry.weekStart, + }); + updateJsonActionsState(); + const validation = validateWebV1Calendar(payload); + setJsonValidationBadge(validation.ok ? 'ok' : 'err'); + renderCalendarRunHistory(); + dispatchIntent({ + type: INTENT_TYPES.NAVIGATE_TAB, + payload: { tab: 'visuals' }, + }); +} + let getConfigSnapshot = () => ({ classId: 'calendar', variant: '', @@ -3343,6 +3641,20 @@ function hydrateConfigPanel() { inputsSnapshot.budget = true; } + recordCalendarHistoryEntry({ + archetype, + seed: normalizedSeed, + variant: variantId, + rig: rigId, + weekStart: + typeof result.week_start === 'string' && result.week_start + ? result.week_start + : weekStartValue, + rawResult: result, + summary: computeCalendarHistorySummary(result.events), + timestamp: new Date().toISOString(), + }); + addRunHistoryEntry({ kind: 'generate', ts: Date.now(), diff --git a/web/style.css b/web/style.css index 28d06ac..ceed46e 100644 --- a/web/style.css +++ b/web/style.css @@ -469,6 +469,117 @@ body { overflow: hidden; } +.visuals-layout { + flex: 1; + display: flex; + gap: 20px; + min-height: 0; +} + +.visuals-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.visuals-history-panel { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(6px); +} + +.visuals-history-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.visuals-history-title { + margin: 0; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #e2e8f0; +} + +.visuals-history-list { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; +} + +.visuals-history-empty { + margin: 0; + padding: 12px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.35); + color: rgba(203, 213, 225, 0.85); + font-size: 13px; + text-align: center; +} + +.visuals-history-item { + list-style: none; +} + +.visuals-history-entry { + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 12px; + background: rgba(30, 41, 59, 0.55); + color: inherit; + font: inherit; + padding: 10px 12px; + cursor: pointer; + text-align: left; + transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease; +} + +.visuals-history-entry:hover { + border-color: rgba(94, 234, 212, 0.55); + background: rgba(30, 64, 175, 0.4); +} + +.visuals-history-entry:focus-visible { + outline: 2px solid #22d3ee; + outline-offset: 2px; +} + +.visuals-history-entry.is-active { + border-color: rgba(56, 189, 248, 0.85); + background: rgba(14, 116, 144, 0.45); +} + +.visuals-history-entry__headline { + font-size: 12px; + font-weight: 600; + color: #f1f5f9; +} + +.visuals-history-entry__meta { + font-size: 12px; + color: rgba(203, 213, 225, 0.9); +} + .console-pane { display: grid; gap: 12px; @@ -688,6 +799,7 @@ body { .visuals-mount { position: relative; z-index: 0; + flex: 1; min-height: 420px; }