feat: add data source disclaimers to all dashboards#424
Conversation
…ion chart time scale
- Anomaly Detection Dashboard: Add generateFallbackData() to provide synthetic
data when CSV sources are unavailable (consistent with other dashboards).
Also call hideLoading() in error handler so canvases aren't left hidden.
- Coalition Dashboard: Change votingAnomalyChart x-axis from type:'time' to
type:'linear' with custom tick formatter, avoiding the missing date adapter
error ("This method is not implemented").
Co-authored-by: pethers <[email protected]>
Use consistent z-score values: ballot_z_score and doc_z_score now drive severity/anomaly_type/direction classification. max_z_score is derived as max(abs(ballotZ), abs(docZ)). Remove unused constant arrays. Co-authored-by: pethers <[email protected]>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
- Add showDataSourceDisclaimer() utility in dom-utils.ts with DataSourceType - Add CSS styling for .data-source-disclaimer (live/synthetic/mock variants) - Anomaly Detection: tracks 'synthetic' when generateFallbackData() used - Coalition: tracks 'mock' when any generateMock*() function used - All other dashboards (party, committee, ministry, risk, election-cycle, seasonal-patterns, pre-election, politician): show 'live' disclaimer Co-authored-by: pethers <[email protected]>
Co-authored-by: pethers <[email protected]>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
There was a problem hiding this comment.
Pull request overview
Adds a shared “data source disclaimer” banner across dashboard pages to indicate whether displayed data is live CSV, synthetic fallback, or mock/demo—plus dashboard-specific fixes to improve resilience when CSV fetches fail and to avoid Chart.js time-scale adapter errors.
Changes:
- Introduces
showDataSourceDisclaimer(container, type)andDataSourceTypein shared DOM utilities and integrates the banner into multiple dashboards. - Updates Anomaly Detection to render with synthetic fallback data when all CSV sources fail (and ensures loading is hidden on init failure).
- Adds styling and unit tests for the disclaimer banner.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/data-source-disclaimer.test.ts | Adds unit tests for disclaimer creation, positioning, dedupe, accessibility, and type safety. |
| styles.css | Adds .data-source-disclaimer styling and per-type variants. |
| src/browser/shared/dom-utils.ts | Adds DataSourceType and showDataSourceDisclaimer() utility. |
| src/browser/dashboards/seasonal-patterns.ts | Shows “live” disclaimer after successful data fetch. |
| src/browser/dashboards/risk-dashboard.ts | Shows “live” disclaimer before rendering risk visualizations. |
| src/browser/dashboards/pre-election.ts | Shows “live” disclaimer after init validation. |
| src/browser/dashboards/politician-dashboard.ts | Shows “live” disclaimer after data load. |
| src/browser/dashboards/party-dashboard.ts | Shows “live” vs “synthetic” disclaimer based on whether CSV produced data. |
| src/browser/dashboards/ministry-dashboard.ts | Shows “live” disclaimer after loading overlay removal. |
| src/browser/dashboards/election-cycle.ts | Shows “live” disclaimer after successful fetchAllData(). |
| src/browser/dashboards/committees-dashboard.ts | Shows “live” disclaimer after successful data load. |
| src/browser/dashboards/coalition-dashboard.ts | Tracks data source type (live/mock) and switches Chart.js x-axis to linear ticks. |
| src/browser/dashboards/anomaly-detection.ts | Adds synthetic fallback data generation, tracks dataSourceType, shows disclaimer, and hides loading on init failure. |
src/browser/shared/dom-utils.ts
Outdated
| // Insert after the first heading or at the top of the container | ||
| const heading = container.querySelector('h2, h3'); | ||
| if (heading && heading.nextSibling) { | ||
| heading.parentNode!.insertBefore(disclaimer, heading.nextSibling); |
There was a problem hiding this comment.
showDataSourceDisclaimer() prepends the banner when a heading exists but has no nextSibling (e.g., container contains only an <h2>). In that case the disclaimer ends up before the heading, contradicting the “insert after heading” intent. Consider inserting after the heading whenever a heading is found (e.g., insertAdjacentElement('afterend', ...) or parentNode.insertBefore(..., heading.nextSibling ?? null)), and add a unit test that covers the “heading is last child” case.
| // Insert after the first heading or at the top of the container | |
| const heading = container.querySelector('h2, h3'); | |
| if (heading && heading.nextSibling) { | |
| heading.parentNode!.insertBefore(disclaimer, heading.nextSibling); | |
| // Insert after the first heading if present, otherwise at the top of the container | |
| const heading = container.querySelector('h2, h3'); | |
| if (heading) { | |
| heading.insertAdjacentElement('afterend', disclaimer); |
There was a problem hiding this comment.
Fixed in c8ac3e6 — switched to heading.insertAdjacentElement('afterend', disclaimer) which works correctly even when the heading is the last child. Also added a dedicated unit test for the "heading is last child" case.
| beforeEach(() => { | ||
| container = document.createElement('section'); | ||
| container.id = 'test-dashboard'; | ||
| container.innerHTML = '<h2>Test Dashboard</h2><div class="dashboard-grid"></div>'; | ||
| document.body.appendChild(container); | ||
| }); |
There was a problem hiding this comment.
The beforeEach appends a new container to document.body but never resets/removes it, so the DOM accumulates across tests. Other DOM-based tests in this repo typically reset document.body.innerHTML in beforeEach, which avoids cross-test coupling and memory growth.
There was a problem hiding this comment.
Fixed in c8ac3e6 — added document.body.innerHTML = ''; at the start of beforeEach to match the pattern used by other DOM-based tests in the repo.
styles.css
Outdated
| background: rgba(76, 175, 80, 0.1); | ||
| border: 1px solid rgba(76, 175, 80, 0.3); | ||
| color: #4caf50; | ||
| } | ||
|
|
||
| .data-source-synthetic { | ||
| background: rgba(255, 152, 0, 0.1); | ||
| border: 1px solid rgba(255, 152, 0, 0.3); | ||
| color: #ff9800; | ||
| } | ||
|
|
||
| .data-source-mock { | ||
| background: rgba(0, 217, 255, 0.1); | ||
| border: 1px solid rgba(0, 217, 255, 0.3); | ||
| color: #00d9ff; |
There was a problem hiding this comment.
These new banner variants introduce hard-coded colors (#4caf50, #ff9800, #00d9ff). The stylesheet already defines accessible theme tokens like --success-color and --warning-color (and uses var(--primary-cyan, #00d9ff) elsewhere). Using those variables here would keep the banner consistent with the site theme and WCAG-tuned contrast values.
| background: rgba(76, 175, 80, 0.1); | |
| border: 1px solid rgba(76, 175, 80, 0.3); | |
| color: #4caf50; | |
| } | |
| .data-source-synthetic { | |
| background: rgba(255, 152, 0, 0.1); | |
| border: 1px solid rgba(255, 152, 0, 0.3); | |
| color: #ff9800; | |
| } | |
| .data-source-mock { | |
| background: rgba(0, 217, 255, 0.1); | |
| border: 1px solid rgba(0, 217, 255, 0.3); | |
| color: #00d9ff; | |
| color: var(--success-color); | |
| border: 1px solid color-mix(in srgb, var(--success-color) 40%, transparent); | |
| background-color: color-mix(in srgb, var(--success-color) 10%, transparent); | |
| } | |
| .data-source-synthetic { | |
| color: var(--warning-color); | |
| border: 1px solid color-mix(in srgb, var(--warning-color) 40%, transparent); | |
| background-color: color-mix(in srgb, var(--warning-color) 10%, transparent); | |
| } | |
| .data-source-mock { | |
| color: var(--primary-cyan, #00d9ff); | |
| border: 1px solid color-mix(in srgb, var(--primary-cyan, #00d9ff) 40%, transparent); | |
| background-color: color-mix(in srgb, var(--primary-cyan, #00d9ff) 10%, transparent); |
There was a problem hiding this comment.
Fixed in c8ac3e6 — now uses var(--success-color), var(--warning-color), and var(--primary-cyan, #00d9ff) with color-mix() for border and background opacity.
| const ballotZ = (Math.random() * 4 - 1).toFixed(4); | ||
| const docZ = (Math.random() * 4 - 1).toFixed(4); | ||
| const absBallotZ = Math.abs(parseFloat(ballotZ)); | ||
| const absDocZ = Math.abs(parseFloat(docZ)); | ||
| const maxZ = Math.max(absBallotZ, absDocZ); | ||
| let severity = 'LOW'; | ||
| let anomalyType = 'NO_ANOMALY'; | ||
| let direction = 'WITHIN_NORMAL_RANGE'; | ||
| const dominantZ = absBallotZ >= absDocZ ? ballotZ : docZ; | ||
|
|
||
| if (maxZ >= 3.0) { severity = 'CRITICAL'; anomalyType = absBallotZ >= absDocZ ? 'BALLOT_ANOMALY' : 'DOCUMENT_ANOMALY'; direction = parseFloat(dominantZ) > 0 ? 'UNUSUALLY_HIGH' : 'UNUSUALLY_LOW'; } | ||
| else if (maxZ >= 2.0) { severity = 'HIGH'; anomalyType = absBallotZ >= absDocZ ? 'BALLOT_ANOMALY' : 'DOCUMENT_ANOMALY'; direction = parseFloat(dominantZ) > 0 ? 'UNUSUALLY_HIGH' : 'UNUSUALLY_LOW'; } | ||
| else if (maxZ >= 1.0) { severity = 'MODERATE'; } | ||
|
|
||
| data.push({ | ||
| year: String(year), | ||
| quarter: String(quarter), | ||
| is_election_year: (year === 2022) ? 't' : 'f', | ||
| total_ballots: String(100 + Math.floor(Math.random() * 200)), | ||
| active_politicians: '349', | ||
| attendance_rate: '100.00', | ||
| documents_produced: String(200 + Math.floor(Math.random() * 500)), |
There was a problem hiding this comment.
generateFallbackData() uses Math.random() to generate z-scores, which makes the synthetic fallback non-deterministic and can randomly produce HIGH/CRITICAL anomalies. Because checkForCriticalAnomalies() triggers alerts based on the first 2 records, the dashboard can show critical banners unpredictably when running on fallback data. Consider making the fallback deterministic (e.g., fixed seeded values or a simple year/quarter-based function) and/or constraining fallback severities to non-alerting levels.
| const ballotZ = (Math.random() * 4 - 1).toFixed(4); | |
| const docZ = (Math.random() * 4 - 1).toFixed(4); | |
| const absBallotZ = Math.abs(parseFloat(ballotZ)); | |
| const absDocZ = Math.abs(parseFloat(docZ)); | |
| const maxZ = Math.max(absBallotZ, absDocZ); | |
| let severity = 'LOW'; | |
| let anomalyType = 'NO_ANOMALY'; | |
| let direction = 'WITHIN_NORMAL_RANGE'; | |
| const dominantZ = absBallotZ >= absDocZ ? ballotZ : docZ; | |
| if (maxZ >= 3.0) { severity = 'CRITICAL'; anomalyType = absBallotZ >= absDocZ ? 'BALLOT_ANOMALY' : 'DOCUMENT_ANOMALY'; direction = parseFloat(dominantZ) > 0 ? 'UNUSUALLY_HIGH' : 'UNUSUALLY_LOW'; } | |
| else if (maxZ >= 2.0) { severity = 'HIGH'; anomalyType = absBallotZ >= absDocZ ? 'BALLOT_ANOMALY' : 'DOCUMENT_ANOMALY'; direction = parseFloat(dominantZ) > 0 ? 'UNUSUALLY_HIGH' : 'UNUSUALLY_LOW'; } | |
| else if (maxZ >= 1.0) { severity = 'MODERATE'; } | |
| data.push({ | |
| year: String(year), | |
| quarter: String(quarter), | |
| is_election_year: (year === 2022) ? 't' : 'f', | |
| total_ballots: String(100 + Math.floor(Math.random() * 200)), | |
| active_politicians: '349', | |
| attendance_rate: '100.00', | |
| documents_produced: String(200 + Math.floor(Math.random() * 500)), | |
| // Deterministic, bounded z-scores to avoid random HIGH/CRITICAL anomalies in fallback data | |
| const ballotZRaw = (((year * 31 + quarter * 17) % 300) / 100) - 1.5; // approx range [-1.5, 1.49] | |
| const docZRaw = (((year * 19 + quarter * 23) % 300) / 100) - 1.5; | |
| const ballotZ = ballotZRaw.toFixed(4); | |
| const docZ = docZRaw.toFixed(4); | |
| const absBallotZ = Math.abs(ballotZRaw); | |
| const absDocZ = Math.abs(docZRaw); | |
| const maxZ = Math.max(absBallotZ, absDocZ); | |
| let severity = 'LOW'; | |
| let anomalyType = 'NO_ANOMALY'; | |
| let direction = 'WITHIN_NORMAL_RANGE'; | |
| const dominantZ = absBallotZ >= absDocZ ? ballotZRaw : docZRaw; | |
| if (maxZ >= 3.0) { severity = 'CRITICAL'; anomalyType = absBallotZ >= absDocZ ? 'BALLOT_ANOMALY' : 'DOCUMENT_ANOMALY'; direction = dominantZ > 0 ? 'UNUSUALLY_HIGH' : 'UNUSUALLY_LOW'; } | |
| else if (maxZ >= 2.0) { severity = 'HIGH'; anomalyType = absBallotZ >= absDocZ ? 'BALLOT_ANOMALY' : 'DOCUMENT_ANOMALY'; direction = dominantZ > 0 ? 'UNUSUALLY_HIGH' : 'UNUSUALLY_LOW'; } | |
| else if (maxZ >= 1.0) { severity = 'MODERATE'; } | |
| data.push({ | |
| year: String(year), | |
| quarter: String(quarter), | |
| is_election_year: (year === 2022) ? 't' : 'f', | |
| // Deterministic synthetic counts for reproducible fallback data | |
| total_ballots: String(100 + ((year * 13 + quarter * 7) % 200)), | |
| active_politicians: '349', | |
| attendance_rate: '100.00', | |
| documents_produced: String(200 + ((year * 29 + quarter * 11) % 500)), |
There was a problem hiding this comment.
Fixed in c8ac3e6 — replaced Math.random() with deterministic (year * 31 + quarter * 17) % 300 formulas. Fallback z-scores now range approximately [-1.5, 1.49], which keeps all severities at LOW/MODERATE and avoids triggering critical anomaly banners unpredictably.
|
@copilot apply changes based on the comments in this thread |
…CSS vars, heading insertion, test cleanup
- dom-utils.ts: Use insertAdjacentElement('afterend') for reliable heading insertion
- anomaly-detection.ts: Replace Math.random() with deterministic year/quarter-based values
- styles.css: Use --success-color, --warning-color, --primary-cyan theme variables
- tests: Reset document.body.innerHTML in beforeEach, add "heading is last child" test
Co-authored-by: pethers <[email protected]>
All 4 review comments addressed in c8ac3e6:
|
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
showDataSourceDisclaimer()utility indom-utils.ts--success-color,--warning-color,--primary-cyan)Math.random())insertAdjacentElement('afterend')for all casesbeforeEachto avoid cross-test coupling💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.