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.
+ )}
+
+
+ ))}
- ))
- )}
+
+ ))}