402 lines
19 KiB
Markdown
402 lines
19 KiB
Markdown
# CLAUDE.md — StatSphere
|
||
|
||
This file tells Claude how to work in this codebase. Read it fully before making any changes.
|
||
|
||
---
|
||
|
||
## Current version
|
||
|
||
- `Version:` 0.0.2
|
||
|
||
### Bumping the version
|
||
|
||
When releasing a new version, update **all three** of these together — they must stay in sync:
|
||
|
||
1. `src/version.js` — `export const APP_VERSION = 'X.Y.Z';`
|
||
2. `package.json` — `"version": "X.Y.Z"`
|
||
3. This file — `Version:` line above
|
||
|
||
The version is displayed in the app as a fixed bottom-right label via `src/components/Footer.jsx`.
|
||
|
||
---
|
||
|
||
## What This Project Is
|
||
|
||
StatSphere is a 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. The app is built with React 18 + Vite and deployed as static files behind Nginx on Ubuntu 24.04.
|
||
|
||
**Current architecture:** All data lives in `localStorage` — there is no backend, no database, no API, and no server-side email or scraping. Authentication is simulated client-side. This is an intermediate state; the long-term direction (per `vibecode-prompt.md`) adds Postgres, real auth, per-match stats entry, email validation, CSV upload, URL scraping, and role-based access control. Do not add those backend features unless explicitly asked — build against the current localStorage architecture.
|
||
|
||
---
|
||
|
||
## 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`
|
||
- Users are loaded from `localStorage` key `statsphere_users_v1` on first render; falls back to `SEED_USERS` from `seedData.js` if the key is absent or unparseable
|
||
- Auth state is loaded from `localStorage` key `statsphere_auth_v1`
|
||
- Every change to `users` or `auth` is auto-saved to `localStorage` via `useEffect`
|
||
|
||
**Do not use component-level state for anything that needs to persist.** Route it through `useStore`.
|
||
|
||
**The store API:**
|
||
```js
|
||
const { users, auth, login, logout, register, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed } = useStore();
|
||
```
|
||
|
||
- `auth` — `{ currentUser: User|null, isLoggedIn: Boolean }`
|
||
- `login(email, password)` — returns `{ success, user }` or `{ success: false, error }`; sets auth state on success
|
||
- `logout()` — clears auth state
|
||
- `register(userData)` — checks for duplicate email, calls `addUser`, returns `{ success, user }` or `{ success: false, error }`
|
||
- `addUser(user)` — generates `id` (Date.now()) and `joinDate` automatically; returns new user
|
||
- `updateUser(id, updates)` — shallow-merges updates into the matching user
|
||
- `deleteUser(id)` — removes by id
|
||
- `getUserById(id)` — returns single user or undefined
|
||
- `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)
|
||
/login → Login.jsx (redirects to /dashboard if already logged in)
|
||
/dashboard → Dashboard.jsx (redirects to /login if not authenticated; role-aware content)
|
||
|
||
— auth-protected placeholder routes (coming soon) —
|
||
/stats-entry → StatsEntry.jsx
|
||
/manage-athletes → ManageAthletes.jsx
|
||
/bulk-upload → BulkUpload.jsx
|
||
/manage-teams → ManageTeams.jsx
|
||
/team-roster → TeamRoster.jsx
|
||
/my-clients → MyClients.jsx
|
||
/client-contacts → ClientContacts.jsx
|
||
/admin/users → AdminUsers.jsx (administrator only)
|
||
/admin/settings → AdminSettings.jsx (administrator only)
|
||
/admin/reports → AdminReports.jsx (administrator only)
|
||
```
|
||
|
||
### Data Layer
|
||
|
||
`src/data/seedData.js` is the single source of truth for:
|
||
- `USER_ROLES` — role registry object: `athlete`, `manager`, `team_manager`, `agent`, `administrator`
|
||
- `SPORTS` — sport registry object
|
||
- `SPORT_STATS_DEFS` — stat definitions per sport (key, label, type)
|
||
- `BIOMETRIC_FIELDS` — 13 biometric field definitions (includes both `DOB` date field and `age` number field)
|
||
- `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
|
||
role: String, // 'athlete' | 'manager' | 'team_manager' | 'agent' | 'administrator'
|
||
password: String, // plain text for now — no hashing in localStorage implementation
|
||
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,
|
||
DOB: String, // date of birth, YYYY-MM-DD format
|
||
age: Number, // computed from DOB at generation time — kept for display convenience
|
||
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
|
||
lastLogin: String, // ISO datetime string, set on login
|
||
}
|
||
```
|
||
|
||
**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
|
||
src/utils/ — pure helper functions shared across pages
|
||
```
|
||
|
||
### Athlete Name Display
|
||
|
||
**Always use `fmtName(user)` from `src/utils/formatName.js` in all lists, tables, and cards.** This displays `"Lastname, F."` (e.g. `"Thompson, M."`).
|
||
|
||
The only place full names appear is `AthleteDetail.jsx`, and only when `auth.isLoggedIn` is true. Non-logged-in visitors see the formatted name even on the profile page.
|
||
|
||
Contact details (email, phone) and social media handles are also hidden on `AthleteDetail.jsx` unless `auth.isLoggedIn`.
|
||
|
||
### 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 `statsphere_users_v1` and `statsphere_auth_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
|
||
```
|
||
|
||
### When to rerun install.sh
|
||
|
||
**You do not need to rerun `install.sh` for normal code changes.** Only rerun it when:
|
||
|
||
| Change | Action required |
|
||
|---|---|
|
||
| New package added to `dependencies` or `devDependencies` in `package.json` | `npm install` on the server (or full `install.sh` rerun) |
|
||
| Node.js version requirement changes | Full `install.sh` rerun |
|
||
| Nginx config changes | Full `install.sh` rerun (or manually edit `/etc/nginx/sites-available/playersedge`) |
|
||
| New system-level dependency (e.g. ImageMagick, a native Node addon) | Full `install.sh` rerun |
|
||
|
||
**Claude will flag this:** Any time a task adds or removes a package from `package.json`, Claude will explicitly note that `npm install` (or `install.sh`) must be rerun on the server before the next deployment.
|
||
|
||
---
|
||
|
||
## 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.
|
||
- **Passwords are stored plain text.** The localStorage auth implementation has no hashing. This is intentional for the current client-side-only phase.
|
||
- **Auth is client-side only.** Anyone who clears localStorage or inspects it can bypass auth. This is a known limitation of the localStorage architecture.
|
||
- **`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.
|
||
- **`user.age` and `user.biometrics.DOB` both exist.** `DOB` is the canonical date of birth (YYYY-MM-DD); `age` is derived from it at seed-generation time. Display `DOB` for logged-in users where personal details are shown; display `age` (or compute from DOB) for public views.
|
||
- **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.
|
||
- **Dashboard placeholder routes exist but are not yet implemented.** `/stats-entry`, `/manage-athletes`, `/bulk-upload`, `/manage-teams`, `/team-roster`, `/my-clients`, `/client-contacts`, `/admin/users`, `/admin/settings`, `/admin/reports` all have stub pages that show "Coming soon." They are the starting point for the features described in `vibecode-prompt.md`.
|
||
- **Sport stats are not collected at registration.** The Register wizard (Personal Info → Biometrics → Review) skips sport stats. Athletes add stats after account setup via `/stats-entry`.
|
||
- **Football positions** in `seedData.js` (internal `positions` object): `QB, RB, WR, TE, FB, OC, OG, OT, DE, DT, LB, CB, S, P, K`. Register.jsx does not have its own positions object — sport stats are entered post-registration.
|
||
- **Hockey positions** in `seedData.js`: `C, LW, RW, RD, LD, G`.
|