From 0d4e242cbb4fc3a4fff9ccc5a17683bbcb2bed4a Mon Sep 17 00:00:00 2001 From: Example User Date: Sun, 2 Aug 2026 10:34:25 +0530 Subject: [PATCH 1/2] feat: add daily challenge widget with local persistence --- src/components/ChallengesWidget.jsx | 147 +++++++++++++++++++++++ src/components/ChallengesWidget.test.jsx | 71 +++++++++++ src/components/Dashboard.jsx | 4 +- 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/components/ChallengesWidget.jsx create mode 100644 src/components/ChallengesWidget.test.jsx 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..f260c1f 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -21,7 +21,7 @@ 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"; /** @param {any} isoTime */ function shortTimeLabel(isoTime) { return new Date(isoTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -455,6 +455,8 @@ onKeyDown={(e) => { + +
From 683e04c84b9964d8329bc8f335e1adfff0ea5e26 Mon Sep 17 00:00:00 2001 From: Example User Date: Sun, 2 Aug 2026 10:59:27 +0530 Subject: [PATCH 2/2] feat: add indoor vs outdoor air quality comparison --- src/components/Dashboard.jsx | 2 + src/components/IndoorTracker.jsx | 218 ++++++++++++++++++++++++++ src/components/IndoorTracker.test.jsx | 118 ++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/components/IndoorTracker.jsx create mode 100644 src/components/IndoorTracker.test.jsx diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index f260c1f..4f64658 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -22,6 +22,7 @@ import { getAQIBand, getPollutantColor, get7DayForecast, fetch7DayForecast, getW 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' }); @@ -517,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 */} +
+
+

Log Indoor Data

+ +
+ + setPm25(e.target.value)} + placeholder="0 - 500" + style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid var(--line)' }} + /> +
+ +
+ + setCo2(e.target.value)} + placeholder="0 - 5000" + style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid var(--line)' }} + /> +
+ +
+ + setVoc(e.target.value)} + placeholder="0 - 1000" + style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid var(--line)' }} + /> +
+ + + + {lastUpdated && ( + + Last saved: {new Date(lastUpdated).toLocaleTimeString()} + + )} +
+
+ + {/* 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(); + }); +});