Files
playersedge/CLAUDE.md
2026-04-07 16:42:17 -04:00

14 KiB
Raw Blame History

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.

  • 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:

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:

{ 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

{
  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:

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:

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:

    lacrosse: { id: 'lacrosse', name: 'Lacrosse', emoji: '🥍', color: '#facc15' }
    
  2. src/data/seedData.js — add to SPORT_STATS_DEFS:

    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:

    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:

    .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:

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.