diff --git a/src/App.jsx b/src/App.jsx
index cfac13b..6ab51a4 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -13,6 +13,7 @@ import QuizSection from "./components/QuizSection";
import SolutionsAwareness from "./components/SolutionsAwareness";
import ScenarioSimulator from "./components/ScenarioSimulator";
import HistoricalAnalysis from "./components/HistoricalAnalysis";
+import HistoricalPollutionExplorer from "./components/HistoricalPollutionExplorer";
import Factoid from "./components/Factoid";
import HistoricalData from "./components/HistoricalData";
import LocationSearch from "./components/LocationSearch";
@@ -327,6 +328,7 @@ function SectionNav({ activeSection, onSectionChange }) {
{ id: "exposure-tracker", label: "Exposure Score" },
{ id: "history", label: "History" },
{ id: "historical-data", label: "Data Explorer" },
+ { id: "historical-explorer", label: "Pollution Explorer" },
{ id: "Commute", label: "Commute" },
{ id: "CarbonCalculator", label: "Carbon Calculator" },
{ id: "glossary", label: "Glossary" },
@@ -1223,11 +1225,16 @@ function AppContent() {
)}
- {activeSection === "historical-data" && (
-
@@ -1358,4 +1365,4 @@ export default function App() {
);
-}
\ No newline at end of file
+}
diff --git a/src/components/HistoricalPollutionExplorer.jsx b/src/components/HistoricalPollutionExplorer.jsx
new file mode 100644
index 0000000..5257b9e
--- /dev/null
+++ b/src/components/HistoricalPollutionExplorer.jsx
@@ -0,0 +1,646 @@
+// src/components/HistoricalPollutionExplorer.jsx
+// @ts-nocheck
+// -----------------------------------------------------------------------------
+// Issue #892 — Historical Pollution Explorer
+//
+// The interactive "investigate pollution over long periods" UI.
+//
+// Capabilities (mirrors the issue's acceptance criteria):
+// 1. Historical data storage — reuses historicalDataService's
+// IndexedDB cache (already done).
+// 2. Timestamped pollutant readings — Open-Meteo hourly API (already
+// done); we aggregate to daily.
+// 3. Date-range selector — start/end date inputs + presets.
+// 4. City/location selector — multi-select from CITY_COORDINATES,
+// enabling single-city view + multi-
+// city comparison overlay.
+// 5. Pollutant selector — PM2.5, PM10, NO₂, Ozone, CO, AQI.
+// 6. Line charts, bar charts, moving averages
+// — recharts LineChart/BarChart with
+// a 7-day moving average overlay.
+// 7. Daily/weekly/monthly/yearly views — view toggle.
+// 8. Identify highest pollution periods
+// — top-5 worst buckets rendered as
+// a ranked list.
+// 9. Calculate percentage changes — half-range change between first
+// and second half of selected range.
+// 10. Export to CSV — reuses formatHistoricalCSV for
+// daily; buildExplorerCsv for
+// aggregated views.
+// 11. Comparison between locations — overlay line chart for up to 4
+// cities on the same axis.
+// -----------------------------------------------------------------------------
+
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
+ Tooltip, Legend, ResponsiveContainer, ReferenceLine,
+} from 'recharts';
+import { CITY_COORDINATES, SAFE_LIMITS } from '../constants/cities';
+import {
+ POLLUTANTS, VIEWS, getPollutantByKey, resampleToView,
+ computeMovingAverage, computePercentageChange,
+ computeHalfRangeChange, identifyHighestPeriods, buildExplorerCsv,
+} from '../utils/historicalExplorer';
+import { fetchHistoricalForLocations } from '../services/historicalExplorerService';
+import { formatHistoricalCSV } from '../services/historicalDataService';
+
+const CHART_COLORS = ['#ef4444', '#10b981', '#3b82f6', '#f59e0b'];
+
+const DATE_PRESETS = [
+ { id: '30d', label: 'Last 30 days', years: 0, daysBack: 30 },
+ { id: '3mo', label: 'Last 3 months', years: 0, daysBack: 90 },
+ { id: '1y', label: 'Last 1 year', years: 1, daysBack: 0 },
+ { id: '3y', label: 'Last 3 years', years: 3, daysBack: 0 },
+ { id: 'janAug', label: 'Jan – Aug (this year)', years: 0, daysBack: 0, janAug: true },
+];
+
+export default function HistoricalPollutionExplorer({ position }) {
+ const { t } = useTranslation();
+
+ const [selectedCityNames, setSelectedCityNames] = useState(() => {
+ const match = position?.cityName
+ ? CITY_COORDINATES.find((c) => c.name === position.cityName)
+ : null;
+ return match ? [match.name] : [CITY_COORDINATES[0].name];
+ });
+
+ const [pollutantKey, setPollutantKey] = useState('pm25');
+ const [view, setView] = useState('monthly');
+ const [years, setYears] = useState(3);
+ const [activePreset, setActivePreset] = useState('3y');
+
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [locationsData, setLocationsData] = useState([]);
+ const [chartType, setChartType] = useState('line');
+
+ const selectedCities = useMemo(
+ () => CITY_COORDINATES.filter((c) => selectedCityNames.includes(c.name)),
+ [selectedCityNames],
+ );
+
+ const load = useCallback(async () => {
+ if (selectedCities.length === 0) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const results = await fetchHistoricalForLocations(selectedCities, years);
+ setLocationsData(results);
+ const anyError = results.find((r) => r.error);
+ if (anyError && results.every((r) => r.error)) {
+ setError(anyError.error);
+ }
+ } catch (err) {
+ setError(err?.message || 'Failed to load historical data');
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedCities, years]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ useEffect(() => {
+ if (!startDate && !endDate && locationsData.length > 0) {
+ const firstDaily = locationsData[0]?.data?.daily;
+ if (firstDaily && firstDaily.length > 0) {
+ setStartDate(firstDaily[0].date);
+ setEndDate(firstDaily[firstDaily.length - 1].date);
+ }
+ }
+ }, [locationsData, startDate, endDate]);
+
+ const applyPreset = useCallback((presetId) => {
+ const preset = DATE_PRESETS.find((p) => p.id === presetId);
+ if (!preset) return;
+ setActivePreset(presetId);
+ if (preset.years) {
+ setYears(preset.years);
+ } else if (preset.janAug) {
+ const now = new Date();
+ const y = now.getUTCFullYear();
+ setStartDate(`${y}-01-01`);
+ setEndDate(`${y}-08-31`);
+ setYears(1);
+ } else if (preset.daysBack) {
+ const end = new Date();
+ const start = new Date();
+ start.setDate(end.getDate() - preset.daysBack);
+ setStartDate(start.toISOString().split('T')[0]);
+ setEndDate(end.toISOString().split('T')[0]);
+ setYears(1);
+ }
+ }, []);
+
+ const primary = locationsData[0];
+ const primaryDaily = useMemo(() => {
+ if (!primary?.data?.daily) return [];
+ let rows = primary.data.daily;
+ if (startDate) rows = rows.filter((r) => r.date >= startDate);
+ if (endDate) rows = rows.filter((r) => r.date <= endDate);
+ return rows;
+ }, [primary, startDate, endDate]);
+
+ const primaryResampled = useMemo(
+ () => resampleToView(primaryDaily, view),
+ [primaryDaily, view],
+ );
+
+ const pollutant = getPollutantByKey(pollutantKey);
+
+ const movingAverage = useMemo(
+ () => computeMovingAverage(primaryResampled, pollutant.out, 7),
+ [primaryResampled, pollutant.out],
+ );
+
+ const chartData = useMemo(() => {
+ return primaryResampled.map((row, i) => ({
+ ...row,
+ ma: movingAverage[i],
+ }));
+ }, [primaryResampled, movingAverage]);
+
+ const halfRangeChange = useMemo(
+ () => computeHalfRangeChange(primaryResampled, pollutant.out),
+ [primaryResampled, pollutant.out],
+ );
+
+ const highestPeriods = useMemo(
+ () => identifyHighestPeriods(primaryResampled, pollutant.out, 5),
+ [primaryResampled, pollutant.out],
+ );
+
+ const overallStats = useMemo(() => {
+ const values = primaryResampled
+ .map((r) => r[pollutant.out])
+ .filter((v) => typeof v === 'number' && Number.isFinite(v));
+ if (values.length === 0) {
+ return { mean: null, min: null, max: null };
+ }
+ const sum = values.reduce((a, b) => a + b, 0);
+ return {
+ mean: Math.round((sum / values.length) * 10) / 10,
+ min: Math.min(...values),
+ max: Math.max(...values),
+ };
+ }, [primaryResampled, pollutant.out]);
+
+ const compareData = useMemo(() => {
+ if (locationsData.length <= 1) return [];
+ const byLabel = new Map();
+ for (const result of locationsData) {
+ if (!result?.data?.daily) continue;
+ let rows = result.data.daily;
+ if (startDate) rows = rows.filter((r) => r.date >= startDate);
+ if (endDate) rows = rows.filter((r) => r.date <= endDate);
+ const resampled = resampleToView(rows, view);
+ for (const row of resampled) {
+ if (!byLabel.has(row.label)) {
+ byLabel.set(row.label, { label: row.label });
+ }
+ const entry = byLabel.get(row.label);
+ entry[result.location.name] = row[pollutant.out];
+ }
+ }
+ return Array.from(byLabel.values()).sort((a, b) =>
+ a.label.localeCompare(b.label),
+ );
+ }, [locationsData, view, pollutant.out, startDate, endDate]);
+
+ const handleExportCSV = useCallback(() => {
+ if (locationsData.length === 0) return;
+ let csv;
+ if (view === 'daily' && primary?.data?.daily) {
+ csv = formatHistoricalCSV(primary.data.daily, startDate, endDate);
+ } else {
+ csv = buildExplorerCsv(primaryResampled, pollutantKey, view);
+ }
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ const citySlug =
+ (primary?.location?.name || 'historical')
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '_');
+ link.download = `${citySlug}_${pollutantKey}_${view}_${startDate}_to_${endDate}.csv`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ }, [locationsData, view, primary, primaryResampled, pollutantKey, startDate, endDate]);
+
+ const toggleCity = useCallback((name) => {
+ setSelectedCityNames((prev) => {
+ if (prev.includes(name)) {
+ if (prev.length === 1) return prev;
+ return prev.filter((n) => n !== name);
+ }
+ if (prev.length >= 4) return prev;
+ return [...prev, name];
+ });
+ }, []);
+
+ if (loading) {
+ return (
+
+
+
{t('historicalExplorer.loading', 'Loading historical pollution data…')}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{t('historicalExplorer.error', 'Error: {{error}}', { error })}
+
+ );
+ }
+
+ return (
+
+
+
+ {t('historicalExplorer.title', 'Historical Pollution Explorer')}
+
+
+ {t('historicalExplorer.subtitle', 'Compare pollution across years, cities, and pollutants.')}
+
+
+
+ {/* ── Control row ───────────────────────────────────────────── */}
+
+
+
+
+
+ {selectedCityNames.map((name) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ── Date preset row ──────────────────────────────────────── */}
+
+ {DATE_PRESETS.map((p) => (
+
+ ))}
+
+
+ {/* ── Date range inputs ────────────────────────────────────── */}
+
+
+
+ { setStartDate(e.target.value); setActivePreset(''); }}
+ data-testid="explorer-start-date"
+ style={inputStyle}
+ />
+
+
+
+ { setEndDate(e.target.value); setActivePreset(''); }}
+ data-testid="explorer-end-date"
+ style={inputStyle}
+ />
+
+
+
+
+ {/* ── Stats strip ──────────────────────────────────────────── */}
+
+
+
+
+ 0 ? '+' : ''}${halfRangeChange}%`}
+ tone={halfRangeChange == null ? 'neutral' : halfRangeChange > 0 ? 'bad' : 'good'}
+ />
+
+
+ {/* ── Main chart ───────────────────────────────────────────── */}
+
+ {t('historicalExplorer.chartTitle', '{{pollutant}} — {{view}} view', {
+ pollutant: pollutant.label,
+ view,
+ })}
+
+
+
+ {chartType === 'line' ? (
+
+
+
+
+
+
+
+ {view === 'daily' && (
+
+ )}
+ {pollutant.safeLimit != null && (
+
+ )}
+
+ ) : (
+
+
+
+
+
+
+ {pollutant.safeLimit != null && (
+
+ )}
+
+ )}
+
+
+
+ {/* ── Comparison chart (only when >1 city) ────────────────── */}
+ {selectedCities.length > 1 && compareData.length > 0 && (
+ <>
+
+ {t('historicalExplorer.compareTitle', 'Multi-city comparison — {{pollutant}}', {
+ pollutant: pollutant.label,
+ })}
+
+
+
+
+
+
+
+
+
+ {selectedCities.map((city, i) => (
+
+ ))}
+
+
+
+ >
+ )}
+
+ {/* ── Highest periods ─────────────────────────────────────── */}
+ {highestPeriods.length > 0 && (
+
+
+ {t('historicalExplorer.highestPeriods', 'Highest {{pollutant}} periods', {
+ pollutant: pollutant.label,
+ })}
+
+
+ {highestPeriods.map((p) => (
+ -
+ {p.label} — {p.value} {pollutant.unit}
+
+ ({p.days} {p.days === 1 ? 'day' : 'days'})
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+const inputStyle = {
+ padding: '0.5rem',
+ borderRadius: '6px',
+ border: '1px solid var(--line, #e2e8f0)',
+ background: 'var(--card, #fff)',
+ color: 'var(--ink, #0f172a)',
+ fontSize: '0.9rem',
+ fontFamily: 'inherit',
+ outline: 'none',
+};
+
+const sectionHeaderStyle = {
+ fontSize: '1.1rem',
+ fontWeight: 500,
+ margin: '0 0 1rem 0',
+ color: 'var(--ink, #0f172a)',
+};
+
+function StatBox({ label, value, tone = 'neutral' }) {
+ const toneColor =
+ tone === 'bad' ? '#ef4444' : tone === 'good' ? '#10b981' : 'inherit';
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
diff --git a/src/components/HistoricalPollutionExplorer.test.jsx b/src/components/HistoricalPollutionExplorer.test.jsx
new file mode 100644
index 0000000..730a91f
--- /dev/null
+++ b/src/components/HistoricalPollutionExplorer.test.jsx
@@ -0,0 +1,242 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import HistoricalPollutionExplorer from './HistoricalPollutionExplorer';
+
+vi.mock('../services/historicalExplorerService', () => ({
+ fetchHistoricalForLocations: vi.fn(),
+}));
+
+vi.mock('recharts', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ResponsiveContainer: ({ children }) => (
+
{children}
+ ),
+ };
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key, fallbackOrTemplate) => {
+ if (typeof fallbackOrTemplate === 'string') return fallbackOrTemplate;
+ if (typeof fallbackOrTemplate === 'function') return fallbackOrTemplate();
+ return key;
+ },
+ }),
+}));
+
+import { fetchHistoricalForLocations } from '../services/historicalExplorerService';
+
+function makeDailyData(startDateStr, n) {
+ const start = new Date(startDateStr + 'T00:00:00Z');
+ const daily = [];
+ for (let i = 0; i < n; i++) {
+ const d = new Date(start);
+ d.setUTCDate(start.getUTCDate() + i);
+ const date = d.toISOString().split('T')[0];
+ daily.push({
+ date, avgAqi: 50 + (i % 50), maxAqi: 60 + (i % 50),
+ pm25: 20 + (i % 20), pm10: 40 + (i % 40), no2: 15 + (i % 15),
+ ozone: 30 + (i % 30), co: 500 + (i % 200),
+ hasReading: true, hoursMeasured: 24,
+ });
+ }
+ return { daily, monthly: [], overallAvg: 60, daysInRange: n, daysWithReadings: n };
+}
+
+describe('HistoricalPollutionExplorer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders a loading state initially', async () => {
+ fetchHistoricalForLocations.mockReturnValueOnce(new Promise(() => {}));
+ render(
);
+ expect(screen.getByTestId('historical-explorer-loading')).toBeTruthy();
+ });
+
+ it('renders the controls + main chart after data loads', async () => {
+ fetchHistoricalForLocations.mockResolvedValueOnce([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: makeDailyData('2024-01-01', 90),
+ error: null,
+ },
+ ]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('historical-explorer')).toBeTruthy();
+ });
+
+ expect(screen.getByTestId('explorer-cities')).toBeTruthy();
+ expect(screen.getByTestId('explorer-pollutant')).toBeTruthy();
+ expect(screen.getByTestId('explorer-view')).toBeTruthy();
+ expect(screen.getByTestId('explorer-chart-type')).toBeTruthy();
+ expect(screen.getByTestId('explorer-main-chart')).toBeTruthy();
+ expect(screen.getByTestId('explorer-stats')).toBeTruthy();
+ });
+
+ it('renders an error state when the fetch fails', async () => {
+ fetchHistoricalForLocations.mockResolvedValueOnce([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: null,
+ error: 'Network down',
+ },
+ ]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('historical-explorer-error')).toBeTruthy();
+ });
+ expect(screen.getByTestId('historical-explorer-error').textContent).toContain('Network down');
+ });
+
+ it('renders the comparison chart when more than one city is selected', async () => {
+ fetchHistoricalForLocations.mockResolvedValue([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: makeDailyData('2024-01-01', 60),
+ error: null,
+ },
+ {
+ location: { name: 'Delhi', lat: 28.6139, lon: 77.209 },
+ data: makeDailyData('2024-01-01', 60),
+ error: null,
+ },
+ ]);
+
+ const { rerender } = render(
+
,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('historical-explorer')).toBeTruthy();
+ });
+
+ const citiesSelect = screen.getByTestId('explorer-cities');
+ fireEvent.change(citiesSelect, { target: { value: ['Pune', 'Delhi'] } });
+
+ await waitFor(() => {
+ expect(fetchHistoricalForLocations).toHaveBeenCalledTimes(2);
+ });
+
+ rerender(
+
,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('explorer-compare-chart')).toBeTruthy();
+ });
+ });
+
+ it('calls export on CSV button click', async () => {
+ const mockData = makeDailyData('2024-01-01', 60);
+ fetchHistoricalForLocations.mockResolvedValueOnce([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: mockData,
+ error: null,
+ },
+ ]);
+
+ global.URL.createObjectURL = vi.fn(() => 'blob:mock');
+ global.URL.revokeObjectURL = vi.fn();
+ const clickSpy = vi.fn();
+ HTMLAnchorElement.prototype.click = clickSpy;
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('explorer-export-csv')).toBeTruthy();
+ });
+
+ fireEvent.click(screen.getByTestId('explorer-export-csv'));
+ expect(global.URL.createObjectURL).toHaveBeenCalled();
+ expect(clickSpy).toHaveBeenCalled();
+ });
+
+ it('changes the pollutant and updates the chart label', async () => {
+ fetchHistoricalForLocations.mockResolvedValue([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: makeDailyData('2024-01-01', 60),
+ error: null,
+ },
+ ]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('historical-explorer')).toBeTruthy();
+ });
+
+ expect(screen.getByTestId('explorer-main-chart').textContent).toContain('PM2.5');
+
+ fireEvent.change(screen.getByTestId('explorer-pollutant'), { target: { value: 'no2' } });
+
+ expect(screen.getByTestId('explorer-main-chart').textContent).toContain('NO');
+ });
+
+ it('changes the view granularity and re-renders the chart', async () => {
+ fetchHistoricalForLocations.mockResolvedValue([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: makeDailyData('2024-01-01', 180),
+ error: null,
+ },
+ ]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('historical-explorer')).toBeTruthy();
+ });
+
+ expect(screen.getByTestId('explorer-main-chart').textContent).toContain('monthly');
+
+ fireEvent.change(screen.getByTestId('explorer-view'), { target: { value: 'yearly' } });
+ expect(screen.getByTestId('explorer-main-chart').textContent).toContain('yearly');
+ });
+
+ it('renders the highest-pollution periods list when data is present', async () => {
+ fetchHistoricalForLocations.mockResolvedValueOnce([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: makeDailyData('2024-01-01', 180),
+ error: null,
+ },
+ ]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('historical-explorer')).toBeTruthy();
+ });
+
+ expect(screen.getByTestId('explorer-highest-periods')).toBeTruthy();
+ const items = screen.getByTestId('explorer-highest-periods').querySelectorAll('li');
+ expect(items.length).toBeGreaterThan(0);
+ });
+
+ it('renders a chip for the selected city', async () => {
+ fetchHistoricalForLocations.mockResolvedValueOnce([
+ {
+ location: { name: 'Pune', lat: 18.5204, lon: 73.8567 },
+ data: makeDailyData('2024-01-01', 30),
+ error: null,
+ },
+ ]);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('city-chip-Pune')).toBeTruthy();
+ });
+ });
+});
diff --git a/src/services/historicalExplorerService.js b/src/services/historicalExplorerService.js
new file mode 100644
index 0000000..ec02031
--- /dev/null
+++ b/src/services/historicalExplorerService.js
@@ -0,0 +1,39 @@
+// src/services/historicalExplorerService.js
+// -----------------------------------------------------------------------------
+// Issue #892 — Historical Pollution Explorer
+//
+// Thin wrapper around `historicalDataService.fetchHistoricalData` and
+// `historicalAggregate.aggregateHourlyToDaily` that adds:
+//
+// - fetchHistoricalDaily(lat, lon, years) — one-shot fetch + aggregate
+// - fetchHistoricalForLocations(locations, years) — parallel fetch +
+// aggregate for multiple cities, used by the comparison view.
+// -----------------------------------------------------------------------------
+
+import { fetchHistoricalData } from './historicalDataService';
+import { aggregateHourlyToDaily } from '../utils/historicalAggregate';
+
+export async function fetchHistoricalDaily(lat, lon, years = 1) {
+ const raw = await fetchHistoricalData(lat, lon, years);
+ return aggregateHourlyToDaily(raw);
+}
+
+export async function fetchHistoricalForLocations(locations, years = 1) {
+ if (!Array.isArray(locations) || locations.length === 0) return [];
+
+ const settled = await Promise.allSettled(
+ locations.map((loc) => fetchHistoricalDaily(loc.lat, loc.lon, years)),
+ );
+
+ return settled.map((res, i) => {
+ const location = locations[i];
+ if (res.status === 'fulfilled') {
+ return { location, data: res.value, error: null };
+ }
+ const msg =
+ res.reason instanceof Error
+ ? res.reason.message
+ : 'Failed to fetch historical data';
+ return { location, data: null, error: msg };
+ });
+}
diff --git a/src/utils/historicalExplorer.js b/src/utils/historicalExplorer.js
new file mode 100644
index 0000000..f46adf0
--- /dev/null
+++ b/src/utils/historicalExplorer.js
@@ -0,0 +1,201 @@
+// src/utils/historicalExplorer.js
+// -----------------------------------------------------------------------------
+// Issue #892 — Historical Pollution Explorer
+//
+// Pure helpers that turn daily aggregated pollution data into the
+// shapes the Explorer UI needs:
+// - resampleToView() — daily → weekly / monthly / yearly rolls
+// - computeMovingAverage() — N-day sliding window over a series
+// - computePercentageChange() — period-over-period delta %
+// - identifyHighestPeriods() — top-N worst intervals
+// - buildExplorerCsv() — CSV export with the user's selected
+// pollutant + view granularity
+//
+// All functions are pure (no React, no fetch, no DOM) so they can be
+// unit-tested in isolation, mirroring the pattern in
+// `src/utils/historicalAggregate.js`.
+// -----------------------------------------------------------------------------
+
+export const POLLUTANTS = [
+ { key: 'aqi', label: 'AQI', unit: '', in: 'us_aqi', out: 'avgAqi', safeLimit: null },
+ { key: 'pm25', label: 'PM2.5', unit: 'µg/m³', in: 'pm2_5', out: 'pm25', safeLimit: 15 },
+ { key: 'pm10', label: 'PM10', unit: 'µg/m³', in: 'pm10', out: 'pm10', safeLimit: 45 },
+ { key: 'no2', label: 'NO₂', unit: 'µg/m³', in: 'nitrogen_dioxide', out: 'no2', safeLimit: 25 },
+ { key: 'ozone', label: 'Ozone', unit: 'µg/m³', in: 'ozone', out: 'ozone', safeLimit: 100 },
+ { key: 'co', label: 'CO', unit: 'µg/m³', in: 'carbon_monoxide', out: 'co', safeLimit: 4000 },
+];
+
+export function getPollutantByKey(key) {
+ return POLLUTANTS.find((p) => p.key === key) ?? POLLUTANTS[0];
+}
+
+export const VIEWS = ['daily', 'weekly', 'monthly', 'yearly'];
+
+function isReading(value) {
+ return typeof value === 'number' && Number.isFinite(value);
+}
+
+function mean(sum, count, decimals = 1) {
+ if (count <= 0) return null;
+ const factor = 10 ** decimals;
+ return Math.round((sum / count) * factor) / factor;
+}
+
+function isoWeekKey(dateStr) {
+ const d = new Date(dateStr + 'T00:00:00Z');
+ if (Number.isNaN(d.getTime())) return null;
+ const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
+ const dayNum = tmp.getUTCDay() || 7;
+ tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
+ const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
+ const weekNum = Math.ceil(((tmp - yearStart) / 86400000 + 1) / 7);
+ return `${tmp.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
+}
+
+export function resampleToView(dailyRows, view = 'daily') {
+ if (!Array.isArray(dailyRows) || dailyRows.length === 0) return [];
+ if (!VIEWS.includes(view)) {
+ throw new Error(`Unknown view: ${view}. Must be one of ${VIEWS.join(', ')}`);
+ }
+
+ if (view === 'daily') {
+ return dailyRows.map((row) => ({ ...row, label: row.date, start: row.date }));
+ }
+
+ const buckets = new Map();
+
+ for (const row of dailyRows) {
+ if (!row || !row.date) continue;
+ let key, label, start;
+ if (view === 'weekly') {
+ key = isoWeekKey(row.date);
+ if (!key) continue;
+ label = key;
+ const [yStr, wStr] = key.split('-W');
+ const year = Number(yStr);
+ const week = Number(wStr);
+ const jan4 = new Date(Date.UTC(year, 0, 4));
+ const jan4Day = jan4.getUTCDay() || 7;
+ const week1Monday = new Date(jan4);
+ week1Monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1));
+ const monday = new Date(week1Monday);
+ monday.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7);
+ start = monday.toISOString().split('T')[0];
+ } else if (view === 'monthly') {
+ key = row.date.substring(0, 7);
+ label = key;
+ start = `${key}-01`;
+ } else {
+ key = row.date.substring(0, 4);
+ label = key;
+ start = `${key}-01-01`;
+ }
+
+ if (!buckets.has(key)) {
+ const stats = { label, start, days: 0 };
+ for (const p of POLLUTANTS) {
+ stats[p.out] = { sum: 0, count: 0, max: -Infinity };
+ }
+ buckets.set(key, stats);
+ }
+ const bucket = buckets.get(key);
+ bucket.days += 1;
+ for (const p of POLLUTANTS) {
+ const v = row[p.out];
+ if (isReading(v)) {
+ bucket[p.out].sum += v;
+ bucket[p.out].count += 1;
+ if (v > bucket[p.out].max) bucket[p.out].max = v;
+ }
+ }
+ }
+
+ const out = [];
+ for (const stats of buckets.values()) {
+ const row = { label: stats.label, start: stats.start, days: stats.days };
+ for (const p of POLLUTANTS) {
+ const { sum, count, max } = stats[p.out];
+ row[p.out] = mean(sum, count, 1);
+ row[`${p.out}_max`] = count > 0 ? Math.round(max) : null;
+ }
+ out.push(row);
+ }
+ out.sort((a, b) => a.start.localeCompare(b.start));
+ return out;
+}
+
+export function computeMovingAverage(rows, field, window = 7) {
+ if (!Array.isArray(rows) || rows.length === 0) return [];
+ if (typeof window !== 'number' || window < 1) {
+ throw new Error('window must be a positive integer');
+ }
+ const out = new Array(rows.length).fill(null);
+ if (window > rows.length) return out;
+ let sum = 0;
+ let count = 0;
+ for (let i = 0; i < rows.length; i++) {
+ const incoming = rows[i]?.[field];
+ if (isReading(incoming)) {
+ sum += incoming;
+ count += 1;
+ }
+ if (i >= window) {
+ const outgoing = rows[i - window]?.[field];
+ if (isReading(outgoing)) {
+ sum -= outgoing;
+ count -= 1;
+ }
+ }
+ if (i >= window - 1 && count > 0) {
+ out[i] = Math.round((sum / count) * 10) / 10;
+ }
+ }
+ return out;
+}
+
+export function computePercentageChange(oldValue, newValue) {
+ if (!isReading(oldValue) || !isReading(newValue)) return null;
+ if (oldValue === 0) return null;
+ const pct = ((newValue - oldValue) / Math.abs(oldValue)) * 100;
+ return Math.round(pct * 10) / 10;
+}
+
+export function computeHalfRangeChange(rows, field) {
+ if (!Array.isArray(rows) || rows.length < 2) return null;
+ const mid = Math.floor(rows.length / 2);
+ const firstHalf = rows.slice(0, mid).map((r) => r?.[field]).filter(isReading);
+ const secondHalf = rows.slice(mid).map((r) => r?.[field]).filter(isReading);
+ if (firstHalf.length === 0 || secondHalf.length === 0) return null;
+ const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
+ const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
+ return computePercentageChange(firstMean, secondMean);
+}
+
+export function identifyHighestPeriods(rows, field, topN = 5) {
+ if (!Array.isArray(rows) || rows.length === 0) return [];
+ const withValue = rows
+ .filter((r) => isReading(r?.[field]))
+ .map((r) => ({
+ label: r.label,
+ start: r.start,
+ days: r.days ?? 1,
+ value: r[field],
+ }));
+ withValue.sort((a, b) => {
+ if (b.value !== a.value) return b.value - a.value;
+ return b.days - a.days;
+ });
+ return withValue.slice(0, topN);
+}
+
+export function buildExplorerCsv(rows, pollutantKey, view) {
+ const p = getPollutantByKey(pollutantKey);
+ const header = ['Period', 'Start', 'Days', `${p.label} Mean`, `${p.label} Max`];
+ if (!Array.isArray(rows) || rows.length === 0) return header.join(',');
+ const lines = rows.map((r) => {
+ const meanVal = r[p.out] ?? '';
+ const maxVal = r[`${p.out}_max`] ?? '';
+ return [r.label, r.start, r.days ?? '', meanVal, maxVal].join(',');
+ });
+ return [header.join(','), ...lines].join('\n');
+}
diff --git a/src/utils/historicalExplorer.test.js b/src/utils/historicalExplorer.test.js
new file mode 100644
index 0000000..604b95b
--- /dev/null
+++ b/src/utils/historicalExplorer.test.js
@@ -0,0 +1,287 @@
+import { describe, it, expect } from 'vitest';
+import {
+ POLLUTANTS, VIEWS, getPollutantByKey,
+ resampleToView, computeMovingAverage, computePercentageChange,
+ computeHalfRangeChange, identifyHighestPeriods, buildExplorerCsv,
+} from './historicalExplorer';
+
+function makeDailyRow(date, overrides = {}) {
+ return {
+ date, avgAqi: 50, maxAqi: 60, pm25: 20, pm10: 40,
+ no2: 15, ozone: 30, co: 500, hasReading: true, hoursMeasured: 24,
+ ...overrides,
+ };
+}
+
+function makeDays(startDateStr, n) {
+ const start = new Date(startDateStr + 'T00:00:00Z');
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const d = new Date(start);
+ d.setUTCDate(start.getUTCDate() + i);
+ out.push(makeDailyRow(d.toISOString().split('T')[0]));
+ }
+ return out;
+}
+
+describe('POLLUTANTS / VIEWS / getPollutantByKey', () => {
+ it('exposes the expected pollutant set', () => {
+ const keys = POLLUTANTS.map((p) => p.key);
+ expect(keys).toEqual(['aqi', 'pm25', 'pm10', 'no2', 'ozone', 'co']);
+ });
+
+ it('exposes the four view granularities', () => {
+ expect(VIEWS).toEqual(['daily', 'weekly', 'monthly', 'yearly']);
+ });
+
+ it('looks up a pollutant by key', () => {
+ expect(getPollutantByKey('pm25').label).toBe('PM2.5');
+ expect(getPollutantByKey('no2').unit).toBe('µg/m³');
+ });
+
+ it('falls back to AQI for an unknown key', () => {
+ expect(getPollutantByKey('not-a-real-key').key).toBe('aqi');
+ });
+});
+
+describe('resampleToView', () => {
+ it('returns an empty array for empty input', () => {
+ expect(resampleToView([], 'daily')).toEqual([]);
+ expect(resampleToView([], 'monthly')).toEqual([]);
+ });
+
+ it('throws on an unknown view', () => {
+ expect(() => resampleToView([{ date: '2024-01-01' }], 'hourly')).toThrow();
+ });
+
+ it('daily view is a pass-through with label/start injected', () => {
+ const rows = [makeDailyRow('2024-01-01'), makeDailyRow('2024-01-02')];
+ const out = resampleToView(rows, 'daily');
+ expect(out).toHaveLength(2);
+ expect(out[0].label).toBe('2024-01-01');
+ expect(out[0].start).toBe('2024-01-01');
+ expect(out[0].pm25).toBe(20);
+ });
+
+ it('monthly view buckets by YYYY-MM', () => {
+ const rows = makeDays('2024-01-01', 60);
+ const out = resampleToView(rows, 'monthly');
+ expect(out).toHaveLength(2);
+ expect(out[0].label).toBe('2024-01');
+ expect(out[0].start).toBe('2024-01-01');
+ expect(out[1].label).toBe('2024-02');
+ expect(out[1].start).toBe('2024-02-01');
+ expect(out[0].days).toBe(31);
+ expect(out[1].days).toBe(29);
+ expect(out[0].pm25).toBe(20);
+ expect(out[0].pm25_max).toBe(20);
+ });
+
+ it('yearly view buckets by YYYY', () => {
+ const rows = makeDays('2023-06-01', 365 + 180);
+ const out = resampleToView(rows, 'yearly');
+ expect(out).toHaveLength(2);
+ expect(out[0].label).toBe('2023');
+ expect(out[1].label).toBe('2024');
+ expect(out[0].days + out[1].days).toBe(365 + 180);
+ });
+
+ it('weekly view produces ISO week keys', () => {
+ const rows = makeDays('2024-01-01', 14);
+ const out = resampleToView(rows, 'weekly');
+ expect(out).toHaveLength(2);
+ expect(out[0].label).toBe('2024-W01');
+ expect(out[1].label).toBe('2024-W02');
+ expect(out[0].days).toBe(7);
+ });
+
+ it('ignores null readings in the mean', () => {
+ const rows = [
+ makeDailyRow('2024-01-01', { pm25: 10 }),
+ makeDailyRow('2024-01-02', { pm25: null }),
+ makeDailyRow('2024-01-03', { pm25: 30 }),
+ ];
+ const out = resampleToView(rows, 'monthly');
+ expect(out[0].pm25).toBe(20);
+ });
+
+ it('sorts output ascending by start date regardless of input order', () => {
+ const rows = [
+ makeDailyRow('2024-03-01'),
+ makeDailyRow('2024-01-01'),
+ makeDailyRow('2024-02-01'),
+ ];
+ const out = resampleToView(rows, 'monthly');
+ expect(out.map((r) => r.label)).toEqual(['2024-01', '2024-02', '2024-03']);
+ });
+});
+
+describe('computeMovingAverage', () => {
+ it('returns an empty array for empty input', () => {
+ expect(computeMovingAverage([], 'pm25', 7)).toEqual([]);
+ });
+
+ it('throws on a non-positive window', () => {
+ expect(() => computeMovingAverage([{ pm25: 1 }], 'pm25', 0)).toThrow();
+ expect(() => computeMovingAverage([{ pm25: 1 }], 'pm25', -3)).toThrow();
+ });
+
+ it('emits null for the first window-1 points', () => {
+ const rows = makeDays('2024-01-01', 10).map((r, i) => ({ ...r, pm25: 10 + i }));
+ const ma = computeMovingAverage(rows, 'pm25', 7);
+ expect(ma.slice(0, 6)).toEqual([null, null, null, null, null, null]);
+ expect(ma[6]).not.toBeNull();
+ });
+
+ it('returns a smaller-than-window series of all nulls', () => {
+ const rows = makeDays('2024-01-01', 3);
+ const ma = computeMovingAverage(rows, 'pm25', 7);
+ expect(ma).toEqual([null, null, null]);
+ });
+
+ it('computes the correct mean over a sliding window', () => {
+ const rows = [{ pm25: 10 }, { pm25: 20 }, { pm25: 30 }, { pm25: 40 }];
+ const ma = computeMovingAverage(rows, 'pm25', 2);
+ expect(ma).toEqual([null, 15, 25, 35]);
+ });
+
+ it('skips null values without shrinking the window count', () => {
+ const rows = [{ pm25: 10 }, { pm25: null }, { pm25: 30 }, { pm25: 40 }];
+ const ma = computeMovingAverage(rows, 'pm25', 3);
+ expect(ma[2]).toBe(20);
+ });
+});
+
+describe('computePercentageChange', () => {
+ it('returns null for null or undefined inputs', () => {
+ expect(computePercentageChange(null, 10)).toBeNull();
+ expect(computePercentageChange(10, null)).toBeNull();
+ expect(computePercentageChange(undefined, 10)).toBeNull();
+ });
+
+ it('returns null for a non-finite input', () => {
+ expect(computePercentageChange(NaN, 10)).toBeNull();
+ expect(computePercentageChange(10, Infinity)).toBeNull();
+ });
+
+ it('returns null when the old value is 0', () => {
+ expect(computePercentageChange(0, 10)).toBeNull();
+ });
+
+ it('returns +X% for a positive change', () => {
+ expect(computePercentageChange(100, 120)).toBe(20);
+ });
+
+ it('returns -X% for a negative change', () => {
+ expect(computePercentageChange(120, 100)).toBe(-16.7);
+ });
+
+ it('returns 0 for no change', () => {
+ expect(computePercentageChange(50, 50)).toBe(0);
+ });
+
+ it('handles a negative old value correctly', () => {
+ expect(computePercentageChange(-20, -10)).toBe(50);
+ });
+});
+
+describe('computeHalfRangeChange', () => {
+ it('returns null for an empty series', () => {
+ expect(computeHalfRangeChange([], 'pm25')).toBeNull();
+ });
+
+ it('returns null for a single-row series', () => {
+ expect(computeHalfRangeChange([{ pm25: 10 }], 'pm25')).toBeNull();
+ });
+
+ it('returns null when one half has no readings', () => {
+ const rows = [{ pm25: 10 }, { pm25: 20 }, { pm25: null }, { pm25: null }];
+ expect(computeHalfRangeChange(rows, 'pm25')).toBeNull();
+ });
+
+ it('returns the percentage change between halves', () => {
+ const rows = [{ pm25: 10 }, { pm25: 20 }, { pm25: 30 }, { pm25: 30 }];
+ expect(computeHalfRangeChange(rows, 'pm25')).toBe(100);
+ });
+});
+
+describe('identifyHighestPeriods', () => {
+ it('returns an empty array for empty input', () => {
+ expect(identifyHighestPeriods([], 'pm25', 5)).toEqual([]);
+ });
+
+ it('returns the top-N highest by value', () => {
+ const rows = [
+ { label: 'A', start: '2024-01-01', days: 30, pm25: 10 },
+ { label: 'B', start: '2024-02-01', days: 28, pm25: 80 },
+ { label: 'C', start: '2024-03-01', days: 31, pm25: 50 },
+ { label: 'D', start: '2024-04-01', days: 30, pm25: 90 },
+ { label: 'E', start: '2024-05-01', days: 31, pm25: 60 },
+ ];
+ const out = identifyHighestPeriods(rows, 'pm25', 3);
+ expect(out).toHaveLength(3);
+ expect(out[0].label).toBe('D');
+ expect(out[1].label).toBe('B');
+ expect(out[2].label).toBe('E');
+ });
+
+ it('breaks ties by days (sustained > spike)', () => {
+ const rows = [
+ { label: 'A', start: '2024-01-01', days: 7, pm25: 50 },
+ { label: 'B', start: '2024-02-01', days: 30, pm25: 50 },
+ ];
+ const out = identifyHighestPeriods(rows, 'pm25', 2);
+ expect(out[0].label).toBe('B');
+ });
+
+ it('filters out rows with null values for the field', () => {
+ const rows = [
+ { label: 'A', start: '2024-01-01', days: 30, pm25: 10 },
+ { label: 'B', start: '2024-02-01', days: 28, pm25: null },
+ { label: 'C', start: '2024-03-01', days: 31, pm25: 40 },
+ ];
+ const out = identifyHighestPeriods(rows, 'pm25', 5);
+ expect(out).toHaveLength(2);
+ expect(out.map((r) => r.label)).toEqual(['C', 'A']);
+ });
+
+ it('respects the topN cap', () => {
+ const rows = Array.from({ length: 20 }, (_, i) => ({
+ label: `L${i}`, start: `2024-0${(i % 9) + 1}-01`, days: 30, pm25: i,
+ }));
+ expect(identifyHighestPeriods(rows, 'pm25', 5)).toHaveLength(5);
+ });
+});
+
+describe('buildExplorerCsv', () => {
+ it('returns a header-only CSV for empty input', () => {
+ const csv = buildExplorerCsv([], 'pm25', 'monthly');
+ expect(csv).toBe('Period,Start,Days,PM2.5 Mean,PM2.5 Max');
+ });
+
+ it('renders one row per input entry', () => {
+ const rows = [
+ { label: '2024-01', start: '2024-01-01', days: 31, pm25: 22.5, pm25_max: 60 },
+ { label: '2024-02', start: '2024-02-01', days: 29, pm25: 18.2, pm25_max: 45 },
+ ];
+ const csv = buildExplorerCsv(rows, 'pm25', 'monthly');
+ const lines = csv.split('\n');
+ expect(lines).toHaveLength(3);
+ expect(lines[0]).toBe('Period,Start,Days,PM2.5 Mean,PM2.5 Max');
+ expect(lines[1]).toBe('2024-01,2024-01-01,31,22.5,60');
+ expect(lines[2]).toBe('2024-02,2024-02-01,29,18.2,45');
+ });
+
+ it('adapts the header to the selected pollutant', () => {
+ const rows = [{ label: '2024', start: '2024-01-01', days: 365, avgAqi: 80, avgAqi_max: 200 }];
+ const csv = buildExplorerCsv(rows, 'aqi', 'yearly');
+ expect(csv.split('\n')[0]).toBe('Period,Start,Days,AQI Mean,AQI Max');
+ });
+
+ it('emits empty strings for null values', () => {
+ const rows = [{ label: '2024-01', start: '2024-01-01', days: 31, pm25: null, pm25_max: null }];
+ const csv = buildExplorerCsv(rows, 'pm25', 'monthly');
+ const lines = csv.split('\n');
+ expect(lines[1]).toBe('2024-01,2024-01-01,31,,');
+ });
+});