Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/components/AnalyticsFilters.jsx
Original file line number Diff line number Diff line change
@@ -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']

Expand Down Expand Up @@ -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'

Expand Down
45 changes: 41 additions & 4 deletions src/components/KpiTiles.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<div className="grid grid-cols-3 gap-1.5">
<KpiCard title={`Accuracy ${suffix}`} value={accText} delta={accDelta} />
<KpiCard title={`Volume ${suffix}`} value={volText} delta={volDelta} />
<KpiCard title="Best Zone" value={bestText} delta={bestDelta} />
<div className="space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<KpiCard title={`Accuracy ${suffix}`} value={accText} delta={accDelta} />
<KpiCard title={`Volume ${suffix}`} value={volText} delta={volDelta} />
<KpiCard title="Best Zone" value={bestText} delta={bestDelta} />
</div>

<div className="flex items-center justify-end">
<button
type="button"
className="text-[10px] md:text-xs px-2 py-1 rounded-lg border dark:border-neutral-700 hover:bg-black/5 dark:hover:bg-white/5"
onClick={() => setShowDetails((v) => !v)}
>
{showDetails ? 'Hide compare details' : 'Compare details'}
</button>
</div>

{showDetails && (
<div className="text-[10px] md:text-xs rounded-lg border dark:border-neutral-700 p-2 space-y-1 text-neutral-600 dark:text-neutral-300">
<p>
Source: <span className="font-medium">{comparisonLabel}</span>
</p>
<p>
Current: <span className="font-medium">{formatRange(currentRange)}</span>
</p>
<p>
Baseline: <span className="font-medium">{formatRange(comparisonRange)}</span>
</p>
</div>
)}
</div>
)
}
Expand Down Expand Up @@ -80,3 +112,8 @@ function KpiCard({ title, value, delta }) {
</div>
)
}

function formatRange(range) {
if (!range?.from || !range?.to) return 'n/a'
return `${range.from} -> ${range.to}`
}
38 changes: 38 additions & 0 deletions src/lib/achievements/catalog.js
Original file line number Diff line number Diff line change
@@ -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,
}))
}
23 changes: 23 additions & 0 deletions src/lib/achievements/catalog.test.js
Original file line number Diff line number Diff line change
@@ -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)
})

66 changes: 66 additions & 0 deletions src/lib/achievements/definitions.js
Original file line number Diff line number Diff line change
@@ -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: '*',
},
}

39 changes: 39 additions & 0 deletions src/lib/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
* ------------------------*/
Expand All @@ -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)
}
47 changes: 47 additions & 0 deletions src/lib/analytics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
aggregateAccuracyByDate,
aggregateByType,
computeKpis,
findComparisonWindow,
} from "./analytics.js";

function makeSession({ date, type = "spot", rounds = [] }) {
Expand Down Expand Up @@ -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" });
});
10 changes: 10 additions & 0 deletions src/lib/ui/mobileDateFieldClass.js
Original file line number Diff line number Diff line change
@@ -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]'

10 changes: 10 additions & 0 deletions src/lib/ui/mobileDateFieldClass.test.js
Original file line number Diff line number Diff line change
@@ -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))
}
})

Loading