Fortschrittsanzeige: Popup mit Checkboxen, Blur, Pro-Lage-Timer

Ladebalken ersetzt durch zentriertes Popup-Fenster mit Checkbox-Checkliste
(Warteschlange, Recherche, Analyse, Faktencheck) und Echtzeit-Timer.

Erster Durchlauf: Popup nicht wegklickbar, Blur-Effekt auf Kacheln.
Aktualisierung: Popup minimierbar zu kompakter Status-Leiste.
Timer laeuft pro Lage im Hintergrund weiter bei Lagenwechsel.
Gesamtzeit wird am Ende im Abschluss-Popup angezeigt.

Sidebar: Animierter Gold-Rand und Fortschrittstext (Recherchiert/
Analysiert/Faktencheck) unter dem Lage-Namen bei laufendem Refresh.

Zusaetzlicher Cancel-Checkpoint im Orchestrator nach Uebersetzung.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-04-09 20:08:59 +02:00
Ursprung 3356ba1ae5
Commit 3f88d00b8c
5 geänderte Dateien mit 588 neuen und 311 gelöschten Zeilen

Datei anzeigen

@@ -699,10 +699,18 @@ const App = {
// Refresh-Status fuer diese Lage wiederherstellen
const isRefreshing = this._refreshingIncidents.has(id);
this._updateRefreshButton(isRefreshing);
// Hide any popup from previous incident
const prevOverlay = document.getElementById('progress-overlay');
if (prevOverlay) prevOverlay.style.display = 'none';
const prevMini = document.getElementById('progress-mini');
if (prevMini) prevMini.style.display = 'none';
const grid = document.querySelector('.grid-stack');
if (grid) grid.classList.remove('blurred');
if (isRefreshing) {
UI.showProgress('researching');
} else {
UI.hideProgress();
const state = UI._progressState[id];
const step = state ? state.step : 'researching';
const isFirst = state ? state.isFirst : false;
UI.showProgress(step, {}, id, isFirst);
}
// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden
@@ -1616,7 +1624,7 @@ const App = {
// Sofort ersten Refresh starten
this._refreshingIncidents.add(incident.id);
this._updateRefreshButton(true);
UI.showProgress('queued');
// showProgress called via handleStatusUpdate
await API.refreshIncident(incident.id);
UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
}
@@ -1676,12 +1684,14 @@ async handleRefresh() {
try {
this._refreshingIncidents.add(this.currentIncidentId);
this._updateRefreshButton(true);
UI.showProgress('queued');
// showProgress called via handleStatusUpdate
const result = await API.refreshIncident(this.currentIncidentId);
if (result && result.status === 'skipped') {
UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
} else {
UI.showToast('Aktualisierung gestartet.', 'success');
var _inc2 = this.incidents.find(function(i) { return i.id === this.currentIncidentId; }.bind(this));
UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.summary);
}
} catch (err) {
this._refreshingIncidents.delete(this.currentIncidentId);
@@ -2013,9 +2023,8 @@ async handleRefresh() {
handleStatusUpdate(msg) {
const status = msg.data.status;
if (status === 'retrying') {
// Retry-Status → Fehleranzeige mit Retry-Info
if (msg.incident_id === this.currentIncidentId) {
UI.showProgressError('', true, msg.data.delay || 120);
UI.showProgressError('', true, msg.data.delay || 120, msg.incident_id);
}
return;
}
@@ -2023,8 +2032,11 @@ async handleRefresh() {
this._refreshingIncidents.add(msg.incident_id);
}
this._updateSidebarDot(msg.incident_id);
// Detect first refresh: no summary means first run
const inc = this.incidents.find(i => i.id === msg.incident_id);
const isFirst = inc && !inc.summary;
UI.showProgress(status, msg.data, msg.incident_id, isFirst);
if (msg.incident_id === this.currentIncidentId) {
UI.showProgress(status, msg.data);
this._updateRefreshButton(status !== 'idle');
}
},
@@ -2037,14 +2049,13 @@ async handleRefresh() {
this._updateRefreshButton(false);
await this.loadIncidentDetail(msg.incident_id);
// Progress-Bar nicht sofort ausblenden — auf refresh_summary warten
// Progress-Popup nicht sofort ausblenden — auf refresh_summary warten
this._pendingComplete = msg.incident_id;
// Fallback: Wenn nach 5s kein refresh_summary kommt → direkt ausblenden
if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = setTimeout(() => {
if (this._pendingComplete === msg.incident_id) {
this._pendingComplete = null;
UI.hideProgress();
UI.hideProgress(msg.incident_id);
}
}, 5000);
}
@@ -2065,8 +2076,7 @@ async handleRefresh() {
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.showProgressComplete(d);
setTimeout(() => UI.hideProgress(), 4000);
UI.showProgressComplete(d, msg.incident_id);
}
// Toast-Text zusammenbauen
@@ -2145,7 +2155,7 @@ async handleRefresh() {
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.showProgressError(msg.data.error, false);
UI.showProgressError(msg.data.error, false, 0, msg.incident_id);
}
UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
},
@@ -2160,11 +2170,19 @@ async handleRefresh() {
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.hideProgress();
UI.hideProgress(msg.incident_id);
}
UI.showToast('Recherche abgebrochen.', 'info');
},
minimizeProgress() {
UI.minimizeProgress(this.currentIncidentId);
},
openProgressPopup() {
UI.openProgressPopup(this.currentIncidentId);
},
async cancelRefresh() {
if (!this.currentIncidentId) return;
const ok = await confirmDialog('Laufende Recherche abbrechen?');

Datei anzeigen

@@ -232,205 +232,363 @@ const UI = {
/**
* Fortschrittsanzeige einblenden und Status setzen.
*/
showProgress(status, extra = {}) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
bar.style.display = 'block';
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
// === Progress State (per-incident) ===
_progressState: {}, // { incidentId: { step, isFirst, startTime, minimized } }
_progressTimerInterval: null,
const steps = {
queued: { active: 0, label: 'In Warteschlange...' },
researching: { active: 1, label: 'Recherchiert Quellen...' },
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
analyzing: { active: 2, label: 'Analysiert Meldungen...' },
factchecking: { active: 3, label: 'Faktencheck läuft...' },
cancelling: { active: 0, label: 'Wird abgebrochen...' },
_getStepOrder() {
return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking'];
},
_getStepLabel(step) {
const map = {
queued: 'In Warteschlange',
researching: 'Recherchiert...',
deep_researching: 'Tiefenrecherche...',
analyzing: 'Analysiert...',
factchecking: 'Faktencheck...',
cancelling: 'Wird abgebrochen...',
};
return map[step] || step;
},
const step = steps[status] || steps.queued;
showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) {
if (!incidentId) incidentId = App.currentIncidentId;
if (!incidentId) return;
// Queue-Position anzeigen
let labelText = step.label;
if (status === 'queued' && extra.queue_position > 1) {
labelText = `In Warteschlange (Position ${extra.queue_position})...`;
} else if (extra.detail) {
labelText = extra.detail;
// Init state for this incident
if (!this._progressState[incidentId]) {
this._progressState[incidentId] = { step: 'queued', isFirst: isFirstRefresh, startTime: null, minimized: false };
}
const state = this._progressState[incidentId];
state.step = status;
if (isFirstRefresh) state.isFirst = true;
// Start timer on first non-queued status
if (status !== 'queued' && !state.startTime) {
if (extra.started_at) {
const serverStart = typeof parseUTC === 'function' ? parseUTC(extra.started_at) : new Date(extra.started_at);
state.startTime = serverStart ? serverStart.getTime() : Date.now();
} else {
state.startTime = Date.now();
}
}
// Multi-Pass: Durchlauf-Info anzeigen
const passEl = document.getElementById('progress-pass-info');
// Start global timer interval if not running
if (!this._progressTimerInterval) {
this._progressTimerInterval = setInterval(() => this._tickProgressTimers(), 1000);
}
// Only show UI for current incident
if (incidentId !== App.currentIncidentId) return;
// Update sidebar status text
this._updateSidebarRefreshStatus(incidentId, status, extra);
if (state.minimized) {
this._showMiniProgress(status, state);
return;
}
this._showPopupProgress(status, extra, state);
},
_showPopupProgress(status, extra, state) {
const overlay = document.getElementById('progress-overlay');
const popup = document.getElementById('progress-popup');
if (!overlay || !popup) return;
overlay.style.display = 'flex';
// Blocking (no close) for first refresh
if (state.isFirst) {
overlay.classList.add('blocking');
// Apply blur to grid
const grid = document.querySelector('.grid-stack');
if (grid) grid.classList.add('blurred');
} else {
overlay.classList.remove('blocking');
}
// Minimize button: only for updates (not first)
const minBtn = document.getElementById('progress-popup-minimize');
if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
// Title
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = state.isFirst ? 'Erste Recherche l\u00e4uft' : 'Aktualisierung l\u00e4uft';
// Multi-pass info
const passEl = document.getElementById('progress-popup-pass');
if (passEl) {
if (extra.research_pass && extra.research_total_passes) {
passEl.textContent = `Durchlauf ${extra.research_pass}/${extra.research_total_passes}`;
passEl.textContent = 'Durchlauf ' + extra.research_pass + '/' + extra.research_total_passes;
passEl.style.display = '';
} else {
passEl.style.display = 'none';
}
}
// Timer starten beim Übergang von queued zu aktivem Status
if (step.active > 0 && !this._progressStartTime) {
if (extra.started_at) {
// Echte Startzeit vom Server verwenden
const serverStart = parseUTC(extra.started_at);
this._progressStartTime = serverStart ? serverStart.getTime() : Date.now();
// Update checklist
const stepOrder = this._getStepOrder();
const currentIdx = stepOrder.indexOf(status === 'deep_researching' ? 'researching' : status);
const items = document.querySelectorAll('.progress-check-item');
// Map checklist items to step indices: queued=0, researching=1, analyzing=3, factchecking=4
const checkStepMap = { queued: 0, researching: 1, analyzing: 3, factchecking: 4 };
items.forEach(item => {
const step = item.dataset.step;
const stepIdx = checkStepMap[step] !== undefined ? checkStepMap[step] : -1;
const icon = item.querySelector('.progress-check-icon');
const detail = item.querySelector('.progress-check-detail');
item.classList.remove('active', 'done', 'error');
if (stepIdx < currentIdx || (step === 'queued' && currentIdx > 0)) {
item.classList.add('done');
if (icon) icon.innerHTML = '\u2713';
} else if (stepIdx === currentIdx || (step === 'researching' && (status === 'researching' || status === 'deep_researching'))) {
item.classList.add('active');
if (icon) icon.innerHTML = '<div class="spinner"></div>';
if (detail && extra.detail) detail.textContent = extra.detail;
else if (detail) detail.textContent = '';
} else {
this._progressStartTime = Date.now();
if (icon) icon.innerHTML = '\u25cb';
if (detail) detail.textContent = '';
}
this._startProgressTimer();
}
const stepIds = ['step-researching', 'step-analyzing', 'step-factchecking'];
stepIds.forEach((id, i) => {
const el = document.getElementById(id);
if (!el) return;
el.className = 'progress-step';
if (i + 1 < step.active) el.classList.add('done');
else if (i + 1 === step.active) el.classList.add('active');
});
const fill = document.getElementById('progress-fill');
const percent = step.active === 0 ? 5 : Math.round((step.active / 3) * 100);
if (fill) {
fill.style.width = percent + '%';
// Cancel button
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = '';
cancelBtn.textContent = 'Abbrechen';
cancelBtn.disabled = false;
}
// ARIA-Werte auf der Progressbar aktualisieren
bar.setAttribute('aria-valuenow', String(percent));
bar.setAttribute('aria-valuetext', labelText);
// Hide complete summary
const summaryEl = document.getElementById('progress-complete-summary');
if (summaryEl) summaryEl.style.display = 'none';
const label = document.getElementById('progress-label');
if (label) label.textContent = labelText;
// Cancel-Button sichtbar machen
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = '';
// Hide mini bar
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
},
/**
* Timer-Intervall starten (1x pro Sekunde).
*/
_startProgressTimer() {
if (this._progressTimer) return;
const timerEl = document.getElementById('progress-timer');
if (!timerEl) return;
_showMiniProgress(status, state) {
const mini = document.getElementById('progress-mini');
if (!mini) return;
mini.style.display = 'flex';
this._progressTimer = setInterval(() => {
if (!this._progressStartTime) return;
const elapsed = Math.max(0, Math.floor((Date.now() - this._progressStartTime) / 1000));
const textEl = document.getElementById('progress-mini-text');
if (textEl) textEl.textContent = this._getStepLabel(status);
// Hide popup
const overlay = document.getElementById('progress-overlay');
if (overlay) overlay.style.display = 'none';
},
minimizeProgress(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
if (!state) return;
state.minimized = true;
this._showMiniProgress(state.step, state);
},
openProgressPopup(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
if (!state) return;
state.minimized = false;
this._showPopupProgress(state.step, {}, state);
},
showProgressComplete(data, incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
const state = this._progressState[incidentId];
// Calculate total time
let totalTimeStr = '';
if (state && state.startTime) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
timerEl.textContent = `${mins}:${String(secs).padStart(2, '0')}`;
}, 1000);
totalTimeStr = mins + ':' + String(secs).padStart(2, '0');
}
if (incidentId === App.currentIncidentId) {
// Remove blur
const grid = document.querySelector('.grid-stack');
if (grid) grid.classList.remove('blurred');
const overlay = document.getElementById('progress-overlay');
if (overlay) {
overlay.style.display = 'flex';
overlay.classList.remove('blocking');
}
// Mark all steps done
document.querySelectorAll('.progress-check-item').forEach(item => {
item.classList.remove('active', 'error');
item.classList.add('done');
const icon = item.querySelector('.progress-check-icon');
if (icon) icon.innerHTML = '\u2713';
});
// Show summary
const parts = [];
if (data.new_articles > 0) parts.push(data.new_articles + ' neue Artikel');
if (data.confirmed_count > 0) parts.push(data.confirmed_count + ' Fakten best\u00e4tigt');
if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widerlegt');
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
const summaryEl = document.getElementById('progress-complete-summary');
if (summaryEl) {
summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText
+ (totalTimeStr ? '<span class="total-time">Gesamtzeit: ' + totalTimeStr + '</span>' : '');
summaryEl.style.display = 'block';
}
// Update title
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = 'Abgeschlossen';
// Hide cancel, show minimize
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
const minBtn = document.getElementById('progress-popup-minimize');
if (minBtn) minBtn.style.display = '';
// Hide mini bar
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
}
// Remove sidebar refresh status
this._removeSidebarRefreshStatus(incidentId);
// Clean up state after delay
setTimeout(() => {
this.hideProgress(incidentId);
}, 5000);
},
/**
* Abschluss-Animation: Grüner Balken mit Summary-Text.
*/
showProgressComplete(data) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) {
if (!incidentId) incidentId = App.currentIncidentId;
if (incidentId !== App.currentIncidentId) return;
// Timer stoppen
this._stopProgressTimer();
const overlay = document.getElementById('progress-overlay');
if (overlay) overlay.style.display = 'flex';
// Alle Steps auf done
['step-researching', 'step-analyzing', 'step-factchecking'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.className = 'progress-step done'; }
});
// Fill auf 100%
const fill = document.getElementById('progress-fill');
if (fill) fill.style.width = '100%';
// Complete-Klasse
bar.classList.remove('progress-bar--error');
bar.classList.add('progress-bar--complete');
// Label mit Summary
const parts = [];
if (data.new_articles > 0) {
parts.push(`${data.new_articles} neue Artikel`);
}
if (data.confirmed_count > 0) {
parts.push(`${data.confirmed_count} Fakten bestätigt`);
}
if (data.contradicted_count > 0) {
parts.push(`${data.contradicted_count} widerlegt`);
}
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
const label = document.getElementById('progress-label');
if (label) label.textContent = `Abgeschlossen: ${summaryText}`;
// Cancel-Button und Pass-Info ausblenden
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
const passElDone = document.getElementById('progress-pass-info');
if (passElDone) passElDone.style.display = 'none';
bar.setAttribute('aria-valuenow', '100');
bar.setAttribute('aria-valuetext', 'Abgeschlossen');
},
/**
* Fehler-Zustand: Roter Balken mit Fehlermeldung.
*/
showProgressError(errorMsg, willRetry = false, delay = 0) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
bar.style.display = 'block';
// Timer stoppen
this._stopProgressTimer();
// Error-Klasse
bar.classList.remove('progress-bar--complete');
bar.classList.add('progress-bar--error');
const label = document.getElementById('progress-label');
if (label) {
label.textContent = willRetry
? `Fehlgeschlagen \u2014 erneuter Versuch in ${delay}s...`
: `Fehlgeschlagen: ${errorMsg}`;
// Mark current step as error
const state = this._progressState[incidentId];
if (state) {
const items = document.querySelectorAll('.progress-check-item.active');
items.forEach(item => {
item.classList.remove('active');
item.classList.add('error');
const icon = item.querySelector('.progress-check-icon');
if (icon) icon.innerHTML = '\u2717';
});
}
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) {
titleEl.textContent = willRetry
? 'Fehlgeschlagen \u2014 erneuter Versuch in ' + delay + 's...'
: 'Fehlgeschlagen: ' + errorMsg;
}
// Cancel-Button ausblenden
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
// Bei finalem Fehler nach 6s ausblenden
if (!willRetry) {
setTimeout(() => this.hideProgress(), 6000);
this._removeSidebarRefreshStatus(incidentId);
setTimeout(() => this.hideProgress(incidentId), 6000);
}
},
/**
* Timer-Intervall stoppen und zurücksetzen.
*/
_stopProgressTimer() {
if (this._progressTimer) {
clearInterval(this._progressTimer);
this._progressTimer = null;
hideProgress(incidentId) {
if (!incidentId) incidentId = App.currentIncidentId;
// Remove blur
const grid = document.querySelector('.grid-stack');
if (grid) grid.classList.remove('blurred');
if (incidentId === App.currentIncidentId) {
const overlay = document.getElementById('progress-overlay');
if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
const mini = document.getElementById('progress-mini');
if (mini) mini.style.display = 'none';
}
// Remove sidebar status
this._removeSidebarRefreshStatus(incidentId);
// Clean up state
delete this._progressState[incidentId];
// Stop timer if no more active refreshes
if (Object.keys(this._progressState).length === 0 && this._progressTimerInterval) {
clearInterval(this._progressTimerInterval);
this._progressTimerInterval = null;
}
this._progressStartTime = null;
const timerEl = document.getElementById('progress-timer');
if (timerEl) timerEl.textContent = '';
},
/**
* Fortschrittsanzeige ausblenden.
*/
hideProgress() {
const bar = document.getElementById('progress-bar');
if (bar) {
bar.style.display = 'none';
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
_tickProgressTimers() {
for (const [id, state] of Object.entries(this._progressState)) {
if (!state.startTime) continue;
const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000));
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const timeStr = mins + ':' + String(secs).padStart(2, '0');
if (parseInt(id) === App.currentIncidentId) {
// Update popup timer
const timerEl = document.getElementById('progress-popup-timer');
if (timerEl) timerEl.textContent = timeStr;
// Update mini timer
const miniTimer = document.getElementById('progress-mini-timer');
if (miniTimer) miniTimer.textContent = timeStr;
}
// Update sidebar timer for this incident
const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id);
if (sidebarTimer) sidebarTimer.textContent = timeStr;
}
this._stopProgressTimer();
},
// === Sidebar Refresh Status ===
_updateSidebarRefreshStatus(incidentId, status, extra) {
const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
if (!item) return;
// Add refreshing class for animated border
item.classList.add('refreshing-item');
// Add or update status text below meta
let statusEl = document.getElementById('sidebar-refresh-' + incidentId);
if (!statusEl) {
const textCol = item.querySelector('div[style*="flex:1"]');
if (!textCol) return;
statusEl = document.createElement('div');
statusEl.id = 'sidebar-refresh-' + incidentId;
statusEl.className = 'incident-refresh-status';
textCol.appendChild(statusEl);
}
const label = this._getStepLabel(status);
statusEl.innerHTML = '<span class="mini-spinner"></span><span>' + label + '</span><span id="sidebar-refresh-timer-' + incidentId + '" style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:10px;color:var(--text-disabled);"></span>';
},
_removeSidebarRefreshStatus(incidentId) {
const statusEl = document.getElementById('sidebar-refresh-' + incidentId);
if (statusEl) statusEl.remove();
const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
if (item) item.classList.remove('refreshing-item');
},
/**
* Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
*/