19 KiB
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:
src/version.js—export const APP_VERSION = 'X.Y.Z';package.json—"version": "X.Y.Z"- 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
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- Users are loaded from
localStoragekeystatsphere_users_v1on first render; falls back toSEED_USERSfromseedData.jsif the key is absent or unparseable - Auth state is loaded from
localStoragekeystatsphere_auth_v1 - Every change to
usersorauthis auto-saved tolocalStorageviauseEffect
Do not use component-level state for anything that needs to persist. Route it through useStore.
The store API:
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 successlogout()— clears auth stateregister(userData)— checks for duplicate email, callsaddUser, returns{ success, user }or{ success: false, error }addUser(user)— generatesid(Date.now()) andjoinDateautomatically; returns new userupdateUser(id, updates)— shallow-merges updates into the matching userdeleteUser(id)— removes by idgetUserById(id)— returns single user or undefinedgetUsersBySport(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)
/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,administratorSPORTS— sport registry objectSPORT_STATS_DEFS— stat definitions per sport (key, label, type)BIOMETRIC_FIELDS— 13 biometric field definitions (includes bothDOBdate field andagenumber field)SEED_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
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.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
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:
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 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 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
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_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.user.ageanduser.biometrics.DOBboth exist.DOBis the canonical date of birth (YYYY-MM-DD);ageis derived from it at seed-generation time. DisplayDOBfor logged-in users where personal details are shown; displayage(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 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.- 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/reportsall have stub pages that show "Coming soon." They are the starting point for the features described invibecode-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(internalpositionsobject):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.