diff --git a/package.json b/package.json index e49d761..5f2d15a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "test": "node --test src/lib/analytics.test.js src/lib/models.test.js src/utils/xpCalculator.test.js", + "test": "node --test src/lib/analytics.test.js src/lib/models.test.js src/utils/xpCalculator.test.js src/lib/achievements/catalog.test.js src/lib/ui/mobileDateFieldClass.test.js", "preview": "vite preview", "analyze": "vite build && (echo \"Open dist/bundle-stats.html\" || true)", "lh:desktop": "lighthouse http://localhost:4173 --preset=desktop --output html --output-path ./lighthouse-desktop.html --only-categories=performance,accessibility,best-practices,seo", diff --git a/src/components/AnalyticsFilters.jsx b/src/components/AnalyticsFilters.jsx index f8e785e..45a2c6d 100644 --- a/src/components/AnalyticsFilters.jsx +++ b/src/components/AnalyticsFilters.jsx @@ -1,4 +1,5 @@ import { Calendar } from 'lucide-react' +import { MOBILE_DATE_INPUT_BASE } from '../lib/ui/mobileDateFieldClass' const TYPE_KEYS = ['spot', 'catch_shoot', 'off_dribble', 'run_half'] @@ -33,7 +34,7 @@ export default function AnalyticsFilters({ value, onChange }) { } const inputBase = - 'block w-full min-w-0 max-w-full [min-inline-size:0] [-webkit-min-logical-width:0] border rounded-lg px-7 py-1.5 text-sm bg-white border-gray-200 text-gray-900 ' + + `${MOBILE_DATE_INPUT_BASE} border rounded-lg px-7 py-1.5 text-sm bg-white border-gray-200 text-gray-900 ` + 'focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-transparent ' + 'dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-100 dark:focus:ring-white/10' diff --git a/src/components/KpiTiles.jsx b/src/components/KpiTiles.jsx index 931a126..bb3a65b 100644 --- a/src/components/KpiTiles.jsx +++ b/src/components/KpiTiles.jsx @@ -1,9 +1,14 @@ +import { useState } from 'react' + export default function KpiTiles({ kpis = {}, comparisonKpis = null, comparisonSource = 'none', + currentRange = null, + comparisonRange = null, windowDays = 7, }) { + const [showDetails, setShowDetails] = useState(false) const suffix = `(${windowDays}d)` const accText = typeof kpis.acc === 'number' ? `${kpis.acc}%` : '0%' const volText = typeof kpis.volume === 'number' ? kpis.volume : 0 @@ -13,12 +18,39 @@ export default function KpiTiles({ const accDelta = toDelta(kpis.acc, comparisonKpis?.acc, '%', hasBaseline, comparisonSource) const volDelta = toDelta(kpis.volume, comparisonKpis?.volume, '', hasBaseline, comparisonSource) const bestDelta = toBestZoneDelta(kpis.bestZone, comparisonKpis?.bestZone, hasBaseline, comparisonSource) + const comparisonLabel = comparisonSource === 'last_active' ? 'last active period' : comparisonSource === 'prev' ? 'previous period' : 'none' return ( -
- - - +
+
+ + + +
+ +
+ +
+ + {showDetails && ( +
+

+ Source: {comparisonLabel} +

+

+ Current: {formatRange(currentRange)} +

+

+ Baseline: {formatRange(comparisonRange)} +

+
+ )}
) } @@ -80,3 +112,8 @@ function KpiCard({ title, value, delta }) {
) } + +function formatRange(range) { + if (!range?.from || !range?.to) return 'n/a' + return `${range.from} -> ${range.to}` +} diff --git a/src/lib/achievements/catalog.js b/src/lib/achievements/catalog.js new file mode 100644 index 0000000..3b9a3b9 --- /dev/null +++ b/src/lib/achievements/catalog.js @@ -0,0 +1,38 @@ +import { BADGES } from './definitions.js' + +export const CATEGORY_LABELS = { + accuracy: 'Accuracy', + streak: 'Streak', + volume: 'Volume', + type: 'Drill Type', +} + +export const BADGE_CATALOG = Object.values(BADGES).map((b) => ({ + ...b, + icon: b.icon || '*', +})) + +export function buildAchievementSections(unlocked = []) { + const unlockedById = new Map((unlocked || []).map((a) => [a.id, a])) + const grouped = new Map() + + for (const badge of BADGE_CATALOG) { + const key = badge.category || 'other' + if (!grouped.has(key)) grouped.set(key, []) + const row = unlockedById.get(badge.id) + grouped.get(key).push({ + ...badge, + unlocked: Boolean(row), + unlockedAt: row?.unlockedAt ?? null, + description: row?.description || badge.description, + name: row?.name || badge.name, + icon: row?.icon || badge.icon, + }) + } + + return Array.from(grouped.entries()).map(([category, items]) => ({ + category, + label: CATEGORY_LABELS[category] || category, + items, + })) +} diff --git a/src/lib/achievements/catalog.test.js b/src/lib/achievements/catalog.test.js new file mode 100644 index 0000000..82c9e43 --- /dev/null +++ b/src/lib/achievements/catalog.test.js @@ -0,0 +1,23 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { BADGE_CATALOG, buildAchievementSections } from './catalog.js' + +test('buildAchievementSections groups badges by category and marks unlocked items', () => { + const unlocked = [ + { id: 'accuracy_80', unlockedAt: Date.now(), name: 'Marksman 80%', description: 'Hit 80%+', icon: '*' }, + { id: 'volume_500', unlockedAt: Date.now(), name: '500 Up', description: 'Reach 500 total shots', icon: '*' }, + ] + const sections = buildAchievementSections(unlocked) + + assert.ok(sections.length > 0) + const accuracy = sections.find((s) => s.category === 'accuracy') + assert.ok(accuracy) + assert.ok(accuracy.items.some((i) => i.id === 'accuracy_80' && i.unlocked)) + assert.ok(accuracy.items.some((i) => i.id !== 'accuracy_80' && !i.unlocked)) +}) + +test('badge catalog remains non-empty for locked preview rendering', () => { + assert.ok(Array.isArray(BADGE_CATALOG)) + assert.ok(BADGE_CATALOG.length > 0) +}) + diff --git a/src/lib/achievements/definitions.js b/src/lib/achievements/definitions.js new file mode 100644 index 0000000..3410bed --- /dev/null +++ b/src/lib/achievements/definitions.js @@ -0,0 +1,66 @@ +export const BADGES = { + accuracy80: { + id: 'accuracy_80', + category: 'accuracy', + name: 'Marksman 80%', + description: 'Hit 80%+ accuracy in a session', + icon: '*', + }, + accuracy85: { + id: 'accuracy_85', + category: 'accuracy', + name: 'Sharpshooter 85%', + description: 'Hit 85%+ accuracy in a session', + icon: '*', + }, + accuracy90: { + id: 'accuracy_90', + category: 'accuracy', + name: 'Sniper 90%', + description: 'Hit 90%+ accuracy in a session', + icon: '*', + }, + streak7: { + id: 'streak_7d', + category: 'streak', + name: 'Weekly Warrior', + description: 'Train 7 days in a row', + icon: '*', + }, + streak30: { + id: 'streak_30d', + category: 'streak', + name: 'Iron Streak 30', + description: 'Train 30 days in a row', + icon: '*', + }, + vol500: { + id: 'volume_500', + category: 'volume', + name: '500 Up', + description: 'Reach 500 total shots', + icon: '*', + }, + vol5000: { + id: 'volume_5000', + category: 'volume', + name: '5,000 Grinder', + description: 'Reach 5,000 total shots', + icon: '*', + }, + typeCatch: { + id: 'type_cns', + category: 'type', + name: 'Catch & Shoot Pro', + description: 'Complete 5 Catch & Shoot sessions', + icon: '*', + }, + typeOtd: { + id: 'type_otd', + category: 'type', + name: 'Off the Dribble Pro', + description: 'Complete 5 Off the Dribble sessions', + icon: '*', + }, +} + diff --git a/src/lib/analytics.js b/src/lib/analytics.js index 56ebda2..04f6c48 100644 --- a/src/lib/analytics.js +++ b/src/lib/analytics.js @@ -215,6 +215,39 @@ export function computeKpis(sessions = [], opts = {}) { return { acc, volume, bestZone: best }; } +/** + * Find comparison window KPIs for current range. + * Looks back in equal-sized windows and returns first with volume > 0. + */ +export function findComparisonWindow({ + sessions = [], + currentFrom, + periodDays, + types, + aggOpts = {}, + maxLookbackWindows = 6, +}) { + let windowTo = addDaysISO(currentFrom, -1) + for (let step = 1; step <= maxLookbackWindows; step++) { + const windowFrom = addDaysISO(windowTo, -(periodDays - 1)) + const filtered = filterSessions(sessions, { + from: windowFrom, + to: windowTo, + types, + }) + const kpis = computeKpis(filtered, aggOpts) + if (Number(kpis.volume || 0) > 0) { + return { + kpis, + source: step === 1 ? 'prev' : 'last_active', + range: { from: windowFrom, to: windowTo }, + } + } + windowTo = addDaysISO(windowFrom, -1) + } + return { kpis: null, source: 'none', range: null } +} + /** ------------------------- * Utils * ------------------------*/ @@ -227,3 +260,9 @@ function num(v) { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; } + +function addDaysISO(iso, n) { + const d = new Date(`${iso}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + n) + return d.toISOString().slice(0, 10) +} diff --git a/src/lib/analytics.test.js b/src/lib/analytics.test.js index 1a984d0..19493bc 100644 --- a/src/lib/analytics.test.js +++ b/src/lib/analytics.test.js @@ -6,6 +6,7 @@ import { aggregateAccuracyByDate, aggregateByType, computeKpis, + findComparisonWindow, } from "./analytics.js"; function makeSession({ date, type = "spot", rounds = [] }) { @@ -133,3 +134,49 @@ test("computeKpis returns overall accuracy, volume, and best zone", () => { assert.equal(result.volume, 20); assert.deepEqual(result.bestZone, { key: "left_corner", label: "Left Corner", acc: 80 }); }); + +test("findComparisonWindow prefers immediate previous period when data exists", () => { + const sessions = [ + makeSession({ + date: "2026-01-06", + rounds: [makeRound({ zones: [makeZone({ position: "center", made: 3, attempts: 5 })] })], + }), + makeSession({ + date: "2026-01-10", + rounds: [makeRound({ zones: [makeZone({ position: "center", made: 4, attempts: 5 })] })], + }), + ]; + + const result = findComparisonWindow({ + sessions, + currentFrom: "2026-01-11", + periodDays: 5, + aggOpts: {}, + maxLookbackWindows: 3, + }); + + assert.equal(result.source, "prev"); + assert.equal(result.kpis.volume, 5); + assert.deepEqual(result.range, { from: "2026-01-06", to: "2026-01-10" }); +}); + +test("findComparisonWindow falls back to last active period when immediate previous is empty", () => { + const sessions = [ + makeSession({ + date: "2026-01-01", + rounds: [makeRound({ zones: [makeZone({ position: "center", made: 2, attempts: 4 })] })], + }), + ]; + + const result = findComparisonWindow({ + sessions, + currentFrom: "2026-01-11", + periodDays: 5, + aggOpts: {}, + maxLookbackWindows: 3, + }); + + assert.equal(result.source, "last_active"); + assert.equal(result.kpis.volume, 4); + assert.deepEqual(result.range, { from: "2026-01-01", to: "2026-01-05" }); +}); diff --git a/src/lib/ui/mobileDateFieldClass.js b/src/lib/ui/mobileDateFieldClass.js new file mode 100644 index 0000000..d141131 --- /dev/null +++ b/src/lib/ui/mobileDateFieldClass.js @@ -0,0 +1,10 @@ +export const MOBILE_DATE_INPUT_CLASS_GUARDS = [ + '[min-inline-size:0]', + '[-webkit-min-logical-width:0]', + 'min-w-0', + 'max-w-full', +] + +export const MOBILE_DATE_INPUT_BASE = + 'block w-full min-w-0 max-w-full [min-inline-size:0] [-webkit-min-logical-width:0]' + diff --git a/src/lib/ui/mobileDateFieldClass.test.js b/src/lib/ui/mobileDateFieldClass.test.js new file mode 100644 index 0000000..95f3bae --- /dev/null +++ b/src/lib/ui/mobileDateFieldClass.test.js @@ -0,0 +1,10 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { MOBILE_DATE_INPUT_BASE, MOBILE_DATE_INPUT_CLASS_GUARDS } from './mobileDateFieldClass.js' + +test('mobile date input class includes Safari overflow guard tokens', () => { + for (const token of MOBILE_DATE_INPUT_CLASS_GUARDS) { + assert.ok(MOBILE_DATE_INPUT_BASE.includes(token)) + } +}) + diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index b4b1b78..cec31e6 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -9,6 +9,7 @@ import { aggregateAccuracyByDate, aggregateByType, computeKpis, + findComparisonWindow, } from '../lib/analytics' import { toast } from 'sonner' import { seedSessions } from '../dev/seedSessions' @@ -21,8 +22,6 @@ import TabButton from './dashboard/TabButton' import LogSection from './dashboard/LogSection' import AnalyticsSection from './dashboard/AnalyticsSection' -const toISO = (d) => new Date(d).toISOString().slice(0, 10) -const addDays = (iso, n) => toISO(new Date(iso + 'T00:00:00Z').getTime() + n * 86400000) const normalizeDir = (d) => (d ? String(d).replace('->', '\u2192') : undefined) export default function Dashboard() { @@ -234,30 +233,18 @@ export default function Dashboard() { ) const kpis = useMemo(() => computeKpis(filtered, aggOpts), [filtered, aggOpts]) - const comparison = useMemo(() => { - const maxLookbackWindows = 6 - const types = filters.types && filters.types.length ? filters.types : undefined - let windowTo = addDays(filters.dateFrom, -1) - - for (let step = 1; step <= maxLookbackWindows; step++) { - const windowFrom = addDays(windowTo, -(periodDays - 1)) - const candidateSessions = filterSessions(rows, { - from: windowFrom, - to: windowTo, - types, - }) - const candidateKpis = computeKpis(candidateSessions, aggOpts) - if (Number(candidateKpis.volume || 0) > 0) { - return { - kpis: candidateKpis, - source: step === 1 ? 'prev' : 'last_active', - } - } - windowTo = addDays(windowFrom, -1) - } - - return { kpis: null, source: 'none' } - }, [rows, filters.dateFrom, filters.types, periodDays, aggOpts]) + const comparison = useMemo( + () => + findComparisonWindow({ + sessions: rows, + currentFrom: filters.dateFrom, + periodDays, + types: filters.types && filters.types.length ? filters.types : undefined, + aggOpts, + maxLookbackWindows: 6, + }), + [rows, filters.dateFrom, filters.types, periodDays, aggOpts] + ) const byPos = useMemo(() => aggregateByPosition(filtered, aggOpts), [filtered, aggOpts]) const trend = useMemo(() => aggregateAccuracyByDate(filtered, aggOpts), [filtered, aggOpts]) @@ -321,6 +308,8 @@ export default function Dashboard() { kpis={kpis} comparisonKpis={comparison.kpis} comparisonSource={comparison.source} + currentRange={{ from: filters.dateFrom, to: filters.dateTo }} + comparisonRange={comparison.range} zonesForUi={zonesForUi} trend={trend} byType={byType} diff --git a/src/pages/dashboard/AnalyticsSection.jsx b/src/pages/dashboard/AnalyticsSection.jsx index 09b8882..879139c 100644 --- a/src/pages/dashboard/AnalyticsSection.jsx +++ b/src/pages/dashboard/AnalyticsSection.jsx @@ -16,6 +16,8 @@ export default function AnalyticsSection({ kpis, comparisonKpis, comparisonSource, + currentRange, + comparisonRange, zonesForUi, trend, byType, @@ -60,6 +62,8 @@ export default function AnalyticsSection({ kpis={kpis} comparisonKpis={comparisonKpis} comparisonSource={comparisonSource} + currentRange={currentRange} + comparisonRange={comparisonRange} windowDays={filters.windowDays || 90} /> )} diff --git a/src/pages/dashboard/DashboardHeader.jsx b/src/pages/dashboard/DashboardHeader.jsx index e5591cb..c968d1b 100644 --- a/src/pages/dashboard/DashboardHeader.jsx +++ b/src/pages/dashboard/DashboardHeader.jsx @@ -4,12 +4,14 @@ import Button from '../../components/ui/Button' import Card from '../../components/ui/Card' import XPProgressBar from '../../components/XPProgressBar' import { useAchievements } from '../../hooks/useAchievements' +import { buildAchievementSections } from '../../lib/achievements/catalog' export default function DashboardHeader({ userEmail, totalXP, userUid, onCreateClick }) { const { achievements } = useAchievements(userUid) const [openMilestones, setOpenMilestones] = useState(false) const latest = achievements?.[0] const recentBadges = useMemo(() => achievements.slice(0, 5), [achievements]) + const sections = useMemo(() => buildAchievementSections(achievements), [achievements]) useEffect(() => { if (!openMilestones) return @@ -100,32 +102,41 @@ export default function DashboardHeader({ userEmail, totalXP, userUid, onCreateC -
- {achievements.length === 0 ? ( -

- No milestones unlocked yet. Log sessions to start earning badges. -

- ) : ( - achievements.map((a) => ( -
-
{a.icon || '*'}
-
-

{a.name || 'Achievement'}

-

- {a.description || 'Milestone unlocked'} -

- {formatUnlocked(a.unlockedAt) && ( -

- Unlocked: {formatUnlocked(a.unlockedAt)} -

- )} -
+
+ {sections.map((section) => ( +
+

{section.label}

+
+ {section.items.map((a) => ( +
+
{a.icon || '*'}
+
+

+ {a.name || 'Achievement'} {!a.unlocked && (locked)} +

+

+ {a.description || 'Milestone'} +

+ {a.unlocked ? ( + formatUnlocked(a.unlockedAt) && ( +

+ Unlocked: {formatUnlocked(a.unlockedAt)} +

+ ) + ) : ( +

Unlock by continuing training.

+ )} +
+
+ ))}
- )) - )} +
+ ))}