diff --git a/src/components/ChallengesWidget.jsx b/src/components/ChallengesWidget.jsx
new file mode 100644
index 0000000..0c3230f
--- /dev/null
+++ b/src/components/ChallengesWidget.jsx
@@ -0,0 +1,147 @@
+import React, { useState, useEffect } from 'react';
+
+const CHALLENGES = [
+ "Use public transport today",
+ "Check AQI before your walk",
+ "Carry a reusable water bottle",
+ "Switch off unused lights",
+ "Walk or cycle for a short trip",
+ "Plant or water a tree",
+ "Avoid single-use plastic today"
+];
+
+const LOCAL_STORAGE_KEY = "pollution_hub_daily_challenge";
+const POINTS_KEY = "pollution_hub_total_points";
+
+export default function ChallengesWidget() {
+ const [challenge, setChallenge] = useState("");
+ const [completed, setCompleted] = useState(false);
+ const [points, setPoints] = useState(0);
+
+ useEffect(() => {
+ // Load points
+ try {
+ const storedPoints = localStorage.getItem(POINTS_KEY);
+ if (storedPoints) {
+ setPoints(parseInt(storedPoints, 10) || 0);
+ }
+ } catch (e) {
+ console.warn("Failed to read points from localStorage", e);
+ }
+
+ // Load challenge
+ const today = new Date().toDateString();
+ let currentData = null;
+
+ try {
+ const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
+ if (raw) {
+ currentData = JSON.parse(raw);
+ }
+ } catch (e) {
+ console.warn("Failed to read challenge from localStorage", e);
+ }
+
+ if (currentData && currentData.assignedDate === today) {
+ setChallenge(currentData.currentChallenge);
+ setCompleted(currentData.completed || false);
+ } else {
+ // Assign new challenge
+ const randomChallenge = CHALLENGES[Math.floor(Math.random() * CHALLENGES.length)];
+ setChallenge(randomChallenge);
+ setCompleted(false);
+
+ const newData = {
+ currentChallenge: randomChallenge,
+ assignedDate: today,
+ completed: false
+ };
+
+ try {
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newData));
+ } catch (e) {
+ console.warn("Failed to write challenge to localStorage", e);
+ }
+ }
+ }, []);
+
+ const handleMarkComplete = () => {
+ if (completed) return;
+
+ setCompleted(true);
+
+ // Update points
+ const newPoints = points + 10;
+ setPoints(newPoints);
+
+ // Update local storage
+ try {
+ localStorage.setItem(POINTS_KEY, newPoints.toString());
+
+ const today = new Date().toDateString();
+ const updatedData = {
+ currentChallenge: challenge,
+ assignedDate: today,
+ completed: true
+ };
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedData));
+ } catch (e) {
+ console.warn("Failed to update challenge status in localStorage", e);
+ }
+ };
+
+ return (
+
+ 🌱 Daily Challenge
+
+
+
+ {challenge || "Loading challenge..."}
+
+
+
+
+ {completed ? (
+
+ ✔ Challenge Completed
+
+ ) : (
+
+ )}
+
+
+ Points: {points}
+
+
+
+ );
+}
diff --git a/src/components/ChallengesWidget.test.jsx b/src/components/ChallengesWidget.test.jsx
new file mode 100644
index 0000000..4d4fac5
--- /dev/null
+++ b/src/components/ChallengesWidget.test.jsx
@@ -0,0 +1,71 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import ChallengesWidget from './ChallengesWidget';
+
+describe('ChallengesWidget', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('renders a challenge and points', () => {
+ render();
+
+ expect(screen.getByText('🌱 Daily Challenge')).toBeInTheDocument();
+
+ const pointsEl = screen.getByTestId('challenge-points');
+ expect(pointsEl).toHaveTextContent('0');
+
+ const markCompleteBtn = screen.getByRole('button', { name: /mark complete/i });
+ expect(markCompleteBtn).toBeInTheDocument();
+ });
+
+ it('completing a challenge updates UI and points', () => {
+ render();
+
+ const markCompleteBtn = screen.getByRole('button', { name: /mark complete/i });
+ fireEvent.click(markCompleteBtn);
+
+ expect(screen.getByText('✔ Challenge Completed')).toBeInTheDocument();
+
+ const pointsEl = screen.getByTestId('challenge-points');
+ expect(pointsEl).toHaveTextContent('10');
+ });
+
+ it('preserves challenge state across rerenders for the same day', () => {
+ // Initial render and complete
+ const { unmount } = render();
+ const markCompleteBtn = screen.getByRole('button', { name: /mark complete/i });
+ fireEvent.click(markCompleteBtn);
+ unmount();
+
+ // Re-render, should still be completed and have 10 points
+ render();
+ expect(screen.getByText('✔ Challenge Completed')).toBeInTheDocument();
+ expect(screen.getByTestId('challenge-points')).toHaveTextContent('10');
+ });
+
+ it('assigns a new challenge and resets completion on a new day', () => {
+ // Simulate completing a challenge yesterday
+ const yesterday = new Date(Date.now() - 86400000).toDateString();
+ localStorage.setItem("pollution_hub_daily_challenge", JSON.stringify({
+ currentChallenge: "Test challenge",
+ assignedDate: yesterday,
+ completed: true
+ }));
+ localStorage.setItem("pollution_hub_total_points", "50");
+
+ render();
+
+ // Should reset completion status for the new day
+ const markCompleteBtn = screen.getByRole('button', { name: /mark complete/i });
+ expect(markCompleteBtn).toBeInTheDocument();
+ expect(screen.queryByText('✔ Challenge Completed')).not.toBeInTheDocument();
+
+ // Should still have points from yesterday
+ expect(screen.getByTestId('challenge-points')).toHaveTextContent('50');
+ });
+});
diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx
index aa2b6e2..4f64658 100644
--- a/src/components/Dashboard.jsx
+++ b/src/components/Dashboard.jsx
@@ -21,7 +21,8 @@ import { useSWR } from "../hooks/useSWR";
import { getAQIBand, getPollutantColor, get7DayForecast, fetch7DayForecast, getWeatherDetails } from "../services/airQualityService";
import MorningBriefing from "./MorningBriefing";
import { eventBus } from "../core/events";
-
+import ChallengesWidget from "./ChallengesWidget";
+import IndoorTracker from "./IndoorTracker";
/** @param {any} isoTime */
function shortTimeLabel(isoTime) {
return new Date(isoTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -455,6 +456,8 @@ onKeyDown={(e) => {
+
+
@@ -515,6 +518,7 @@ onKeyDown={(e) => {
+
7-Day AQI & Weather Forecast
{forecastError && Failed to load forecast data.
}
diff --git a/src/components/IndoorTracker.jsx b/src/components/IndoorTracker.jsx
new file mode 100644
index 0000000..3ad0e48
--- /dev/null
+++ b/src/components/IndoorTracker.jsx
@@ -0,0 +1,218 @@
+import React, { useState, useEffect } from 'react';
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Legend
+} from 'recharts';
+
+const LOCAL_STORAGE_KEY = 'pollution_hub_indoor_aqi';
+
+export default function IndoorTracker({ currentOutdoor }) {
+ const [pm25, setPm25] = useState('');
+ const [co2, setCo2] = useState('');
+ const [voc, setVoc] = useState('');
+ const [lastUpdated, setLastUpdated] = useState(null);
+
+ // For the charts/tips, we need parsed numeric values
+ const [savedData, setSavedData] = useState(null);
+
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
+ if (raw) {
+ const data = JSON.parse(raw);
+ setPm25(data.pm25 !== undefined ? String(data.pm25) : '');
+ setCo2(data.co2 !== undefined ? String(data.co2) : '');
+ setVoc(data.voc !== undefined ? String(data.voc) : '');
+ setLastUpdated(data.lastUpdated || null);
+ setSavedData(data);
+ }
+ } catch (e) {
+ console.warn("Failed to read indoor AQI from localStorage", e);
+ }
+ }, []);
+
+ const handleSave = (e) => {
+ e.preventDefault();
+
+ // Convert to numbers for validation
+ const numPm25 = pm25 === '' ? null : Number(pm25);
+ const numCo2 = co2 === '' ? null : Number(co2);
+ const numVoc = voc === '' ? null : Number(voc);
+
+ // Validate
+ if (numPm25 !== null && (numPm25 < 0 || numPm25 > 500)) {
+ alert('PM2.5 must be between 0 and 500');
+ return;
+ }
+ if (numCo2 !== null && (numCo2 < 0 || numCo2 > 5000)) {
+ alert('CO₂ must be between 0 and 5000');
+ return;
+ }
+ if (numVoc !== null && (numVoc < 0 || numVoc > 1000)) {
+ alert('VOC must be between 0 and 1000');
+ return;
+ }
+
+ const now = new Date().toISOString();
+ const newData = {
+ pm25: numPm25,
+ co2: numCo2,
+ voc: numVoc,
+ lastUpdated: now
+ };
+
+ try {
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newData));
+ setLastUpdated(now);
+ setSavedData(newData);
+ } catch (err) {
+ console.warn('Failed to save indoor data', err);
+ }
+ };
+
+ const outdoorPm25 = currentOutdoor?.pm2_5 || 0;
+ const indoorPm25Val = savedData?.pm25 !== null && savedData?.pm25 !== undefined ? savedData.pm25 : 0;
+
+ const comparisonData = [
+ {
+ name: 'PM2.5 (µg/m³)',
+ Indoor: indoorPm25Val,
+ Outdoor: outdoorPm25
+ }
+ ];
+
+ // Generate tips
+ const tips = [];
+ if (savedData) {
+ if (savedData.pm25 !== null && savedData.pm25 !== undefined) {
+ if (savedData.pm25 > outdoorPm25) {
+ tips.push("Your indoor air is currently worse than outside. Consider opening a window.");
+ } else if (savedData.pm25 < outdoorPm25) {
+ tips.push("Indoor air quality is currently better than outside.");
+ }
+ }
+ if (savedData.co2 !== null && savedData.co2 > 1000) {
+ tips.push("Improve ventilation by opening windows or doors (High CO₂).");
+ }
+ if (savedData.voc !== null && savedData.voc > 500) {
+ tips.push("Reduce use of chemical cleaners and improve airflow (High VOC).");
+ }
+ }
+
+ return (
+
+
+
+
🏠 Indoor vs. Outdoor Air Quality
+
Track your indoor environment manually and compare with outdoor readings.
+
+
+
+ {/* Data Entry Form */}
+
+
+ {/* Visualization and Comparison */}
+ {savedData && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Contextual Tips */}
+
+
💡 Actionable Insights
+ {tips.length > 0 ? (
+
+ {tips.map((tip, idx) => (
+ - {tip}
+ ))}
+
+ ) : (
+
No specific recommendations right now. Keep monitoring!
+ )}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/IndoorTracker.test.jsx b/src/components/IndoorTracker.test.jsx
new file mode 100644
index 0000000..c47312b
--- /dev/null
+++ b/src/components/IndoorTracker.test.jsx
@@ -0,0 +1,118 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import IndoorTracker from './IndoorTracker';
+
+// Mock Recharts to avoid DOM measuring issues in JSDOM
+vi.mock('recharts', async () => {
+ const OriginalRecharts = await vi.importActual('recharts');
+ return {
+ ...OriginalRecharts,
+ ResponsiveContainer: ({ children }) => (
+
+ {children}
+
+ ),
+ };
+});
+
+describe('IndoorTracker', () => {
+ const mockOutdoor = {
+ pm2_5: 25.5
+ };
+
+ beforeEach(() => {
+ localStorage.clear();
+ // Suppress console.warn for intentional errors in tests
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(window, 'alert').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it('renders form and allows input', () => {
+ render();
+
+ expect(screen.getByText('🏠 Indoor vs. Outdoor Air Quality')).toBeInTheDocument();
+ expect(screen.getByTestId('pm25-input')).toBeInTheDocument();
+
+ // Type a value
+ fireEvent.change(screen.getByTestId('pm25-input'), { target: { value: '12.5' } });
+ expect(screen.getByTestId('pm25-input')).toHaveValue(12.5);
+ });
+
+ it('validates PM2.5 input', () => {
+ render();
+
+ fireEvent.change(screen.getByTestId('pm25-input'), { target: { value: '600' } });
+ fireEvent.submit(screen.getByRole('button', { name: /save indoor data/i }).closest('form'));
+
+ expect(window.alert).toHaveBeenCalledWith('PM2.5 must be between 0 and 500');
+ expect(localStorage.getItem('pollution_hub_indoor_aqi')).toBeNull();
+ });
+
+ it('validates CO2 input', () => {
+ render();
+
+ fireEvent.change(screen.getByTestId('co2-input'), { target: { value: '6000' } });
+ fireEvent.submit(screen.getByRole('button', { name: /save indoor data/i }).closest('form'));
+
+ expect(window.alert).toHaveBeenCalledWith('CO₂ must be between 0 and 5000');
+ expect(localStorage.getItem('pollution_hub_indoor_aqi')).toBeNull();
+ });
+
+ it('saves valid data to localStorage and shows comparison', () => {
+ render();
+
+ fireEvent.change(screen.getByTestId('pm25-input'), { target: { value: '30' } });
+ fireEvent.change(screen.getByTestId('co2-input'), { target: { value: '1200' } });
+ fireEvent.submit(screen.getByRole('button', { name: /save indoor data/i }).closest('form'));
+
+ const savedData = JSON.parse(localStorage.getItem('pollution_hub_indoor_aqi'));
+ expect(savedData.pm25).toBe(30);
+ expect(savedData.co2).toBe(1200);
+
+ // Should render chart container
+ expect(screen.getByTestId('indoor-comparison-chart')).toBeInTheDocument();
+ });
+
+ it('displays contextual tips correctly', () => {
+ // Indoor PM2.5 > Outdoor PM2.5
+ render(); // outdoor is 25.5
+
+ fireEvent.change(screen.getByTestId('pm25-input'), { target: { value: '40' } });
+ fireEvent.change(screen.getByTestId('co2-input'), { target: { value: '1500' } }); // High CO2
+ fireEvent.change(screen.getByTestId('voc-input'), { target: { value: '800' } }); // High VOC
+ fireEvent.submit(screen.getByRole('button', { name: /save indoor data/i }).closest('form'));
+
+ expect(screen.getByText(/Your indoor air is currently worse than outside/i)).toBeInTheDocument();
+ expect(screen.getByText(/Improve ventilation by opening windows or doors/i)).toBeInTheDocument();
+ expect(screen.getByText(/Reduce use of chemical cleaners and improve airflow/i)).toBeInTheDocument();
+ });
+
+ it('displays better air quality tip', () => {
+ render(); // outdoor is 25.5
+
+ fireEvent.change(screen.getByTestId('pm25-input'), { target: { value: '10' } });
+ fireEvent.submit(screen.getByRole('button', { name: /save indoor data/i }).closest('form'));
+
+ expect(screen.getByText(/Indoor air quality is currently better than outside/i)).toBeInTheDocument();
+ });
+
+ it('loads previously saved data from localStorage', () => {
+ localStorage.setItem('pollution_hub_indoor_aqi', JSON.stringify({
+ pm25: 15,
+ co2: 800,
+ voc: 100,
+ lastUpdated: new Date().toISOString()
+ }));
+
+ render();
+
+ expect(screen.getByTestId('pm25-input')).toHaveValue(15);
+ expect(screen.getByTestId('co2-input')).toHaveValue(800);
+ expect(screen.getByTestId('indoor-comparison-chart')).toBeInTheDocument();
+ });
+});