diff --git a/src/components/EcoChallengeDashboard.jsx b/src/components/EcoChallengeDashboard.jsx index 8d26a25..99753c7 100644 --- a/src/components/EcoChallengeDashboard.jsx +++ b/src/components/EcoChallengeDashboard.jsx @@ -1,6 +1,55 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { fetchActiveChallenges, joinChallenge, claimChallengeReward } from '../services/challengeService'; +/** + * How far along a challenge is, as a percentage of its bar. + * + * `Math.min(x, 100)` capped the top and nothing guarded the bottom or the divisor. A + * `targetValue` of 0 gives Infinity — or NaN when progress is 0 too — and `width: + * "NaN%"` is dropped by the browser, so the bar silently disappeared rather than + * showing an obviously wrong value. A missing or negative `progress` did the same. + * + * @param {unknown} progress - Units completed. + * @param {unknown} targetValue - Units required. + * @returns {number} A percentage in [0, 100]. + */ +export function progressPercent(progress, targetValue) { + const done = Number(progress); + const target = Number(targetValue); + if (!Number.isFinite(done) || !Number.isFinite(target) || target <= 0) return 0; + if (done <= 0) return 0; + return Math.min((done / target) * 100, 100); +} + +/** + * A progress count for display, without printing `undefined` next to the target. + * + * @param {unknown} value + * @returns {number} + */ +export function progressCount(value) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; +} + +/** + * Pulls the challenge list out of a response without assuming its shape. + * + * `data?.challenges.map(...)` stopped its optional chain at `data`, so a response of + * `{}` — a shape nothing in `fetchActiveChallenges` rules out, since it returns + * `response.json()` unchecked — was `undefined.map` and threw during render. + * + * @param {unknown} data + * @returns {{challenges: any[], userProgress: Record, totalPointsEarned: number}} + */ +export function readChallengeData(data) { + return { + challenges: Array.isArray(data?.challenges) ? data.challenges : [], + userProgress: (data?.userProgress && typeof data.userProgress === 'object') ? data.userProgress : {}, + totalPointsEarned: Number.isFinite(Number(data?.totalPointsEarned)) ? Number(data.totalPointsEarned) : 0, + }; +} + /** * @component EcoChallengeDashboard * @description Interactive UI displaying active challenges, progress bars, and join/claim actions. @@ -9,31 +58,38 @@ const EcoChallengeDashboard = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [notice, setNotice] = useState(null); const [actionLoading, setActionLoading] = useState(null); - useEffect(() => { - loadData(); - }, []); - - const loadData = async () => { - setLoading(true); + const loadData = useCallback(async ({ showSpinner = true } = {}) => { + if (showSpinner) setLoading(true); try { const result = await fetchActiveChallenges(); setData(result); + // Without this the flag latched: handleJoin and handleClaim both re-fetch on + // success, but a successful load behind an uncleared error just populated + // `data` under an error screen nobody could dismiss. + setError(null); } catch (err) { - setError(err.message); + setError(err?.message || 'Could not load challenges.'); } finally { setLoading(false); } - }; + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); const handleJoin = async (challengeId) => { setActionLoading(challengeId); + setActionError(null); try { await joinChallenge(challengeId); - await loadData(); // Refresh data + await loadData({ showSpinner: false }); } catch (err) { - alert(err.message); + setActionError(err?.message || 'Could not join that challenge.'); } finally { setActionLoading(null); } @@ -41,12 +97,14 @@ const EcoChallengeDashboard = () => { const handleClaim = async (challengeId) => { setActionLoading(challengeId); + setActionError(null); try { const result = await claimChallengeReward(challengeId); - alert(`🎉 Congratulations! You earned ${result.pointsAwarded} points!`); - await loadData(); + const points = progressCount(result?.pointsAwarded); + setNotice(`🎉 Reward claimed — you earned ${points} points.`); + await loadData({ showSpinner: false }); } catch (err) { - alert(err.message); + setActionError(err?.message || 'Could not claim that reward.'); } finally { setActionLoading(null); } @@ -64,20 +122,34 @@ const EcoChallengeDashboard = () => { if (loading) { return ( -
+
); } - if (error) { + // A failure with nothing behind it is the page; a failure with challenges already + // on screen is a banner over them. Either way there is a way back, which the + // latched error state did not offer. + if (error && !data) { return ( -
- {error} +
+

{error}

+
); } + const { challenges, userProgress, totalPointsEarned } = readChallengeData(data); + return (
{/* Header Stats */} @@ -89,104 +161,142 @@ const EcoChallengeDashboard = () => {
Total Points
-
{data?.totalPointsEarned || 0}
+
{totalPointsEarned}
+ {error && ( +
+ {error} Showing the challenges from the last successful load. + +
+ )} + + {actionError && ( +
+ {actionError} +
+ )} + + {notice && ( +
+ {notice} +
+ )} + {/* Challenges Grid */}

Active Challenges

-
- {data?.challenges.map((challenge) => { - const progress = data.userProgress[challenge.id]; - const isJoined = !!progress; - const isCompleted = progress?.isCompleted; - const isClaimed = progress?.rewardClaimed; - const progressPercent = isJoined ? Math.min((progress.progress / challenge.targetValue) * 100, 100) : 0; - - return ( -
-
-
- {getCategoryIcon(challenge.category)} - - {challenge.frequency} - -
+ {challenges.length === 0 ? ( +

+ No challenges are running right now. Check back soon. +

+ ) : ( +
+ {challenges.map((challenge) => { + const progress = userProgress[challenge.id]; + const isJoined = !!progress; + const isCompleted = progress?.isCompleted; + const isClaimed = progress?.rewardClaimed; + const percent = isJoined ? progressPercent(progress.progress, challenge.targetValue) : 0; -

{challenge.title}

-

{challenge.description}

- - {isJoined ? ( -
-
- Progress - - {progress.progress} / {challenge.targetValue} {challenge.unit} - -
-
-
-
- {isCompleted && !isClaimed && ( -

- ✅ Challenge Completed! Claim your reward. - .

- )} - {isClaimed && ( -

- 🏆 Reward Claimed -

- )} + return ( +
+
+
+ {getCategoryIcon(challenge.category)} + + {challenge.frequency} +
- ) : ( -
-
- Target: {challenge.targetValue} {challenge.unit} + +

{challenge.title}

+

{challenge.description}

+ + {isJoined ? ( +
+
+ Progress + + {progressCount(progress.progress)} / {challenge.targetValue} {challenge.unit} + +
+
+
+
+ {isCompleted && !isClaimed && ( +

+ ✅ Challenge Completed! Claim your reward. +

+ )} + {isClaimed && ( +

+ 🏆 Reward Claimed +

+ )}
-
- Reward: +{challenge.rewardValue} Points - {challenge.badgeName && ` & "${challenge.badgeName}" Badge`} + ) : ( +
+
+ Target: {challenge.targetValue} {challenge.unit} +
+
+ Reward: +{challenge.rewardValue} Points + {challenge.badgeName && ` & "${challenge.badgeName}" Badge`} +
-
- )} -
+ )} +
-
- {!isJoined ? ( - - ) : isCompleted && !isClaimed ? ( - - ) : ( - - )} +
+ {!isJoined ? ( + + ) : isCompleted && !isClaimed ? ( + + ) : ( + + )} +
-
- ); - })} -
+ ); + })} +
+ )}
); diff --git a/src/components/EcoChallengeDashboard.test.jsx b/src/components/EcoChallengeDashboard.test.jsx new file mode 100644 index 0000000..44eac6b --- /dev/null +++ b/src/components/EcoChallengeDashboard.test.jsx @@ -0,0 +1,279 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import EcoChallengeDashboard, { progressPercent, progressCount, readChallengeData } from './EcoChallengeDashboard'; +import { fetchActiveChallenges, joinChallenge, claimChallengeReward } from '../services/challengeService'; + +vi.mock('../services/challengeService', () => ({ + fetchActiveChallenges: vi.fn(), + joinChallenge: vi.fn(), + claimChallengeReward: vi.fn(), +})); + +function challenge(overrides = {}) { + return { + id: 'ch-1', + title: 'Report five hotspots', + description: 'Submit five verified pollution reports this week.', + category: 'REPORTING', + frequency: 'WEEKLY', + targetValue: 5, + unit: 'reports', + rewardValue: 250, + badgeName: 'Hotspot Scout', + ...overrides, + }; +} + +function payload(overrides = {}) { + return { + challenges: [challenge()], + userProgress: {}, + totalPointsEarned: 1200, + ...overrides, + }; +} + +async function renderLoaded(data = payload()) { + fetchActiveChallenges.mockResolvedValue(data); + render(); + await waitFor(() => expect(screen.queryByTestId('challenges-loading')).not.toBeInTheDocument()); +} + +beforeEach(() => { + joinChallenge.mockResolvedValue({ ok: true }); + claimChallengeReward.mockResolvedValue({ pointsAwarded: 250 }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('progressPercent', () => { + it('scales progress against the target', () => { + expect(progressPercent(2, 5)).toBe(40); + expect(progressPercent(5, 5)).toBe(100); + }); + + it('caps at 100 when progress overshoots', () => { + expect(progressPercent(9, 5)).toBe(100); + }); + + it('returns 0 for a zero target instead of Infinity', () => { + // `(3 / 0) * 100` is Infinity and `(0 / 0) * 100` is NaN. `width: "NaN%"` is + // dropped by the browser, so the bar vanished rather than looking wrong. + expect(progressPercent(3, 0)).toBe(0); + expect(progressPercent(0, 0)).toBe(0); + }); + + it('returns 0 rather than a negative width', () => { + expect(progressPercent(-4, 5)).toBe(0); + }); + + it('returns 0 for missing or non-numeric values', () => { + expect(progressPercent(undefined, 5)).toBe(0); + expect(progressPercent(null, 5)).toBe(0); + expect(progressPercent('two', 5)).toBe(0); + expect(progressPercent(2, undefined)).toBe(0); + expect(progressPercent(2, -5)).toBe(0); + }); + + it('is always a usable CSS percentage', () => { + const inputs = [[3, 0], [0, 0], [-4, 5], [undefined, 5], [2, 'x'], [9, 5], [Infinity, 5]]; + for (const [done, target] of inputs) { + const percent = progressPercent(done, target); + expect(Number.isFinite(percent)).toBe(true); + expect(percent).toBeGreaterThanOrEqual(0); + expect(percent).toBeLessThanOrEqual(100); + } + }); +}); + +describe('progressCount', () => { + it('passes a real count through', () => { + expect(progressCount(3)).toBe(3); + }); + + it('never renders undefined next to the target', () => { + expect(progressCount(undefined)).toBe(0); + expect(progressCount(null)).toBe(0); + expect(progressCount('many')).toBe(0); + expect(progressCount(-2)).toBe(0); + }); +}); + +describe('readChallengeData', () => { + it('reads a well-formed payload', () => { + expect(readChallengeData(payload())).toEqual({ + challenges: [challenge()], + userProgress: {}, + totalPointsEarned: 1200, + }); + }); + + it('survives a payload with no challenges array', () => { + // `data?.challenges.map(...)` stopped its optional chain at `data`, so this + // threw during render. + expect(readChallengeData({}).challenges).toEqual([]); + expect(readChallengeData({ userProgress: {} }).challenges).toEqual([]); + expect(readChallengeData(null).challenges).toEqual([]); + expect(readChallengeData({ challenges: 'nope' }).challenges).toEqual([]); + }); + + it('survives a payload with no userProgress', () => { + expect(readChallengeData({ challenges: [] }).userProgress).toEqual({}); + expect(readChallengeData({ userProgress: null }).userProgress).toEqual({}); + }); + + it('defaults the points total to 0', () => { + expect(readChallengeData({}).totalPointsEarned) .toBe(0); + expect(readChallengeData({ totalPointsEarned: 'lots' }).totalPointsEarned).toBe(0); + }); +}); + +describe('EcoChallengeDashboard — the error screen is recoverable', () => { + it('offers a retry instead of a dead red box', async () => { + fetchActiveChallenges.mockRejectedValueOnce(new Error('Failed to fetch')); + + render(); + + const errorScreen = await screen.findByTestId('challenges-error'); + expect(errorScreen).toHaveTextContent('Failed to fetch'); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('clears the error when the retry succeeds', async () => { + fetchActiveChallenges.mockRejectedValueOnce(new Error('Failed to fetch')); + render(); + await screen.findByTestId('challenges-error'); + + fetchActiveChallenges.mockResolvedValue(payload()); + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + + await waitFor(() => expect(screen.getByText('Report five hotspots')).toBeInTheDocument()); + expect(screen.queryByTestId('challenges-error')).not.toBeInTheDocument(); + }); + + it('does not leave loaded challenges hidden behind a stale error', async () => { + await renderLoaded(); + + // A refresh fails, then recovers. The old code latched `error` on the first + // failure and never cleared it, so everything after this point was invisible. + fetchActiveChallenges.mockRejectedValueOnce(new Error('flaky')); + fireEvent.click(screen.getByRole('button', { name: /join challenge/i })); + await waitFor(() => expect(screen.getByTestId('challenges-error-banner')).toBeInTheDocument()); + expect(screen.getByText('Report five hotspots')).toBeInTheDocument(); + + fetchActiveChallenges.mockResolvedValue(payload()); + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + + await waitFor(() => expect(screen.queryByTestId('challenges-error-banner')).not.toBeInTheDocument()); + }); +}); + +describe('EcoChallengeDashboard — malformed payloads', () => { + it('renders the empty state instead of throwing on a payload with no challenges', async () => { + await renderLoaded({}); + + expect(screen.getByTestId('challenges-empty')).toBeInTheDocument(); + expect(screen.getByTestId('total-points')).toHaveTextContent('0'); + }); + + it('distinguishes an empty result from a failure', async () => { + await renderLoaded(payload({ challenges: [] })); + + expect(screen.getByTestId('challenges-empty')).toHaveTextContent(/no challenges are running/i); + expect(screen.queryByTestId('challenges-error')).not.toBeInTheDocument(); + }); + + it('renders a joined challenge whose progress record is missing fields', async () => { + await renderLoaded(payload({ userProgress: { 'ch-1': {} } })); + + expect(screen.getByTestId('progress-label-ch-1')).toHaveTextContent('0 / 5 reports'); + expect(screen.getByTestId('progress-label-ch-1')).not.toHaveTextContent('undefined'); + }); +}); + +describe('EcoChallengeDashboard — the progress bar', () => { + it('renders a usable width for a zero target', async () => { + await renderLoaded(payload({ + challenges: [challenge({ targetValue: 0 })], + userProgress: { 'ch-1': { progress: 3 } }, + })); + + expect(screen.getByTestId('progress-bar-ch-1')).toHaveStyle({ width: '0%' }); + }); + + it('does not overflow its track', async () => { + await renderLoaded(payload({ userProgress: { 'ch-1': { progress: 12 } } })); + + expect(screen.getByTestId('progress-bar-ch-1')).toHaveStyle({ width: '100%' }); + }); + + it('scales a partial completion', async () => { + await renderLoaded(payload({ userProgress: { 'ch-1': { progress: 2 } } })); + + expect(screen.getByTestId('progress-bar-ch-1')).toHaveStyle({ width: '40%' }); + }); +}); + +describe('EcoChallengeDashboard — completed challenges', () => { + it('does not render the stray full stop after the completion message', async () => { + await renderLoaded(payload({ + userProgress: { 'ch-1': { progress: 5, isCompleted: true, rewardClaimed: false } }, + })); + + const message = screen.getByText(/challenge completed/i); + expect(message.textContent.trim()).toBe('✅ Challenge Completed! Claim your reward.'); + }); + + it('reports a claim in the page rather than through window.alert', async () => { + const windowAlert = vi.spyOn(window, 'alert').mockImplementation(() => { }); + await renderLoaded(payload({ + userProgress: { 'ch-1': { progress: 5, isCompleted: true, rewardClaimed: false } }, + })); + + fireEvent.click(screen.getByRole('button', { name: /claim reward/i })); + + await waitFor(() => expect(screen.getByTestId('challenges-notice')).toHaveTextContent('250 points')); + expect(windowAlert).not.toHaveBeenCalled(); + }); + + it('reports a failed claim in the page and leaves the button usable', async () => { + claimChallengeReward.mockRejectedValue(new Error('Reward already claimed.')); + const windowAlert = vi.spyOn(window, 'alert').mockImplementation(() => { }); + await renderLoaded(payload({ + userProgress: { 'ch-1': { progress: 5, isCompleted: true, rewardClaimed: false } }, + })); + + fireEvent.click(screen.getByRole('button', { name: /claim reward/i })); + + await waitFor(() => expect(screen.getByTestId('challenges-action-error')).toHaveTextContent('Reward already claimed.')); + expect(windowAlert).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /claim reward/i })).toBeEnabled(); + }); +}); + +describe('EcoChallengeDashboard — joining', () => { + it('joins and refreshes without blanking the page', async () => { + await renderLoaded(); + + fireEvent.click(screen.getByRole('button', { name: /join challenge/i })); + + await waitFor(() => expect(joinChallenge).toHaveBeenCalledWith('ch-1')); + // A background refresh must not drop the grid back to the spinner. + expect(screen.queryByTestId('challenges-loading')).not.toBeInTheDocument(); + expect(screen.getByText('Report five hotspots')).toBeInTheDocument(); + }); + + it('reports a join failure in the page', async () => { + joinChallenge.mockRejectedValue(new Error('Already enrolled.')); + const windowAlert = vi.spyOn(window, 'alert').mockImplementation(() => { }); + await renderLoaded(); + + fireEvent.click(screen.getByRole('button', { name: /join challenge/i })); + + await waitFor(() => expect(screen.getByTestId('challenges-action-error')).toHaveTextContent('Already enrolled.')); + expect(windowAlert).not.toHaveBeenCalled(); + }); +});