Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/components/ChallengesWidget.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<article className="kpi-card challenges-widget" data-testid="challenges-widget">
<h3>🌱 Daily Challenge</h3>

<div style={{ marginTop: '1rem', marginBottom: '1.5rem', minHeight: '3rem' }}>
<p style={{ fontSize: '1.1rem', fontWeight: '500', color: 'var(--ink)' }}>
{challenge || "Loading challenge..."}
</p>
</div>

<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{completed ? (
<div style={{
padding: '0.75rem',
backgroundColor: '#f0fdf4',
color: '#15803d',
borderRadius: '6px',
textAlign: 'center',
fontWeight: '600',
border: '1px solid #bbf7d0'
}}>
✔ Challenge Completed
</div>
) : (
<button
type="button"
onClick={handleMarkComplete}
disabled={!challenge}
style={{
padding: '0.75rem 1rem',
backgroundColor: 'var(--brand)',
color: '#fff',
border: 'none',
borderRadius: '6px',
fontWeight: '600',
cursor: challenge ? 'pointer' : 'not-allowed',
opacity: challenge ? 1 : 0.7
}}
>
Mark Complete
</button>
)}

<div style={{
textAlign: 'center',
fontSize: '0.95rem',
fontWeight: '600',
color: 'var(--muted)'
}}>
Points: <span data-testid="challenge-points">{points}</span>
</div>
</div>
</article>
);
}
71 changes: 71 additions & 0 deletions src/components/ChallengesWidget.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<ChallengesWidget />);

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(<ChallengesWidget />);

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(<ChallengesWidget />);
const markCompleteBtn = screen.getByRole('button', { name: /mark complete/i });
fireEvent.click(markCompleteBtn);
unmount();

// Re-render, should still be completed and have 10 points
render(<ChallengesWidget />);
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(<ChallengesWidget />);

// 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');
});
});
6 changes: 5 additions & 1 deletion src/components/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -455,6 +456,8 @@ onKeyDown={(e) => {
</ResponsiveContainer>
</div>
</article>
<ChallengesWidget />

</div>

<div data-testid="pollutants-grid" className="pollutants-grid">
Expand Down Expand Up @@ -515,6 +518,7 @@ onKeyDown={(e) => {
</div>

<div className="chart-grid">
<IndoorTracker currentOutdoor={current} />
<article className="chart-card forecast-card" style={{ gridColumn: '1 / -1' }}>
<h3>7-Day AQI & Weather Forecast</h3>
{forecastError && <p style={{ color: 'var(--danger)', padding: '1rem' }}>Failed to load forecast data.</p>}
Expand Down
Loading
Loading