(h.hour >= 22 || h.hour < 6) && h.db > zone.nightLimit).length === 0 ? "#22c55e" : "#ef4444"}>
+
(h.hour >= 22 || h.hour < 6) && h.db > zone.nightLimit).length === 0 ? "#22c55e" : "#ef4444") }}>
{Math.round(hourly.filter(h => (h.hour >= 22 || h.hour < 6) && h.db <= zone.nightLimit).length / hourly.filter(h => h.hour >= 22 || h.hour < 6).length * 100)}%
@@ -613,6 +613,8 @@ export default function NoisePollutionTracker() {
);
const renderInsights = () => {
+ const exposureHours = hourly.filter(h => h.db > 85).length;
+ const leq8h = Math.round(hourly.slice(0, 8).reduce((acc, h) => acc + h.db, 0) / (hourly.slice(0, 8).length || 1)) || avgDb;
const insights = [
{ icon: "🔊", title: "Chronic Exposure Alert", color: "#ef4444", body: `At ${currentDb} dB (current), hearing damage begins after ${currentDb > 100 ? "15 minutes" : currentDb > 85 ? "2 hours" : currentDb > 70 ? "8 hours" : "no significant risk"}. ${exposureHours > 2 ? `You've experienced ${exposureHours} hours above 85 dB in the last 24h — well above safe daily limits.` : "Daily exposure is within safe limits for most hearing health standards."}` },
{ icon: "🚗", title: "Primary Noise Contributor", color: "#f97316", body: `Traffic noise is the dominant environmental noise source, contributing an average of 78–92 dB. WHO recommends ≤53 dB for roads to avoid health effects. Current urban levels typically exceed this by 20–40 dB, causing annoyance, sleep disturbance, and cardiovascular risk.` },
diff --git a/src/components/SymptomReportButton.jsx b/src/components/SymptomReportButton.jsx
index cdc9ea9f..c9af0f1a 100644
--- a/src/components/SymptomReportButton.jsx
+++ b/src/components/SymptomReportButton.jsx
@@ -2,6 +2,14 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { eventBus } from '../core/events';
export const SYMPTOM_REPORTS_STORAGE_KEY = 'pollution-symptom-reports';
+
+/**
+ * How many reports are kept.
+ *
+ * The list was never trimmed, so it grew until localStorage refused the write — and the
+ * write failure was swallowed, leaving the dialog to thank the visitor for a report that
+ * had not been stored. A cap plus a reported failure is better than either.
+ */
export const MAX_STORED_REPORTS = 200;
const SYMPTOM_OPTIONS = [
@@ -15,13 +23,21 @@ const SYMPTOM_OPTIONS = [
'Skin irritation',
];
+/** Elements that can hold focus inside the dialog, in document order. */
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
+/**
+ * Rounds a coordinate to ~1.1km precision so stored reports stay
+ * approximate rather than an exact trace of the reporter.
+ * @param {number} value
+ * @returns {number}
+ */
function toApproximateCoord(value) {
return Math.round(value * 100) / 100;
}
+/** @returns {any[]} */
export function readSymptomReports() {
try {
const raw = localStorage.getItem(SYMPTOM_REPORTS_STORAGE_KEY);
@@ -32,12 +48,22 @@ export function readSymptomReports() {
}
}
+/**
+ * Persists the reports, trimming to the newest {@link MAX_STORED_REPORTS}.
+ *
+ * @param {any[]} reports
+ * @returns {boolean} Whether the write landed. The caller has to know: a report the
+ * visitor was thanked for and that was silently dropped is worse than an error.
+ */
export function saveSymptomReports(reports) {
const trimmed = reports.slice(-MAX_STORED_REPORTS);
+
try {
localStorage.setItem(SYMPTOM_REPORTS_STORAGE_KEY, JSON.stringify(trimmed));
return true;
} catch {
+ // Most likely a full quota. Retry once with a much shorter list before giving up,
+ // so one oversized history does not permanently block reporting.
try {
localStorage.setItem(
SYMPTOM_REPORTS_STORAGE_KEY,
@@ -50,9 +76,11 @@ export function saveSymptomReports(reports) {
}
}
+/** @param {{fallbackPosition?: {lat: number, lon: number}}} params */
export default function SymptomReportButton({ fallbackPosition }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedSymptoms, setSelectedSymptoms] = useState([]);
+ /** idle | submitting | submitted | failed */
const [status, setStatus] = useState('idle');
const dialogRef = useRef(null);
@@ -60,6 +88,7 @@ export default function SymptomReportButton({ fallbackPosition }) {
const triggerRef = useRef(null);
const closeTimerRef = useRef(null);
+ /** @param {string} symptom */
const toggleSymptom = (symptom) => {
setSelectedSymptoms((prev) =>
prev.includes(symptom) ? prev.filter((s) => s !== symptom) : [...prev, symptom]
@@ -76,12 +105,20 @@ export default function SymptomReportButton({ fallbackPosition }) {
setStatus('idle');
}, []);
+ // Focus management and the Escape/Tab handling that `aria-modal` promises.
+ //
+ // None of this was here: focus stayed on the trigger behind the backdrop, which
+ // assistive technology treats as inert once aria-modal is set, so the dialog was
+ // never announced and Tab walked the page behind it. Escape did nothing, and the
+ // only way out with a keyboard was to tab through every checkbox to reach Cancel.
+ // This mirrors what SolutionsAwareness already does for its article modal.
useEffect(() => {
if (!isOpen) return undefined;
const previouslyFocused = document.activeElement;
closeBtnRef.current?.focus();
+ /** @param {KeyboardEvent} event */
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
@@ -110,6 +147,10 @@ export default function SymptomReportButton({ fallbackPosition }) {
return () => {
window.removeEventListener('keydown', handleKeyDown);
+ // Back to whatever opened the dialog. A pointer-opened dialog can leave
+ // `document.activeElement` on , in which case the trigger is where
+ // focus belongs — dropping it on restarts tab order at the top of
+ // the page, which is the thing that makes a modal painful to use.
const restoreTo =
previouslyFocused instanceof HTMLElement && previouslyFocused !== document.body
? previouslyFocused
@@ -118,6 +159,8 @@ export default function SymptomReportButton({ fallbackPosition }) {
};
}, [isOpen, closeModal]);
+ // A pending auto-close must not outlive the component. Nothing cancelled the old
+ // timer, so unmounting inside its 1.2 seconds set state on a component that was gone.
useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
@@ -125,46 +168,33 @@ export default function SymptomReportButton({ fallbackPosition }) {
}, []);
const submitReport = () => {
+ // Geolocation can take up to the 5s timeout below, and the button used to stay
+ // live throughout — three clicks while the permission prompt was up filed three
+ // reports, which then showed as three separate markers on the map. A failed
+ // attempt can still be retried; only an in-flight or completed one is refused.
if (selectedSymptoms.length === 0 || status === 'submitting' || status === 'submitted') return;
setStatus('submitting');
- const finalize = async (coords) => {
- const newReport = {
+ /** @param {{lat: number, lon: number}|null} coords */
+ const finalize = (coords) => {
+ const reports = readSymptomReports();
+ reports.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
symptoms: selectedSymptoms,
timestamp: new Date().toISOString(),
- // Privacy measure: we only send approximate coordinates
latitude: coords ? toApproximateCoord(coords.lat) : null,
longitude: coords ? toApproximateCoord(coords.lon) : null,
- };
+ });
- // 1. Save locally
- const reports = readSymptomReports();
- reports.push(newReport);
- const savedLocally = saveSymptomReports(reports);
-
- // 2. Send to backend
- try {
- const response = await fetch('/api/symptoms', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(newReport),
- });
-
- if (!response.ok) {
- throw new Error('Failed to send to server');
- }
-
- eventBus.emit('SYMPTOM_REPORT_SUBMITTED');
- setStatus('submitted');
- closeTimerRef.current = setTimeout(closeModal, 1200);
-
- } catch (error) {
- console.error('Error submitting symptom report:', error);
- // If backend fails but local succeeds, we still show a localized failure to be safe
+ if (!saveSymptomReports(reports)) {
setStatus('failed');
+ return;
}
+
+ eventBus.emit('SYMPTOM_REPORT_SUBMITTED');
+ setStatus('submitted');
+ closeTimerRef.current = setTimeout(closeModal, 1200);
};
if (navigator.geolocation) {
@@ -195,6 +225,9 @@ export default function SymptomReportButton({ fallbackPosition }) {
{
if (event.target === event.currentTarget) closeModal();
}}
@@ -232,7 +265,8 @@ export default function SymptomReportButton({ fallbackPosition }) {
<>
{status === 'failed' && (
- Your report could not be saved right now. Please try again later.
+ Your report could not be saved — this browser's storage is
+ full or unavailable. Nothing was recorded.
)}
diff --git a/src/components/VoiceAlertManager.jsx b/src/components/VoiceAlertManager.jsx
index ef65b137..da7855f6 100644
--- a/src/components/VoiceAlertManager.jsx
+++ b/src/components/VoiceAlertManager.jsx
@@ -21,6 +21,7 @@ const VoiceAlertManager = () => {
config,
isSpeaking,
queueLength,
+ queue = [],
addToQueue,
clearQueue,
updateConfig,
diff --git a/src/context/TenantContext.jsx b/src/context/TenantContext.jsx
index 2f22d63f..96386a59 100644
--- a/src/context/TenantContext.jsx
+++ b/src/context/TenantContext.jsx
@@ -227,7 +227,12 @@ export function TenantProvider({ children }) {
}, [tenantId, currentTenant]);
useEffect(() => {
- const activeId = localStorage.getItem(STORAGE_KEY);
+ let activeId = null;
+ try {
+ activeId = localStorage.getItem(STORAGE_KEY);
+ } catch {
+ // Storage unavailable or insecure
+ }
fetchTenants().then(() => {
if (activeId && tenants.length > 0) {
const saved = tenants.find((t) => t.id === activeId);
diff --git a/src/global.d.ts b/src/global.d.ts
index 97ce4524..3fa9f4f5 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -1,2 +1,8 @@
///
///
+
+declare module '*.module.css' {
+ const classes: { readonly [key: string]: string };
+ export default classes;
+}
+
diff --git a/src/services/historicalDataService.js b/src/services/historicalDataService.js
index 558f695a..7906f540 100644
--- a/src/services/historicalDataService.js
+++ b/src/services/historicalDataService.js
@@ -1,6 +1,7 @@
import { getTenantScopedDbName, getTenantScopedStoreName } from './tenantService';
import { logger } from '../utils/logger';
import { localDayKey } from '../utils/localDay';
+import { formatCSV, formatCSVRow } from '../utils/csv';
const log = logger.child({ module: 'historicalDataService' });
@@ -266,9 +267,10 @@ export function getDelimiterForLocale(locale) {
*/
export function formatHistoricalCSV(dailyData, startDate, endDate, delimiter) {
const actualDelimiter = delimiter !== undefined ? delimiter : getDelimiterForLocale();
+ const headers = ['Date', 'AQI', 'PM2.5', 'PM10', 'NO2', 'Ozone', 'CO'];
if (!Array.isArray(dailyData) || dailyData.length === 0) {
- return ['Date', 'AQI', 'PM2.5', 'PM10', 'NO2', 'Ozone', 'CO'].join(actualDelimiter);
+ return formatCSVRow(headers, actualDelimiter);
}
const filtered = dailyData
@@ -280,7 +282,6 @@ export function formatHistoricalCSV(dailyData, startDate, endDate, delimiter) {
})
.sort((a, b) => a.date.localeCompare(b.date));
- const headers = ['Date', 'AQI', 'PM2.5', 'PM10', 'NO2', 'Ozone', 'CO'];
const rows = filtered.map((day) => [
day.date,
day.maxAqi != null ? day.maxAqi : (day.aqi != null ? day.aqi : ''),
@@ -291,5 +292,5 @@ export function formatHistoricalCSV(dailyData, startDate, endDate, delimiter) {
day.co != null ? day.co : ''
]);
- return [headers.join(actualDelimiter), ...rows.map((r) => r.join(actualDelimiter))].join('\n');
+ return formatCSV([headers, ...rows], actualDelimiter);
}
diff --git a/src/services/historicalDataService.test.js b/src/services/historicalDataService.test.js
index 16eca433..d3bafa36 100644
--- a/src/services/historicalDataService.test.js
+++ b/src/services/historicalDataService.test.js
@@ -144,5 +144,15 @@ describe('formatHistoricalCSV', () => {
const csv = formatHistoricalCSV([], undefined, undefined, ';');
expect(csv).toBe('Date;AQI;PM2.5;PM10;NO2;Ozone;CO');
});
+
+ it('escapes embedded delimiters and neutralizes formula injection in values', () => {
+ const complexData = [
+ { date: '2026-07-01', maxAqi: '=SUM(1,2)', pm25: '35,5', pm10: 70, no2: 10, ozone: 20, co: 4 },
+ ];
+ const csv = formatHistoricalCSV(complexData, undefined, undefined, ',');
+ const lines = csv.split('\n');
+ expect(lines[1]).toBe('2026-07-01,"\'=SUM(1,2)","35,5",70,10,20,4');
+ });
});
});
+
diff --git a/src/services/verificationService.test.js b/src/services/verificationService.test.js
index bc8ba245..8909ac66 100644
--- a/src/services/verificationService.test.js
+++ b/src/services/verificationService.test.js
@@ -319,7 +319,7 @@ describe('computeVerificationScore', () => {
const result = computeVerificationScore(report, {});
// Only freshness (10 pts for fresh) can contribute
expect(result.confidenceScore).toBeLessThanOrEqual(10);
- expect(result.factors).toHaveLength(5);
+ expect(result.factors).toHaveLength(6);
});
it('composite score does not exceed 100', () => {
diff --git a/src/utils/csv.js b/src/utils/csv.js
new file mode 100644
index 00000000..d0fb5deb
--- /dev/null
+++ b/src/utils/csv.js
@@ -0,0 +1,73 @@
+/**
+ * @fileoverview Secure, RFC 4180 compliant CSV serialization utility
+ * with formula injection (CSV injection) protection.
+ */
+
+const FORMULA_PREFIXES = ['=', '+', '-', '@', '\t', '\r'];
+
+/**
+ * Sanitizes and escapes a single CSV cell value according to RFC 4180
+ * and spreadsheet formula injection prevention rules.
+ *
+ * @param {any} value - The raw cell value.
+ * @param {string} [delimiter=','] - The delimiter character (e.g. ',' or ';').
+ * @returns {string} The escaped CSV cell string.
+ */
+export function escapeCSVCell(value, delimiter = ',') {
+ if (value === null || value === undefined) {
+ return '';
+ }
+
+ if (typeof value === 'number') {
+ if (Number.isNaN(value)) return '';
+ return String(value);
+ }
+
+ if (typeof value === 'boolean') {
+ return String(value);
+ }
+
+ let str = String(value);
+
+ // Neutralize formula injection if the string begins with =, +, -, @, \t, or \r
+ if (str.length > 0 && FORMULA_PREFIXES.includes(str[0])) {
+ str = `'${str}`;
+ }
+
+ // Quote if it contains delimiter, double quote, CR, or LF
+ const needsQuotes =
+ str.includes(delimiter) ||
+ str.includes('"') ||
+ str.includes('\n') ||
+ str.includes('\r');
+
+ if (needsQuotes) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+
+ return str;
+}
+
+/**
+ * Formats an array of cell values into a single CSV row string.
+ *
+ * @param {Array
} row - Array of cell values.
+ * @param {string} [delimiter=','] - The delimiter character.
+ * @returns {string} The formatted CSV row string.
+ */
+export function formatCSVRow(row, delimiter = ',') {
+ if (!Array.isArray(row)) return '';
+ return row.map((cell) => escapeCSVCell(cell, delimiter)).join(delimiter);
+}
+
+/**
+ * Formats a 2D array of rows into a complete CSV string.
+ *
+ * @param {Array>} rows - 2D array of row data.
+ * @param {string} [delimiter=','] - The delimiter character.
+ * @returns {string} The formatted CSV content string.
+ */
+export function formatCSV(rows, delimiter = ',') {
+ if (!Array.isArray(rows)) return '';
+ return rows.map((row) => formatCSVRow(row, delimiter)).join('\n');
+}
diff --git a/src/utils/csv.test.js b/src/utils/csv.test.js
new file mode 100644
index 00000000..769ac8f9
--- /dev/null
+++ b/src/utils/csv.test.js
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import { escapeCSVCell, formatCSVRow, formatCSV } from './csv';
+
+describe('escapeCSVCell', () => {
+ it('converts null, undefined, and NaN to empty string', () => {
+ expect(escapeCSVCell(null)).toBe('');
+ expect(escapeCSVCell(undefined)).toBe('');
+ expect(escapeCSVCell(NaN)).toBe('');
+ });
+
+ it('preserves plain strings and numbers', () => {
+ expect(escapeCSVCell('Delhi')).toBe('Delhi');
+ expect(escapeCSVCell(123)).toBe('123');
+ expect(escapeCSVCell(0)).toBe('0');
+ expect(escapeCSVCell(45.67)).toBe('45.67');
+ expect(escapeCSVCell(-15)).toBe('-15');
+ expect(escapeCSVCell(true)).toBe('true');
+ expect(escapeCSVCell(false)).toBe('false');
+ });
+
+ it('quotes cells containing commas, quotes, and newlines (RFC 4180)', () => {
+ expect(escapeCSVCell('PM2.5, respirable')).toBe('"PM2.5, respirable"');
+ expect(escapeCSVCell('Anand Vihar, Delhi')).toBe('"Anand Vihar, Delhi"');
+ expect(escapeCSVCell('Line1\nLine2')).toBe('"Line1\nLine2"');
+ expect(escapeCSVCell('Line1\r\nLine2')).toBe('"Line1\r\nLine2"');
+ expect(escapeCSVCell('Quote "inside"')).toBe('"Quote ""inside"""');
+ });
+
+ it('neutralizes spreadsheet formula injection (=, +, -, @, \\t, \\r)', () => {
+ expect(escapeCSVCell('=HYPERLINK("http://example.com","click")')).toBe(
+ '"\'=HYPERLINK(""http://example.com"",""click"")"'
+ );
+ expect(escapeCSVCell('=SUM(A1:A10)')).toBe("'=SUM(A1:A10)");
+ expect(escapeCSVCell('+12345')).toBe("'+12345");
+ expect(escapeCSVCell('-cmd|/C calc')).toBe("'-cmd|/C calc");
+ expect(escapeCSVCell('@cmd')).toBe("'@cmd");
+ expect(escapeCSVCell('\tmalicious')).toBe("'\tmalicious");
+ expect(escapeCSVCell('\r\nmalicious')).toBe('"\'\r\nmalicious"');
+ });
+
+ it('handles custom delimiters (such as semicolon)', () => {
+ expect(escapeCSVCell('A;B', ';')).toBe('"A;B"');
+ expect(escapeCSVCell('A,B', ';')).toBe('A,B');
+ });
+});
+
+describe('formatCSVRow', () => {
+ it('formats an array of cell values into a single delimited row', () => {
+ const row = ['2026-01-04T05:00:00Z', 'PM2.5, respirable', 210, 60, 'CPCB', '=HYPERLINK("http://example.com")'];
+ const result = formatCSVRow(row, ',');
+ expect(result).toBe('2026-01-04T05:00:00Z,"PM2.5, respirable",210,60,CPCB,"\'=HYPERLINK(""http://example.com"")"');
+ });
+
+ it('returns empty string for non-array input', () => {
+ expect(formatCSVRow(null)).toBe('');
+ });
+});
+
+describe('formatCSV', () => {
+ it('formats 2D array into multiline CSV', () => {
+ const rows = [
+ ['Date', 'Value'],
+ ['2026-01-01', 100],
+ ['2026-01-02', 150],
+ ];
+ expect(formatCSV(rows)).toBe('Date,Value\n2026-01-01,100\n2026-01-02,150');
+ });
+
+ it('returns empty string for non-array input', () => {
+ expect(formatCSV(null)).toBe('');
+ });
+});
diff --git a/src/utils/reportExporter.js b/src/utils/reportExporter.js
index 92d4ffb8..2263edc4 100644
--- a/src/utils/reportExporter.js
+++ b/src/utils/reportExporter.js
@@ -2,34 +2,38 @@
* @fileoverview Utility functions to format aggregated data into downloadable, regulation-ready structures.
*/
+import { formatCSV } from './csv';
+
/**
* Converts compliance report data to CSV format.
* @param {Object} report - The compliance report object.
* @returns {string} CSV formatted string.
*/
export const exportToCSV = (report) => {
+ const safeReport = report || {};
const headers = ['Timestamp', 'Pollutant', 'Recorded Value', 'Threshold', 'Standard', 'Severity'];
- const rows = report.exceedances.map(ex => [
- ex.timestamp,
- ex.pollutant,
- ex.recordedValue,
- ex.threshold,
- ex.standard,
- ex.severity
+ const exceedances = Array.isArray(safeReport.exceedances) ? safeReport.exceedances : [];
+
+ const rows = exceedances.map((ex) => [
+ ex?.timestamp,
+ ex?.pollutant,
+ ex?.recordedValue,
+ ex?.threshold,
+ ex?.standard,
+ ex?.severity,
]);
- const csvContent = [
- `Report ID: ${report.id}`,
- `Period: ${report.startDate} to ${report.endDate}`,
- `Standard: ${report.standard}`,
- `Total Exceedances: ${report.totalExceedances}`,
- `Generated At: ${report.generatedAt}`,
+ const metadataRows = [
+ `Report ID: ${safeReport.id ?? ''}`,
+ `Period: ${safeReport.startDate ?? ''} to ${safeReport.endDate ?? ''}`,
+ `Standard: ${safeReport.standard ?? ''}`,
+ `Total Exceedances: ${safeReport.totalExceedances ?? ''}`,
+ `Generated At: ${safeReport.generatedAt ?? ''}`,
'',
- headers.join(','),
- ...rows.map(row => row.join(','))
- ].join('\n');
+ ];
- return csvContent;
+ const dataCsv = formatCSV([headers, ...rows], ',');
+ return [...metadataRows, dataCsv].join('\n');
};
/**
diff --git a/src/utils/reportExporter.test.js b/src/utils/reportExporter.test.js
new file mode 100644
index 00000000..61d1eb2e
--- /dev/null
+++ b/src/utils/reportExporter.test.js
@@ -0,0 +1,69 @@
+import { describe, it, expect } from 'vitest';
+import { exportToCSV, exportToJSON } from './reportExporter';
+
+describe('reportExporter', () => {
+ it('correctly escapes commas, quotes, and formulas in compliance CSV exports', () => {
+ const report = {
+ id: 'R-1',
+ startDate: '2026-01-01',
+ endDate: '2026-01-31',
+ standard: 'CPCB',
+ totalExceedances: 1,
+ generatedAt: '2026-02-01T00:00:00Z',
+ exceedances: [
+ {
+ timestamp: '2026-01-04T05:00:00Z',
+ pollutant: 'PM2.5, respirable',
+ recordedValue: 210,
+ threshold: 60,
+ standard: 'CPCB',
+ severity: '=HYPERLINK("http://example.com","click")',
+ },
+ ],
+ };
+
+ const csv = exportToCSV(report);
+ const lines = csv.split('\n');
+
+ expect(lines[0]).toBe('Report ID: R-1');
+ expect(lines[1]).toBe('Period: 2026-01-01 to 2026-01-31');
+ expect(lines[2]).toBe('Standard: CPCB');
+ expect(lines[3]).toBe('Total Exceedances: 1');
+ expect(lines[4]).toBe('Generated At: 2026-02-01T00:00:00Z');
+ expect(lines[5]).toBe('');
+ expect(lines[6]).toBe('Timestamp,Pollutant,Recorded Value,Threshold,Standard,Severity');
+ expect(lines[7]).toBe('2026-01-04T05:00:00Z,"PM2.5, respirable",210,60,CPCB,"\'=HYPERLINK(""http://example.com"",""click"")"');
+ });
+
+ it('exports a header-only table without throwing when exceedances is absent or empty', () => {
+ const reportWithoutExceedances = {
+ id: 'R-EMPTY',
+ startDate: '2026-01-01',
+ endDate: '2026-01-31',
+ standard: 'CPCB',
+ totalExceedances: 0,
+ generatedAt: '2026-02-01T00:00:00Z',
+ };
+
+ const csv = exportToCSV(reportWithoutExceedances);
+ const lines = csv.split('\n');
+
+ expect(lines[0]).toBe('Report ID: R-EMPTY');
+ expect(lines[6]).toBe('Timestamp,Pollutant,Recorded Value,Threshold,Standard,Severity');
+ expect(lines[7]).toBeUndefined();
+ });
+
+ it('does not write "undefined" literals when report metadata properties are missing', () => {
+ const csv = exportToCSV({});
+ expect(csv).not.toContain('undefined');
+ expect(csv).toContain('Report ID: ');
+ expect(csv).toContain('Period: to ');
+ expect(csv).toContain('Timestamp,Pollutant,Recorded Value,Threshold,Standard,Severity');
+ });
+
+ it('serializes compliance reports to formatted JSON with exportToJSON', () => {
+ const report = { id: 'R-1', standard: 'CPCB' };
+ const json = exportToJSON(report);
+ expect(JSON.parse(json)).toEqual(report);
+ });
+});