# 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 ```bash 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`. - `StoreProvider` wraps the entire app in `App.jsx` - Data is loaded from `localStorage` key `PlayersEdge_users_v1` on first render; falls back to `SEED_USERS` from `seedData.js` if the key is absent or unparseable - Every change to `users` is auto-saved to `localStorage` via a `useEffect` - 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:** ```js const { users, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed } = useStore(); ``` - `addUser(user)` — generates `id` (Date.now()) and `joinDate` automatically - `updateUser(id, updates)` — shallow-merges updates into the matching user - `deleteUser(id)` — removes by id - `getUsersBySport(sport)` — filters `users` where `u.sports.includes(sport)` - `resetToSeed()` — replaces all users with `SEED_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 object - `SPORT_STATS_DEFS` — stat definitions per sport (key, label, type) - `BIOMETRIC_FIELDS` — 12 biometric field definitions - `SEED_USERS` — 125 generated fake athletes - `getUsersBySport(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: ```js { key: 'touchdowns', label: 'TDs', type: 'number' } // type: 'text' | 'number' | 'decimal' ``` - `text` — rendered as a select (position field only) - `number` — integer; displayed as-is - `decimal` — float; displayed with `.toFixed(2)` in tables, `.toFixed(1)` in biometrics --- ## User Object Shape ```js { 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.sports` always has at least one entry - `user.primarySport` always exists in `user.sports` - `user.sportStats` only has keys that appear in `user.sports` - `user.name` must equal `user.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: ```js 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 - `useMemo` is used for any list that filters or sorts — don't compute in render body - Hover effects are applied with `onMouseEnter`/`onMouseLeave` setting `e.currentTarget.style` directly (no CSS modules, no Tailwind) - Navigation after an action (register, delete) uses `useNavigate` from 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: ```js 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:** 1. `src/index.css` — global CSS custom properties and utility classes 2. 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 `