From 33dedf73c69771648450edb73c1fb2e02447c4cf Mon Sep 17 00:00:00 2001 From: Lennart van der Molen Date: Fri, 14 Nov 2025 14:29:58 +0100 Subject: [PATCH] feat(web): add calendar generation loading state --- web/app.js | 151 ++++++++++++++++++++++++++++++++++++++++++-------- web/style.css | 77 +++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 24 deletions(-) diff --git a/web/app.js b/web/app.js index 8f61e29..db0ccbe 100644 --- a/web/app.js +++ b/web/app.js @@ -93,8 +93,15 @@ const visualsState = { fallbackMessage: null, urchin: null, useLegacy: false, + statusOverlay: null, + statusText: null, + metaBar: null, + runLabel: null, }; let lastVisualPayload = null; +let isGeneratingCalendar = false; +const GENERATE_BUTTON_DEFAULT_LABEL = 'Generate schedule'; +const GENERATE_BUTTON_LOADING_LABEL = 'Generating…'; const CALENDAR_HISTORY_LIMIT = 20; const calendarHistoryState = { @@ -133,6 +140,28 @@ function syncVisualsVisibility() { } } +function showVisualsOverlay(message, { loading = false } = {}) { + const overlay = visualsState.statusOverlay; + const text = visualsState.statusText; + if (!overlay || !text) { + return; + } + overlay.hidden = false; + overlay.classList.toggle('is-loading', Boolean(loading)); + text.textContent = message || ''; +} + +function hideVisualsOverlay() { + const overlay = visualsState.statusOverlay; + const text = visualsState.statusText; + if (!overlay || !text) { + return; + } + overlay.hidden = true; + overlay.classList.remove('is-loading'); + text.textContent = ''; +} + function resolveLegacyPng(payload) { if (!payload || typeof payload !== 'object') { return ''; @@ -230,6 +259,16 @@ function initVisualsMount() { layout.append(mainPanel); visualsState.mainPanel = mainPanel; + const metaBar = document.createElement('div'); + metaBar.className = 'visuals-meta'; + metaBar.hidden = true; + const runLabel = document.createElement('span'); + runLabel.className = 'visuals-meta__run'; + metaBar.append(runLabel); + mainPanel.append(metaBar); + visualsState.metaBar = metaBar; + visualsState.runLabel = runLabel; + const mount = document.createElement('div'); mount.className = 'visuals-mount'; mainPanel.append(mount); @@ -251,6 +290,18 @@ function initVisualsMount() { visualsState.fallbackImg = fallbackImg; visualsState.fallbackMessage = fallbackMessage; + const overlay = document.createElement('div'); + overlay.className = 'visuals-status-overlay'; + overlay.hidden = true; + const overlaySpinner = document.createElement('div'); + overlaySpinner.className = 'visuals-status-overlay__spinner'; + const overlayText = document.createElement('div'); + overlayText.className = 'visuals-status-overlay__text'; + overlay.append(overlaySpinner, overlayText); + mainPanel.append(overlay); + visualsState.statusOverlay = overlay; + visualsState.statusText = overlayText; + const historyPanel = createCalendarHistoryPanel(); if (historyPanel) { layout.append(historyPanel); @@ -393,6 +444,40 @@ function formatHistoryHours(value) { return rounded.toFixed(1); } +function updateActiveRunLabel() { + const metaBar = visualsState.metaBar; + const runLabel = visualsState.runLabel; + if (!metaBar || !runLabel) { + return; + } + + const { activeId, runHistory } = calendarHistoryState; + if (!activeId) { + runLabel.textContent = ''; + metaBar.hidden = true; + return; + } + + const index = runHistory.findIndex((entry) => entry.id === activeId); + if (index === -1) { + runLabel.textContent = ''; + metaBar.hidden = true; + return; + } + + const entry = runHistory[index]; + const runNumber = runHistory.length - index; + const parts = [`Run #${runNumber}`]; + if (entry.timestamp) { + const formatted = formatHistoryTimestamp(entry.timestamp); + if (formatted) { + parts.push(formatted); + } + } + runLabel.textContent = parts.join(' · '); + metaBar.hidden = false; +} + function renderCalendarRunHistory() { const list = calendarHistoryState.list; if (!list) { @@ -405,6 +490,7 @@ function renderCalendarRunHistory() { emptyItem.className = 'visuals-history-empty'; emptyItem.textContent = 'No runs yet. Generate a schedule to build history.'; list.append(emptyItem); + updateActiveRunLabel(); return; } @@ -468,6 +554,7 @@ function renderCalendarRunHistory() { }); list.append(fragment); + updateActiveRunLabel(); } function createCalendarHistoryPanel() { @@ -552,6 +639,7 @@ function restoreCalendarHistoryEntry(entryId) { const validation = validateWebV1Calendar(payload); setJsonValidationBadge(validation.ok ? 'ok' : 'err'); renderCalendarRunHistory(); + hideVisualsOverlay(); dispatchIntent({ type: INTENT_TYPES.NAVIGATE_TAB, payload: { tab: 'visuals' }, @@ -3554,9 +3642,23 @@ function hydrateConfigPanel() { if (generateButton) { styleRuntimeButton(generateButton); generateButton.disabled = false; + generateButton.textContent = GENERATE_BUTTON_DEFAULT_LABEL; updateRuntimeButtonState(generateButton); + const setGenerateButtonState = (loading) => { + isGeneratingCalendar = Boolean(loading); + generateButton.disabled = isGeneratingCalendar; + generateButton.textContent = isGeneratingCalendar + ? GENERATE_BUTTON_LOADING_LABEL + : GENERATE_BUTTON_DEFAULT_LABEL; + updateRuntimeButtonState(generateButton); + }; + const handleGenerate = async () => { + if (isGeneratingCalendar) { + return; + } + const snapshot = typeof getConfigSnapshot === 'function' ? getConfigSnapshot() : {}; const variantId = snapshot.variant || 'mk1'; const rigId = snapshot.rig || 'default'; @@ -3568,9 +3670,11 @@ function hydrateConfigPanel() { ? calendarConfig?.mk2?.workforce?.budgetText || '' : ''; - generateButton.disabled = true; - generateButton.textContent = 'Generating…'; - updateRuntimeButtonState(generateButton); + setGenerateButtonState(true); + updateVisuals(null); + showVisualsOverlay('Generating schedule…', { loading: true }); + calendarHistoryState.activeId = null; + renderCalendarRunHistory(); beginConsoleRun('Generating payload…'); @@ -3582,23 +3686,23 @@ function hydrateConfigPanel() { const seedNumber = Number.parseInt(seedValue, 10); const normalizedSeed = Number.isFinite(seedNumber) ? seedNumber : seedValue; - const workerArgs = { - class: 'calendar', - variant: variantId, - rig: rigId, - archetype, - week_start: weekStartValue, - seed: normalizedSeed, - }; - if (budgetText && budgetText.trim()) { - try { - workerArgs.yearly_budget = JSON.parse(budgetText); - } catch (parseError) { - throw { error: 'Invalid yearly budget JSON.', stdout: '', stderr: '' }; + try { + const workerArgs = { + class: 'calendar', + variant: variantId, + rig: rigId, + archetype, + week_start: weekStartValue, + seed: normalizedSeed, + }; + if (budgetText && budgetText.trim()) { + try { + workerArgs.yearly_budget = JSON.parse(budgetText); + } catch (parseError) { + throw { error: 'Invalid yearly budget JSON.', stdout: '', stderr: '' }; + } } - } - try { const { result = null, stdout = '', stderr = '', fallback = false } = await sendWorkerMessage('run', { fn: selectedFn || 'mock_run', @@ -3612,6 +3716,7 @@ function hydrateConfigPanel() { if (!result || typeof result !== 'object') { appendConsoleLog('error: No result returned from worker.'); + showVisualsOverlay('No result returned from worker.', { loading: false }); dispatchIntent({ type: INTENT_TYPES.SHOW_TOAST, payload: { @@ -3630,6 +3735,7 @@ function hydrateConfigPanel() { weekStart: result.week_start || weekStartValue, }); updateJsonActionsState(); + hideVisualsOverlay(); const eventsCount = Array.isArray(result.events) ? result.events.length : 0; const inputsSnapshot = { @@ -3693,6 +3799,7 @@ function hydrateConfigPanel() { ? error.message : 'Generation failed.'; console.error('Generation failed:', error); + showVisualsOverlay(description, { loading: false }); dispatchIntent({ type: INTENT_TYPES.SHOW_TOAST, payload: { @@ -3703,15 +3810,11 @@ function hydrateConfigPanel() { }, }); } finally { - generateButton.disabled = false; - generateButton.textContent = 'Generate'; - updateRuntimeButtonState(generateButton); + setGenerateButtonState(false); } }; - generateButton.addEventListener('click', () => { - handleGenerate(); - }); + generateButton.addEventListener('click', handleGenerate); } if (initializeRuntimeButton && runtimeReady) { diff --git a/web/style.css b/web/style.css index ceed46e..b2f8d11 100644 --- a/web/style.css +++ b/web/style.css @@ -482,6 +482,30 @@ body { display: flex; flex-direction: column; gap: 16px; + position: relative; +} + +.visuals-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.45); + color: #e2e8f0; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.01em; +} + +.visuals-meta[hidden] { + display: none; +} + +.visuals-meta__run { + font-weight: 600; } .visuals-history-panel { @@ -831,6 +855,59 @@ body { text-align: center; } +.visuals-status-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px; + border-radius: 16px; + background: rgba(15, 23, 42, 0.75); + color: #e2e8f0; + font-size: 16px; + font-weight: 600; + text-align: center; + pointer-events: none; + backdrop-filter: blur(2px); + z-index: 5; +} + +.visuals-status-overlay[hidden] { + display: none; +} + +.visuals-status-overlay__spinner { + width: 32px; + height: 32px; + border-radius: 50%; + border: 3px solid rgba(226, 232, 240, 0.3); + border-top-color: #38bdf8; + animation: visuals-spinner 0.85s linear infinite; + display: none; +} + +.visuals-status-overlay.is-loading .visuals-status-overlay__spinner { + display: block; +} + +.visuals-status-overlay__text { + max-width: 320px; + font-size: 15px; + line-height: 1.4; +} + +@keyframes visuals-spinner { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .radial-urchin-root { display: flex; flex-direction: column;