337 lines
14 KiB
Markdown
337 lines
14 KiB
Markdown
# 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 `<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
|
||
|
||
1. **`src/data/seedData.js`** — add to `SPORTS`:
|
||
```js
|
||
lacrosse: { id: 'lacrosse', name: 'Lacrosse', emoji: '🥍', color: '#facc15' }
|
||
```
|
||
|
||
2. **`src/data/seedData.js`** — add to `SPORT_STATS_DEFS`:
|
||
```js
|
||
lacrosse: [
|
||
{ key: 'position', label: 'Position', type: 'text' },
|
||
{ key: 'goals', label: 'Goals', type: 'number' },
|
||
// ...
|
||
]
|
||
```
|
||
|
||
3. **`src/data/seedData.js`** — add to the `positions` object inside `genUser`:
|
||
```js
|
||
lacrosse: ['A', 'M', 'D', 'G']
|
||
```
|
||
|
||
4. **`src/pages/Register.jsx`** — add to the local `positions` object (identical to above).
|
||
|
||
5. **`src/index.css`** — add sport badge color class:
|
||
```css
|
||
.sport-lacrosse { background: #2a2a1a; color: #facc15; border: 1px solid #854d0e; }
|
||
```
|
||
|
||
6. Optionally add a seed data generator function in `seedData.js` and wire it into `genSportStats` and 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 refresh
|
||
- `Cache-Control: no-cache` on `sw.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:
|
||
```bash
|
||
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_dash` key has a leading digit.** Access it as `biometrics['40_yard_dash']`, not `biometrics.40_yard_dash`. This is intentional — the label reads naturally in the UI.
|
||
- **`user.name` is not auto-derived.** When updating `firstName` or `lastName`, also update `name`. The `addUser` function handles this; `updateUser` does not — it takes whatever you pass.
|
||
- **Seed data is regenerated randomly on every `npm run build`** (if localStorage is cleared). The randomness is seeded by `Math.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 the `position` text field).
|
||
- **`fmtVal` in Leaders shows `—` for `0`** — 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.
|