14 KiB
CLAUDE.md — PlayersEdge
This file tells Claude how to work in this codebase. Read it fully before making any changes.
Current version
Version:0.0.1
What This Project Is
PlayersEdge is a client-side Progressive Web App for tracking athlete biometric and sport-specific statistics. It supports five sports (American Football, Hockey, Baseball, Soccer, Basketball) with multi-sport athlete profiles. There is no backend, no database, and no API — all data lives in localStorage. The app is built with React 18 + Vite and deployed as static files behind Nginx on Ubuntu 24.04.
Commands
npm run dev # Dev server at http://localhost:5173 (hot reload)
npm run build # Production build → ./dist/
npm run preview # Serve production build at http://localhost:4173
There are no tests. There is no linter config. There is no TypeScript.
Architecture
State Management
All app state flows through a single React Context defined in src/hooks/useStore.jsx.
StoreProviderwraps the entire app inApp.jsx- Data is loaded from
localStoragekeyPlayersEdge_users_v1on first render; falls back toSEED_USERSfromseedData.jsif the key is absent or unparseable - Every change to
usersis auto-saved tolocalStoragevia auseEffect - There is no per-session state beyond what's in localStorage — refreshing the page preserves all data
Do not use component-level state for anything that needs to persist. Route it through useStore.
The store API:
const { users, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed } = useStore();
addUser(user)— generatesid(Date.now()) andjoinDateautomaticallyupdateUser(id, updates)— shallow-merges updates into the matching userdeleteUser(id)— removes by idgetUsersBySport(sport)— filtersuserswhereu.sports.includes(sport)resetToSeed()— replaces all users withSEED_USERS
Routing
React Router v6. All routes are defined once in App.jsx. The Nginx config uses try_files $uri $uri/ /index.html for SPA fallback — all routes resolve client-side.
/ → Home.jsx
/leaders → Leaders.jsx (accepts ?sport= query param)
/filter → Filter.jsx
/athletes → Athletes.jsx
/athletes/:id → AthleteDetail.jsx
/register → Register.jsx (accepts ?edit={id} query param)
Data Layer
src/data/seedData.js is the single source of truth for:
SPORTS— sport registry objectSPORT_STATS_DEFS— stat definitions per sport (key, label, type)BIOMETRIC_FIELDS— 12 biometric field definitionsSEED_USERS— 125 generated fake athletesgetUsersBySport(sport)— exported helper (also re-implemented in the store)
SPORT_STATS_DEFS governs everything — Leaders table columns, Filter dropdowns, Register form fields, and AthleteDetail stat grids all read from it. When adding a stat, add it here and nowhere else. The UI derives from the data.
Stat definition shape:
{ key: 'touchdowns', label: 'TDs', type: 'number' }
// type: 'text' | 'number' | 'decimal'
text— rendered as a select (position field only)number— integer; displayed as-isdecimal— float; displayed with.toFixed(2)in tables,.toFixed(1)in biometrics
User Object Shape
{
id: String, // Date.now() string for new users; '101'–'225' for seed users
firstName: String,
lastName: String,
name: String, // firstName + ' ' + lastName — must be kept in sync
email: String,
phone: String,
city: String,
country: String,
bio: String, // max 300 chars
socials: {
facebook: String, // @handle or empty string
instagram: String,
bluesky: String,
snapchat: String,
},
avatarColor: String, // hex — used for avatar bg tint when no profileImage
profileImage: String, // base64 data URL or empty string
primarySport: String, // one of the SPORTS keys
sports: String[], // array of sport keys — always contains primarySport
biometrics: {
height_cm, weight_kg, age, reach_cm,
dominant_hand, dominant_foot,
body_fat_pct, vo2_max, vertical_jump_cm,
'40_yard_dash', bench_press_reps, years_pro
},
sportStats: {
football: { position, touchdowns, passing_yards, ... },
hockey: { position, goals, assists, ... },
// only present for sports in the user's sports array
},
joinDate: String, // YYYY-MM-DD
}
Key invariants:
user.sportsalways has at least one entryuser.primarySportalways exists inuser.sportsuser.sportStatsonly has keys that appear inuser.sportsuser.namemust equaluser.firstName + ' ' + user.lastName
Component Conventions
File Organization
src/components/ — shared, reusable, no page logic
src/pages/ — one file per route, owns that route's state
src/hooks/ — useStore only (for now)
src/data/ — seedData only
Import Style
All imports use explicit .jsx extensions. No index barrel files. Example:
import Avatar from '../components/Avatar.jsx';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS, SPORT_STATS_DEFS } from '../data/seedData.js';
Component Patterns
- All components are functional with hooks — no class components
- Default export only — no named component exports
useMemois used for any list that filters or sorts — don't compute in render body- Hover effects are applied with
onMouseEnter/onMouseLeavesettinge.currentTarget.styledirectly (no CSS modules, no Tailwind) - Navigation after an action (register, delete) uses
useNavigatefrom react-router-dom
Form State Pattern
The Register.jsx form uses a single form object in state and a setField(dotPath, value) helper that deep-clones and traverses the path:
function setField(path, value) {
setForm(f => {
const clone = JSON.parse(JSON.stringify(f));
const parts = path.split('.');
let cur = clone;
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]];
cur[parts[parts.length - 1]] = value;
return clone;
});
}
// Usage:
setField('socials.instagram', '@handle');
setField('sportStats.football.touchdowns', 42);
Do not flatten the form structure. Do not use separate useState calls per field.
Toast Notifications
The Register page shows a .toast div using a local toast state string. The toast CSS class in index.css handles the animation entirely (keyframes toastIn at 0s, toastOut at 2.5s). Set the toast message, then clear it after 3s with setTimeout. Navigate after 1.8s on success.
Styling Conventions
All styles live in two places only:
src/index.css— global CSS custom properties and utility classes- Inline
style={{}}props on JSX elements — for component-specific layout
There are no CSS modules, no styled-components, no Tailwind, no SCSS. Do not introduce any CSS tooling.
CSS Variables (defined in :root in index.css)
Backgrounds: --bg-base, --bg-card, --bg-card2, --bg-input
Accents: --accent (#00d4ff), --accent2 (#ff6b35), --accent3 (#a855f7)
Text: --text-primary, --text-secondary, --text-muted
Borders: --border, --border-bright
Semantic: --success, --warning, --danger
Fonts: --font-display ('Barlow Condensed'), --font-body ('Barlow')
Layout: --nav-h (60px), --bottom-nav-h (64px mobile / 0px desktop), --radius (12px), --radius-sm (8px)
Always use CSS variables — never hardcode colors that match a variable.
Utility Classes (defined in index.css)
Use these classes rather than recreating their styles inline:
| Class | Purpose |
|---|---|
.page |
Page content wrapper — handles top/bottom nav padding, max-width 900px, centered |
.card |
Primary surface — --bg-card, 1px border, 12px radius, 20px padding |
.card2 |
Secondary surface — --bg-card2, 1px --border-bright, 8px radius, 16px padding |
.btn |
Base button — display, font, uppercase, transition |
.btn-primary |
Cyan fill button |
.btn-secondary |
Ghost button |
.btn-danger |
Red fill button |
.badge |
Small pill label |
.sport-{id} |
Sport-specific badge colors (football/hockey/baseball/soccer/basketball) |
.label |
Form field label — uppercase, 12px, --font-display |
.form-group |
Form field wrapper — 16px bottom margin |
.section-title |
Page heading — 28px, 800 weight, uppercase |
.section-sub |
Page subheading — 14px, --text-secondary |
.stat-val |
Large stat number — 24px, 800 weight, --accent |
.stat-lbl |
Stat label beneath value — 11px, uppercase, --text-muted |
.tab-bar |
Horizontal scrollable tab container |
.tab / .tab.active |
Tab item and active state |
.rank-badge |
Circular rank number (32px) |
.rank-1/2/3/n |
Gold/silver/bronze/neutral rank colors |
.grid-2 |
2-column grid, collapses to 1-col at 600px |
.grid-3 |
3-column grid, collapses to 2-col at 600px |
.avatar |
Circular avatar base styles |
.toast |
Fixed bottom notification pill |
.pwa-banner |
Fixed PWA install prompt |
Responsive Breakpoints
768px— desktop/mobile navigation switch (set in Nav.jsx<style>block)600px— grid column collapse (set in index.css media query)
The .page class handles the correct padding for both nav bars automatically. Every page component just needs <div className="page"> as its root — don't add manual top/bottom padding.
Navigation Height
--bottom-nav-h is set to 64px on mobile and 0px on desktop via media queries injected by Nav.jsx. The .toast and .pwa-banner use calc(var(--bottom-nav-h) + Npx) to position correctly above the bottom nav on mobile.
Adding a New Sport
-
src/data/seedData.js— add toSPORTS:lacrosse: { id: 'lacrosse', name: 'Lacrosse', emoji: '🥍', color: '#facc15' } -
src/data/seedData.js— add toSPORT_STATS_DEFS:lacrosse: [ { key: 'position', label: 'Position', type: 'text' }, { key: 'goals', label: 'Goals', type: 'number' }, // ... ] -
src/data/seedData.js— add to thepositionsobject insidegenUser:lacrosse: ['A', 'M', 'D', 'G'] -
src/pages/Register.jsx— add to the localpositionsobject (identical to above). -
src/index.css— add sport badge color class:.sport-lacrosse { background: #2a2a1a; color: #facc15; border: 1px solid #854d0e; } -
Optionally add a seed data generator function in
seedData.jsand wire it intogenSportStatsand the generation loop.
No changes needed to routing, Nav, Leaders, Filter, Athletes, AthleteDetail, or Avatar.
Adding a New Stat to an Existing Sport
Edit SPORT_STATS_DEFS in src/data/seedData.js only. Add the new stat definition object to the correct sport's array. The Leaders table (shows first 10 numeric stats), Filter page, Register form, and AthleteDetail grid all derive from this array automatically.
If the stat should appear in seed data, also update the relevant gen{Sport}Stats() function.
PWA
The PWA is configured in vite.config.js via vite-plugin-pwa. The service worker is auto-generated by Workbox and caches all {js,css,html,ico,png,svg,woff2} files. registerType: 'autoUpdate' means the SW updates silently on rebuild.
The install prompt (PWABanner.jsx) listens for beforeinstallprompt. Once dismissed, it sets localStorage.getItem('pwa_dismissed') and never shows again. This is separate from PlayersEdge_users_v1.
PWA icons (public/icon-192.png, public/icon-512.png, public/apple-touch-icon.png) are placeholder PNGs. Replace them with real images for production.
Deployment
The app builds to ./dist/ as fully static files. Serve them with any web server. The Nginx config in install.sh is the reference deployment.
Critical Nginx requirements:
try_files $uri $uri/ /index.html— required for React Router to work on direct URL access and refreshCache-Control: no-cacheonsw.js— required so the service worker updates properly- Long-term cache headers on
*.js,*.css— Vite adds content hashes to filenames so this is safe
To rebuild and redeploy after code changes:
npm run build
# dist/ is ready — rsync or cp to web root
Known Limitations / Things to Be Aware Of
- Profile images are base64 in localStorage. Large images can approach the ~5MB localStorage limit. There is no file size validation.
- No authentication. Any visitor can edit or delete any athlete.
40_yard_dashkey has a leading digit. Access it asbiometrics['40_yard_dash'], notbiometrics.40_yard_dash. This is intentional — the label reads naturally in the UI.user.nameis not auto-derived. When updatingfirstNameorlastName, also updatename. TheaddUserfunction handles this;updateUserdoes not — it takes whatever you pass.- Seed data is regenerated randomly on every
npm run build(if localStorage is cleared). The randomness is seeded byMath.random()with no fixed seed. This is intentional — seed data is fake and disposable. - The Leaders table shows the first 10 numeric stats for the selected sport. If a sport has stats that should be prioritised in the table, order them first in
SPORT_STATS_DEFS(after thepositiontext field). fmtValin Leaders shows—for0— this is a display choice, not a bug. Stats that are genuinely 0 (e.g. a QB's sack count as a defender stat) display as—to reduce noise.