Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
15003c0
feat: add centralized error boundary pattern for dashboard components
Copilot Mar 1, 2026
034eef5
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
19061d9
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
ff8cb7b
fix: address review feedback on error-boundary and fallback-ui
Copilot Mar 1, 2026
c5304aa
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
8d0464f
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
1f7fcd4
fix: address second review round — CSS, DOM snapshot, correct contain…
Copilot Mar 1, 2026
5f3fa88
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
19f8729
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
82f162d
fix: loading overlay as child element; localized retry labels for all…
Copilot Mar 1, 2026
0a15feb
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
d1f4a9d
fix: add options param to renderWithFallback; safe retry callbacks wi…
Copilot Mar 1, 2026
7d1d86e
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
813531d
fix: skip innerHTML restore on first attempt; use Promise.resolve() i…
Copilot Mar 1, 2026
7379115
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
ac85071
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
2c9a9be
fix: overlay positioning + reuse shimmer keyframes for skeleton-bar
Copilot Mar 1, 2026
9da1885
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
f39f230
Fix container.style.position restoration in finally block; rename mis…
Copilot Mar 1, 2026
806aafb
Merge branch 'main' into copilot/add-centralized-error-boundary
pethers Mar 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions src/browser/dashboards/party-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
getResponsiveOptions,
addChartKeyboardNav,
showDataSourceDisclaimer,
renderErrorFallback,
} from '../shared/index.js';

import { detectLanguage } from '../shared/index.js';
Expand Down Expand Up @@ -99,6 +100,7 @@ interface LocaleTranslations {
readonly momentumSrOnly?: string;
readonly loadingMessage: string;
readonly errorMessage: string;
readonly retryMessage: string;
readonly dataAttribution: string;
readonly lastUpdated: string;
readonly parties: PartyNames;
Expand Down Expand Up @@ -231,6 +233,7 @@ const TRANSLATIONS: TranslationMap = {
'Doughnut chart displaying momentum scores for Social Democrats, Moderates, Sweden Democrats, Centre Party, Left Party, Christian Democrats, Liberals, and Green Party.',
loadingMessage: 'Loading CIA data from GitHub repository...',
errorMessage: 'Error loading data. Please try again later.',
retryMessage: 'Retry',
dataAttribution: 'Data by CIA Platform',
lastUpdated: 'Last Updated',
parties: {
Expand Down Expand Up @@ -277,6 +280,7 @@ const TRANSLATIONS: TranslationMap = {
'Ringdiagram som visar momentumpoäng för Socialdemokraterna, Moderaterna, Sverigedemokraterna, Centerpartiet, Vänsterpartiet, Kristdemokraterna, Liberalerna och Miljöpartiet.',
loadingMessage: 'Laddar CIA-data från GitHub-repository...',
errorMessage: 'Fel vid laddning av data. Försök igen senare.',
retryMessage: 'Försök igen',
dataAttribution: 'Data från CIA-plattformen',
lastUpdated: 'Senast Uppdaterad',
parties: {
Expand Down Expand Up @@ -307,6 +311,7 @@ const TRANSLATIONS: TranslationMap = {
'Partimomentumscorer med percentilbenchmarks (P50, P90), der angiver valgbane.',
loadingMessage: 'Indlæser CIA-data fra GitHub-repository...',
errorMessage: 'Fejl ved indlæsning af data. Prøv igen senere.',
retryMessage: 'Prøv igen',
dataAttribution: 'Data fra CIA-platformen',
lastUpdated: 'Senest Opdateret',
parties: {
Expand Down Expand Up @@ -337,6 +342,7 @@ const TRANSLATIONS: TranslationMap = {
'Partimomentumpoeng med persentilreferanser (P50, P90) som indikerer valgbane.',
loadingMessage: 'Laster inn CIA-data fra GitHub-repository...',
errorMessage: 'Feil ved lasting av data. Prøv igjen senere.',
retryMessage: 'Prøv igjen',
dataAttribution: 'Data fra CIA-plattformen',
lastUpdated: 'Sist Oppdatert',
parties: {
Expand Down Expand Up @@ -367,6 +373,7 @@ const TRANSLATIONS: TranslationMap = {
'Puolueen vauhtipisteet prosenttipisteillä (P50, P90), jotka osoittavat vaalikaaren.',
loadingMessage: 'Ladataan CIA-tietoja GitHub-repositoriosta...',
errorMessage: 'Virhe tietojen lataamisessa. Yritä myöhemmin uudelleen.',
retryMessage: 'Yritä uudelleen',
dataAttribution: 'Tiedot CIA-alustalta',
lastUpdated: 'Viimeksi Päivitetty',
parties: {
Expand Down Expand Up @@ -398,6 +405,7 @@ const TRANSLATIONS: TranslationMap = {
'Parteien-Momentum-Werte mit Perzentil-Benchmarks (P50, P90), die den Wahlverlauf anzeigen.',
loadingMessage: 'Lade CIA-Daten aus GitHub-Repository...',
errorMessage: 'Fehler beim Laden der Daten. Bitte versuchen Sie es später erneut.',
retryMessage: 'Wiederholen',
dataAttribution: 'Daten von der CIA-Plattform',
lastUpdated: 'Zuletzt Aktualisiert',
parties: {
Expand Down Expand Up @@ -429,6 +437,7 @@ const TRANSLATIONS: TranslationMap = {
'Scores de momentum des partis avec des repères de percentile (P50, P90) indiquant la trajectoire électorale.',
loadingMessage: 'Chargement des données CIA depuis le dépôt GitHub...',
errorMessage: 'Erreur lors du chargement des données. Veuillez réessayer plus tard.',
retryMessage: 'Réessayer',
dataAttribution: 'Données de la plateforme CIA',
lastUpdated: 'Dernière Mise à Jour',
parties: {
Expand Down Expand Up @@ -460,6 +469,7 @@ const TRANSLATIONS: TranslationMap = {
loadingMessage: 'Cargando datos de CIA desde el repositorio de GitHub...',
errorMessage:
'Error al cargar los datos. Por favor, inténtelo de nuevo más tarde.',
retryMessage: 'Reintentar',
dataAttribution: 'Datos de la plataforma CIA',
lastUpdated: 'Última Actualización',
parties: {
Expand Down Expand Up @@ -491,6 +501,7 @@ const TRANSLATIONS: TranslationMap = {
'Partijmomentumscores met percentiel-benchmarks (P50, P90) die het verkiezingstraject aangeven.',
loadingMessage: 'CIA-gegevens laden vanuit GitHub-repository...',
errorMessage: 'Fout bij het laden van gegevens. Probeer het later opnieuw.',
retryMessage: 'Opnieuw proberen',
dataAttribution: 'Gegevens van het CIA-platform',
lastUpdated: 'Laatst Bijgewerkt',
parties: {
Expand Down Expand Up @@ -521,6 +532,7 @@ const TRANSLATIONS: TranslationMap = {
'درجات زخم الأحزاب مع معايير النسبة المئوية (P50، P90) التي تشير إلى المسار الانتخابي.',
loadingMessage: 'جارٍ تحميل بيانات CIA من مستودع GitHub...',
errorMessage: 'خطأ في تحميل البيانات. يرجى المحاولة مرة أخرى لاحقًا.',
retryMessage: 'حاول مجددًا',
dataAttribution: 'البيانات من منصة CIA',
lastUpdated: 'آخر تحديث',
parties: {
Expand Down Expand Up @@ -551,6 +563,7 @@ const TRANSLATIONS: TranslationMap = {
'ציוני מומנטום של מפלגות עם אמות מידה אחוזיות (P50, P90) המצביעים על מסלול בחירות.',
loadingMessage: 'טוען נתוני CIA ממאגר GitHub...',
errorMessage: 'שגיאה בטעינת נתונים. נסה שוב מאוחר יותר.',
retryMessage: 'נסה שוב',
dataAttribution: 'נתונים מפלטפורמת CIA',
lastUpdated: 'עודכן לאחרונה',
parties: {
Expand Down Expand Up @@ -581,6 +594,7 @@ const TRANSLATIONS: TranslationMap = {
'パーセンタイルベンチマーク(P50、P90)を使用した政党の勢いスコアで、選挙の軌跡を示します。',
loadingMessage: 'GitHubリポジトリからCIAデータを読み込んでいます...',
errorMessage: 'データの読み込みエラー。後でもう一度お試しください。',
retryMessage: '再試行',
dataAttribution: 'CIAプラットフォームからのデータ',
lastUpdated: '最終更新',
parties: {
Expand Down Expand Up @@ -611,6 +625,7 @@ const TRANSLATIONS: TranslationMap = {
'백분위수 벤치마크(P50, P90)로 선거 궤적을 나타내는 정당 모멘텀 점수.',
loadingMessage: 'GitHub 저장소에서 CIA 데이터를 로드하는 중...',
errorMessage: '데이터 로드 오류. 나중에 다시 시도하십시오.',
retryMessage: '다시 시도',
dataAttribution: 'CIA 플랫폼의 데이터',
lastUpdated: '마지막 업데이트',
parties: {
Expand Down Expand Up @@ -640,6 +655,7 @@ const TRANSLATIONS: TranslationMap = {
'具有百分位基准(P50,P90)的政党动量分数,指示选举轨迹。',
loadingMessage: '正在从GitHub存储库加载CIA数据...',
errorMessage: '加载数据时出错。请稍后再试。',
retryMessage: '重试',
dataAttribution: '来自CIA平台的数据',
lastUpdated: '最后更新',
parties: {
Expand Down Expand Up @@ -1227,13 +1243,15 @@ export async function init(): Promise<void> {
logger.debug('✅ Party dashboard initialized successfully');
} catch (error) {
logger.error('Error initializing party dashboard:', error);

// Show error message
const errorDiv = document.createElement('div');
errorDiv.className = 'dashboard-error';
errorDiv.style.cssText =
'padding: 2rem; text-align: center; color: var(--danger-color);';
errorDiv.textContent = t.errorMessage;
dashboardSection.appendChild(errorDiv);
// Append a dedicated error container so the rest of the section's DOM
// (including <canvas> elements) is preserved for a subsequent retry.
const errContainer = document.createElement('div');
dashboardSection.appendChild(errContainer);
renderErrorFallback(errContainer, t.errorMessage, () => {
errContainer.remove();
init().catch((err) =>
logger.error('Retry failed during party dashboard re-initialization:', err),
);
}, t.retryMessage);
}
Comment on lines 1244 to 1256
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderErrorFallback(dashboardSection, ...) clears and replaces the entire #party-dashboard section. On retry, init() expects existing chart DOM (e.g., #partyEffectivenessChart, #partyComparisonChart, etc.), but those canvases were removed by the fallback, so the dashboard cannot recover without a page reload. Render the error UI into a dedicated placeholder inside the section (or preserve/restore the original section markup before retry) so a retry can actually re-render charts.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1f7fcd4. The catch block now creates a div (errContainer), appends it to dashboardSection, and calls renderErrorFallback(errContainer, …). This leaves the canvas elements untouched. The retry callback removes errContainer before re-calling init(), so all expected chart targets are present for the retry render.

}
30 changes: 18 additions & 12 deletions src/browser/dashboards/risk-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
addChartKeyboardNav,
initDashboardSection,
showDataSourceDisclaimer,
renderErrorFallback,
} from '../shared/index.js';

import { loadCSV, createDataSource } from '../shared/index.js';
Expand Down Expand Up @@ -965,18 +966,23 @@ export async function init(): Promise<void> {
} catch (error) {
logger.error('❌ Failed to load CIA risk data:', error);

// Display error message to user
const alertContainer = document.getElementById('earlyWarningAlerts');
if (alertContainer) {
alertContainer.innerHTML = `
<div class="alert alert-danger" role="alert">
<h4>⚠️ Data Loading Error</h4>
<p>Unable to load risk assessment data from CIA Platform.</p>
<p><strong>Error:</strong> ${(error as Error).message}</p>
<p>Please check your internet connection and try refreshing the page.</p>
<p><small>Data source: view_politician_risk_summary_sample.csv (403 politicians)</small></p>
</div>
`;
// Display error message to user using the shared error boundary fallback.
// Render into a child container prepended to #risk-dashboard so the rest
// of the section's DOM is preserved for a subsequent retry.
const dashboardSection = document.getElementById('risk-dashboard');
if (dashboardSection) {
const errContainer = document.createElement('div');
dashboardSection.prepend(errContainer);
renderErrorFallback(
errContainer,
'Unable to load risk assessment data from CIA Platform.',
() => {
errContainer.remove();
init().catch((err) =>
logger.error('Retry failed during risk dashboard re-initialization:', err),
);
},
);
}

// Cannot proceed without data - exit gracefully
Expand Down
126 changes: 126 additions & 0 deletions src/browser/shared/error-boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @module Shared/ErrorBoundary
* @description Centralized error boundary pattern for browser-side dashboard components.
* Prevents individual component failures from breaking the entire page by wrapping
* render functions with error catching, fallback UI, and optional retry logic.
*
* @intelligence Intelligence platform resilience layer — each dashboard panel is isolated
* so a single data-source failure never cascades to the rest of the page. Retry logic
* maximises successful data acquisition from unstable government APIs.
*
* @business Platform reliability — isolated component failures improve perceived
* reliability and reduce support incidents. Automatic retry reduces manual page refreshes
* and keeps users engaged with available data.
*
* @marketing Enterprise readiness signal — graceful degradation and structured error
* handling demonstrate production quality to government and enterprise prospects.
*/

import { logger } from './logger.js';
import { renderErrorFallback, renderLoadingFallback } from './fallback-ui.js';

/**
* Localised labels for the loading and error states produced by
* {@link renderWithFallback}. Both fields are optional; English defaults
* are used when omitted so the API remains backwards-compatible.
*/
export interface RenderWithFallbackOptions {
/** ARIA label announced by screen readers while the skeleton is visible. */
readonly loadingLabel?: string;
/** Text shown on the retry button in the error card. */
readonly retryLabel?: string;
}

/**
* Wrap a synchronous or asynchronous render function with an error boundary.
*
* - Shows a loading skeleton while an async render is in progress.
* - On success the container is left with whatever the render function produced.
* - On failure the container shows an error card with an optional retry button.
* - Each retry re-runs the full renderFn.
*
* @param container - Target DOM element that will receive the rendered output.
* @param renderFn - Function (sync or async) that populates `container`.
* @param fallbackMessage - Human-readable message shown in the error card.
* @param options - Optional localised labels for the loading/error states.
*/
export async function renderWithFallback(
container: HTMLElement,
renderFn: () => void | Promise<void>,
fallbackMessage = 'Data temporarily unavailable',
options: RenderWithFallbackOptions = {},
): Promise<void> {
// Snapshot original markup so retry attempts can restore pre-existing DOM
// elements (e.g. <canvas> elements) that renderFn depends on.
// Note: restore uses innerHTML, so child element references held by callers
// are not preserved across retries — they will point to recreated nodes.
const originalHTML = container.innerHTML;
let inFlight = false;
let isFirstAttempt = true;

const attempt = async (): Promise<void> => {
if (inFlight) {
// Prevent overlapping attempts that could cause race conditions.
return;
}

inFlight = true;

// On retry, restore the original markup so any required child elements
// (e.g. <canvas> targets) are present for the re-render. The first
// attempt skips this to preserve existing DOM element references held
// by the caller.
if (!isFirstAttempt) {
container.innerHTML = originalHTML;
}
isFirstAttempt = false;

// Append a dedicated loading overlay so the skeleton stays visible while
// the async render is in progress without destroying required children.
const loadingOverlay = document.createElement('div');
loadingOverlay.setAttribute('data-error-boundary-loading', 'true');
loadingOverlay.setAttribute('aria-busy', 'true');
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderWithFallback appends a div[data-error-boundary-loading] and refers to it as a “loading overlay”, but there are no CSS rules for that attribute/class, and the element also has no class name. As a result it will behave like a normal block element (likely pushing content down) rather than overlaying the existing chart/canvas area, which can make the loading state hard to notice. Consider adding a dedicated class (or CSS for [data-error-boundary-loading]) that positions the overlay over the container (and ensuring the container establishes a positioning context), so the skeleton is consistently visible without changing layout.

Suggested change
loadingOverlay.setAttribute('aria-busy', 'true');
loadingOverlay.setAttribute('aria-busy', 'true');
loadingOverlay.className = 'error-boundary-loading-overlay';
// Ensure the container provides a positioning context for the overlay.
const currentPosition = getComputedStyle(container).position;
if (currentPosition === '' || currentPosition === 'static') {
container.style.position = 'relative';
}
// Style the overlay to cover the container without affecting layout.
loadingOverlay.style.position = 'absolute';
loadingOverlay.style.top = '0';
loadingOverlay.style.right = '0';
loadingOverlay.style.bottom = '0';
loadingOverlay.style.left = '0';
loadingOverlay.style.display = 'flex';
loadingOverlay.style.alignItems = 'center';
loadingOverlay.style.justifyContent = 'center';
loadingOverlay.style.zIndex = '1';
loadingOverlay.style.pointerEvents = 'none';

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c9a9be. The overlay now sets position: absolute (inset 0) on itself and ensures the container has position: relative when it's static, so the skeleton covers the content area without shifting layout. pointerEvents: none and zIndex: 1 are also applied. The element also gets the class error-boundary-loading-overlay for styling hooks.

loadingOverlay.className = 'error-boundary-loading-overlay';

// Ensure the container provides a positioning context for the overlay.
// Capture the prior inline value so it can be restored in the finally block.
const priorInlinePosition = container.style.position;
const currentPosition = getComputedStyle(container).position;
if (currentPosition === '' || currentPosition === 'static') {
container.style.position = 'relative';
}
Comment on lines +85 to +91
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderWithFallback mutates container.style.position (sets it to relative when computed position is static) but never restores the previous inline value. This can unintentionally change layout/stacking context for the container after the render completes, even though the loading overlay is removed. Consider capturing the prior inline style.position (and whether you changed it) and restoring it in the finally block (or toggling a CSS class instead of setting inline styles).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f39f230. priorInlinePosition = container.style.position is captured before the mutation and container.style.position = priorInlinePosition is called in the finally block after the overlay is removed, so the container's stacking context is always restored to whatever it was before renderWithFallback ran.


// Style the overlay to cover the container without affecting layout.
loadingOverlay.style.position = 'absolute';
loadingOverlay.style.top = '0';
loadingOverlay.style.right = '0';
loadingOverlay.style.bottom = '0';
loadingOverlay.style.left = '0';
loadingOverlay.style.display = 'flex';
loadingOverlay.style.alignItems = 'center';
loadingOverlay.style.justifyContent = 'center';
loadingOverlay.style.zIndex = '1';
loadingOverlay.style.pointerEvents = 'none';

renderLoadingFallback(loadingOverlay, options.loadingLabel);
container.appendChild(loadingOverlay);

try {
await Promise.resolve(renderFn());
} catch (err) {
logger.error('[ErrorBoundary] Render failed:', err);
renderErrorFallback(container, fallbackMessage, attempt, options.retryLabel);
} finally {
Comment on lines +67 to +113
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderWithFallback() always calls renderLoadingFallback(container), which clears the container’s existing DOM. Most dashboards rely on pre-existing markup (e.g. <canvas id="partyEffectivenessChart"> in index*.html) and render functions query by ID rather than recreating elements; if callers pass a container that wraps those elements, the skeleton will delete them and retries/successful renders may silently do nothing. Consider changing the contract so loading/error states overlay without destroying required children, or snapshot/restore the container’s original markup on retry.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1f7fcd4. renderWithFallback now snapshots container.innerHTML at call time and restores it (container.innerHTML = originalHTML) immediately before invoking renderFn(), so any pre-existing DOM elements (e.g. <canvas> targets) are always present when the render function runs — on the initial attempt and on every retry. Two new tests verify this behavior.

// Remove the overlay once the attempt finishes (success or failure).
if (loadingOverlay.parentNode === container) {
container.removeChild(loadingOverlay);
}
// Restore the container's original inline position value to avoid
// permanently altering its stacking context after the overlay is gone.
container.style.position = priorInlinePosition;
inFlight = false;
}
};

await attempt();
}
Loading
Loading