35 KiB
PlayersEdge – Athlete Stats Platform
Vibe-Coding Prompt
Project Overview
Build StatSphere, a full-featured Progressive Web App (PWA) for tracking athlete biometric and sport-specific statistics. The app supports five sports at launch with a data model designed for easy expansion. Athletes can be registered in multiple sports simultaneously. The platform features a stat leaders board, advanced filtering, athlete profiles, and a multi-step registration form. A postgres database will be used for all user personal, biometric and stat data. Users must be able to login to use the site for their own profile and stats data management. Registration should be done via a multi-step form, which includes user login (email address as user name and password (with validation for both)). Initial registion should not include sports stats data.
There should be user roles to select from when registering: athelete, manager (manages multiple individual athletes), team_manager (manages a specific team of atheletes), agent (can get personal contact information of the athlete), administrator. There will be a cost for to use this web app based on the role (which will take the user to a payment page), the current cost will be zero ($0) dollars at checkout, but checkout will be a dummy process and will assume the amount was paid and registration will proceed. The athletes account must be created first (manager and team_manager can use a CSV to bulk create the accounts). An email will be sent to the athlete to 1. validate/complete account profile details (and email address). 2. change password (if account was created by a manager) 3. authorize manager or team_manager (if account was created by a manager). Once authorized, the manager/team_manager can update do everything on behalf of the athlete except change personal details (email, lastname, firstname, dob, address, phone and social media feeds) after the athlete has approved them.
When an athlete/manager logs in, they will be taken to the stats control panel will be able to add sports stats data for themselves. Stats data should be entered on a per match basis. Default stats should be based on the players position, with the option to add any individual stat for the given sport. (example: OG does not have a default for a receiving_touchdown stat, so they should have the option to add it as needed). There should be an option to input a URL or update a document (ie: game sheet) to validate the new stats. The manager can enter a URL for personal stats of each athlete and the website will attempt to scrape the URL weekly for automatic stat data entry. Scraped stat data will be considered validated.
When a manager logs in, the control panel will display a list a athletes they manage, they cannot change contact details once the athlete has confirmed those details. The can manage sports and stats for each sport on behalf of the athlete. There will be a CSV upload option to bulk enter stats for a given athlete. The default positional stats are required. Manual entries for custom stats are required. The manager can enter a URL for personal stats of each athlete and the website will attempt to scrape the URL weekly for automatic stat data entry. Scraped stat data will be considered validated.
When a team_manager logs in, the control panel will display a list teams they manage, clicking the team will display a list of athletes for the team. The manager cannot change contact details for an athlete once the athlete has confirmed those details. They can manage stats on behalf of the athlete for the given team/sport. There will be a CSV upload option to bulk enter stats for a given athlete. The default positional stats are required. Manual entries for custom stats are required. The manager can enter a URL for personal stats of each athlete and the website will attempt to scrape the URL weekly for automatic stat data entry. Scraped stat data will be considered validated.
Part of the profile setup will be the team the athlete plays for and the league the team is member of. There will be a form to search for the league and team (if it either do not exist), the athlete/manager/team_manager will need to add the league name and team name, with the URLs for both. The URLs will be validated by a site scrape to ensure the names match. Once validated, the league and team will be available for other athletes/managers/team_managers to use.
Viewable stats, whether they be for the individual athlete, or a comparison search, should be in table format. The default leaders table must be for the current year, with an options to view specific seasons for the given year (preseason, regular season, playoffs) and the option to view previous years. Athletes are listed by lastname, first inititial, no personal contact information (including social media links, teams or leagues) is displayed unless the logged in user is the athlete themself, a manager/team_manager of the athlete, or an agent. General users (not logged in) can view the leaderboard and and search pages, no personal contact details for any athlete is displayed (other than generic things like city/country/age), and it will only display biometric details and stats.
On an athelete specific details view, the display will be the users profile image aligned to the left, with biometric data (height, weight, age (DOB for logged users), shoots or dominate hand) in a small table beside the profile photo. Then a table beside that showing current year totals for the given sport. A table underneath displaying carreer totals for the given sport. If the postion does not have game stats, then the two tables will show match details (wins, losses, started). Below the profile/current year/career section, will show the rest of the biometric data, and per match stats, with an options for choose a different seasons (preseason, regular season, playoffs) and/or years.
All forms should be logically aligned and labeled, responsive and easy to use. For example, QBs in American football should have passing stats (including passing TDs), then rushing stats (including rushing TDs), etc.
Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Framework | React 18 | Functional components + hooks throughout |
| Routing | React Router v6 | BrowserRouter, Routes, Route, Link, useNavigate, useSearchParams |
| Build tool | Vite 5 | Fast dev server + production build |
| PWA | vite-plugin-pwa + Workbox | Auto service worker, manifest, offline caching |
| Styling | Pure CSS (no framework) | CSS custom properties for theming, no Tailwind |
| Icons | lucide-react | Minimal usage; emoji used for sport icons |
| State | React Context + localStorage | StoreProvider wraps the whole app |
| Fonts | Google Fonts | Barlow Condensed (display) + Barlow (body) |
| Web server | Nginx | SPA fallback, static file serving, gzip |
| Target OS | Ubuntu 24.04 LXC | Auto-installer script included |
package.json dependencies:
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"lucide-react": "^0.460.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5",
"workbox-window": "^7.3.0"
}
}
Visual Design System
Theme: Dark Sports
A high-contrast dark theme inspired by professional sports analytics dashboards.
:root {
--bg-base: #0a0f1e; /* page background — near-black navy */
--bg-card: #111827; /* primary card surface */
--bg-card2: #1a2235; /* secondary card / nested surface */
--bg-input: #1e2d45; /* form input background */
--accent: #00d4ff; /* primary accent — electric cyan */
--accent2: #ff6b35; /* secondary accent — orange */
--accent3: #a855f7; /* tertiary accent — purple */
--text-primary: #f0f4ff; /* near-white body text */
--text-secondary: #8899bb;/* muted labels */
--text-muted: #4a5875; /* de-emphasised text */
--border: #1e2d45; /* card borders */
--border-bright: #2a3d5a; /* input/hover borders */
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--font-display: 'Barlow Condensed', sans-serif;
--font-body: 'Barlow', sans-serif;
--nav-h: 60px;
--bottom-nav-h: 64px; /* 0px on desktop, 64px on mobile */
--radius: 12px;
--radius-sm: 8px;
}
Typography
- Display / headings:
Barlow Condensed— weight 700–900, uppercase, letter-spacing 0.02–0.1em - Body:
Barlow— weight 400–600 - Section titles: 28px, weight 800, uppercase,
--font-display - Stat values: 20–24px, weight 800,
--font-display, colored--accent - Stat labels: 11px, uppercase, letter-spacing 0.06em,
--text-muted,--font-display - Table headers: 11px, uppercase, letter-spacing 0.1em,
--text-muted,--font-display
Sport Color Coding
Each sport has a distinct badge color scheme (background / text / border):
| Sport | Class | BG | Text |
|---|---|---|---|
| American Football | .sport-football |
#1a3a1a |
#4ade80 |
| Hockey | .sport-hockey |
#1a2e4a |
#60a5fa |
| Baseball | .sport-baseball |
#3a1a1a |
#f87171 |
| Soccer | .sport-soccer |
#2a1a3a |
#c084fc |
| Basketball | .sport-basketball |
#3a2a1a |
#fb923c |
Badges use class .badge + .sport-{id} and are uppercase, 11px, weight 700, --font-display.
Rank Badges
Circular 32px badges for leaderboard ranks:
- Rank 1: gold
#ffd700, black text - Rank 2: silver
#c0c0c0, black text - Rank 3: bronze
#cd7f32, black text - Rank 4+:
--bg-input,--text-secondary
Reusable CSS Classes
.page— content wrapper:padding: (nav-h + 20px) 16px (bottom-nav-h + 20px), max-width 900px, centered.card—background: --bg-card, 1px border, radius 12px, padding 20px.card2—background: --bg-card2, 1px border, radius 8px, padding 16px.btn— inline-flex, gap 8px, padding 10px 20px, radius 8px, font-display, 14px, 600 weight, uppercase.btn-primary— background--accent, color black.btn-secondary— transparent, border--border-bright, hover: border + text →--accent.btn-danger— background--danger, white text.badge— inline-flex, padding 3px 10px, radius 20px, 11px, 700 weight, uppercase.label— 12px, uppercase, letter-spacing 0.08em,--text-secondary,--font-display, display block, margin-bottom 6px.form-group— margin-bottom 16px.tab-bar— flex row,--bg-cardbg, border, radius 8px, padding 4px, overflow-x auto.tab— padding 8px 16px, radius 6px, 13px, 700 weight, uppercase;.active→ background--accent, color black.grid-2— 2-column grid, gap 16px; collapses to 1-col below 600px.grid-3— 3-column grid, gap 12px; collapses to 2-col below 600px.section-title—--font-display, 28px, 800, uppercase.section-sub— 14px,--text-secondary, margin-bottom 24px.stat-val—--font-display, 24px, 800,--accent, line-height 1.stat-lbl— 11px,--text-muted, uppercase, letter-spacing 0.06em,--font-display.toast— fixed bottom (above bottom-nav), centered, success green pill, animated in/out.pwa-banner— fixed bottom (above bottom-nav), full-width,--bg-card2, cyan border, install prompt
Forms
All input, select, textarea elements:
- Background:
--bg-input - Border: 1px solid
--border-bright - Border-radius:
--radius-sm - Padding: 10px 14px
- Color:
--text-primary - Focus: border-color →
--accent - Width: 100%
Tables
border-collapse: collapse, full width, 14pxth: 10px 12px padding,--font-display, 11px, 700, uppercase,--text-muted, bottom border, pointer cursor, hover →--accenttd: 12px 12px padding, bottom border- Row hover: background
rgba(0,212,255,0.03) - Active sort column header:
--accentcolored - Active sort column values:
--accent, 16px, weight 800
File Structure
statsphere/
├── public/
│ ├── favicon.svg
│ ├── icon-192.png
│ ├── icon-512.png
│ └── apple-touch-icon.png
├── src/
│ ├── components/
│ │ ├── Nav.jsx
│ │ ├── Avatar.jsx
│ │ ├── SportBadge.jsx
│ │ └── PWABanner.jsx
│ ├── data/
│ │ └── seedData.js
│ ├── hooks/
│ │ └── useStore.jsx
│ ├── pages/
│ │ ├── Home.jsx
│ │ ├── Leaders.jsx
│ │ ├── Filter.jsx
│ │ ├── Athletes.jsx
│ │ ├── AthleteDetail.jsx
│ │ └── Register.jsx
│ ├── App.jsx
│ ├── main.jsx
│ └── index.css
├── index.html
├── vite.config.js
├── package.json
├── install.sh
└── README.md
Data Model
Sports Registry (SPORTS)
An object keyed by sport ID. Each entry:
{
id: 'football',
name: 'American Football',
emoji: '🏈',
color: '#4ade80',
}
Sports: football, hockey, baseball, soccer, basketball
Stat Definitions (SPORT_STATS_DEFS)
An object keyed by sport ID. Each sport has an array of stat definition objects:
{ key: 'touchdowns', label: 'TDs', type: 'number' }
// type: 'text' | 'number' | 'decimal'
// 'text' = select/dropdown (used for position)
// 'number' = integer
// 'decimal' = float, displayed with toFixed(2)
User can add any stat in the sport they are participating in, but in some sports, each position will have default stats. When comparing users in specific positions, the default stats will be used. Clicking on the user name will show all the extended stats entered.
Football: All Positions: games_played (text), date, opponent QB: passing_touchdowns, passing_yards, rushing_yards, receiving_yards, passer_rating (decimal), pass_completions, pass_attempts, completion_pct (decimal), interceptions_thrown, sacks_taken (decimal) QB, RB, WR, TE, FB: receiving_touchdowns, rushing_touchdowns, rushing_attempts, rushing_yards, receptions, receiving_yards, targets, yards_after_catch, fumbles, fumbles_lost DT, DE, LB, S, CB: tackles, passes_defended, fumble_roveries, interception_recoveries P: punts, punt_yards, punt_avg, punt_long, punt_blocked K: field_goal_distance_success,field_goal_distance_missed, field_goal_percentage (decimal), extra_point_attempts, extra_points_made,
Hockey: position (text), goals, assists, points, plus_minus, penalty_minutes, shots, shot_pct (decimal), games_played, save_pct (decimal), gaa (decimal), shutouts, toi (decimal), faceoff_pct (decimal), power_play_goals, shorthanded_goals
Baseball (16 stats): position (text), batting_avg (decimal), home_runs, rbi, runs, hits, stolen_bases, obp (decimal), slg (decimal), ops (decimal), era (decimal), strikeouts_p, wins, whip (decimal), games_played, war (decimal)
Soccer (16 stats): position (text), goals, assists, games_played, minutes_played, shots, shots_on_target, pass_accuracy (decimal), key_passes, dribbles, tackles, yellow_cards, red_cards, save_pct (decimal), clean_sheets, xg (decimal)
Basketball (16 stats): position (text), points_per_game (decimal), rebounds_per_game (decimal), assists_per_game (decimal), steals_per_game (decimal), blocks_per_game (decimal), fg_pct (decimal), three_pt_pct (decimal), ft_pct (decimal), games_played, minutes_per_game (decimal), turnovers (decimal), plus_minus (decimal), efficiency (decimal), true_shooting (decimal), usage_rate (decimal)
Biometric Fields (BIOMETRIC_FIELDS)
12 fields displayed on every athlete profile:
height_cm (number), weight_kg (number), age (number), reach_cm (number),
dominant_hand (text), dominant_foot (text), body_fat_pct (decimal),
vo2_max (decimal), vertical_jump_cm (number), 40_yard_dash (decimal),
bench_press_reps (number), years_pro (number)
Athlete User Object
{
id: String, // unique, Date.now() for new users
firstName: String,
lastName: String,
name: String, // firstName + ' ' + lastName
email: String,
phone: String,
city: String,
country: String,
bio: String, // max 300 chars
socials: {
facebook: String, // optional @handle
instagram: String,
bluesky: String,
snapchat: String,
},
avatarColor: String, // hex color for avatar fallback
profileImage: String, // base64 data URL or empty string
primarySport: String, // sport id
sports: String[], // array of sport ids (1 or more)
biometrics: {
height_cm: Number,
weight_kg: Number,
DOB: date (format:yyyy-mm-dd),
reach_cm: Number,
dominant_hand: String,
dominant_foot: String,
body_fat_pct: Number,
vo2_max: Number,
vertical_jump_cm: Number,
'40_yard_dash': Number,
bench_press_reps: Number,
years_pro: Number,
},
sportStats: {
football: { position, touchdowns, passing_yards, ... },
hockey: { position, goals, assists, ... },
// only sports the athlete is registered in
},
joinDate: String, // ISO date string YYYY-MM-DD
}
Positions Per Sport
football: ['QB','RB','WR','TE','OC',"OG","OT",'DE','DT','LB','CB','S','P','K']
hockey: ['C','LW','RW','RD','LD','G']
baseball: ['SP','RP','C','1B','2B','3B','SS','LF','CF','RF','DH']
soccer: ['GK','CB','LB','RB','CDM','CM','CAM','LW','RW','ST']
basketball: ['PG','SG','SF','PF','C']
Seed Data Generation
Generate 125 athlete records (25 per sport). Approximately 30% of athletes play a second sport randomly selected from the remaining four. All data is fake but realistic.
Name pool:
- First names (30): Marcus, Tyler, Jordan, Devon, Chase, Zach, Ryan, Cole, Drew, Cody, Logan, Austin, Blake, Jalen, Myles, Trey, Dylan, Caleb, Eli, Noah, Liam, Aiden, Cameron, Hunter, Bryce, Jake, Luke, Owen, Seth, Finn
- Last names (30): Thompson, Williams, Johnson, Davis, Martinez, Anderson, Taylor, Jackson, White, Harris, Martin, Garcia, Walker, Robinson, Lewis, Lee, Allen, Young, Hernandez, King, Wright, Scott, Torres, Nguyen, Hill, Flores, Green, Adams, Nelson, Baker
Biometric ranges by sport (height cm / weight kg):
- Football: h 175–205, w 85–140
- Hockey: h 175–198, w 82–105
- Baseball: h 170–200, w 82–110
- Soccer: h 165–195, w 70–90
- Basketball: h 185–220, w 85–120
Common biometrics: age 20–38, reach = height + 5–20, body_fat_pct 6–18%, vo2_max 48–72, vertical_jump 55–90cm, 40_yard_dash 4.30–5.20s, bench_press_reps 10–35, years_pro 1–18
Stat generation is position-aware. Key rules:
- Football: QBs get passing_yards (2800–5200), rushers get rushing_yards, WR/TE get receiving_yards; defenders get tackles/sacks/interceptions; non-QBs get 0 for passing stats
- Hockey: Goalies (G) get save_pct (0.900–0.935), gaa (1.8–3.5), shutouts (1–12); skaters get goals (5–55), assists, points; Centers get faceoff_pct (42–58%)
- Baseball: Pitchers (SP/RP) get era (2.10–5.80), wins (4–22), whip (0.92–1.60), strikeouts_p; position players get batting_avg (0.220–0.350), home_runs, obp, slg, ops
- Soccer: GK gets save_pct (65–82%), clean_sheets; defenders get fewer goals; attackers get more shots/xg
- Basketball: Centers/PFs get higher rebounds; PGs get higher assists; all get per-game averages
Socials: each athlete randomly gets 0–4 platforms (60% chance per platform), handle = @firstname_lastname##
Cities: New York, Los Angeles, Chicago, Houston, Phoenix, Toronto, Vancouver, Dallas, Miami, Boston, Seattle, Denver, Atlanta, Minneapolis, Detroit
Countries: 80% USA, 20% Canada/Mexico/UK/Brazil/Australia
Avatar colors pool: ['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899','#06b6d4','#84cc16']
Bio pool (10 rotating):
- "Dedicated athlete with a passion for the game and improving every day."
- "Competitive spirit who gives 110% on and off the field."
- "Team player first, stats second. Love the game, love my teammates."
- "Training hard to be the best version of myself every season."
- "Multiple-sport athlete who believes cross-training builds champions."
- "Community advocate and sports mentor for youth programs."
- "Playing to inspire the next generation of athletes."
- "Fueled by competition and the pursuit of excellence."
- "Veteran player with years of experience and a championship mindset."
- "Rising star hungry for the next level."
Export SEED_USERS as the merged, deduplicated array of all generated athletes.
Global State (useStore.jsx)
A React Context provider wrapping the entire app. Persists to localStorage under key statsphere_users_v1. Loads seed data on first run (when localStorage is empty).
Exposed API:
{
users, // User[] — full array
addUser(user), // adds new user, generates id + joinDate, returns new user
updateUser(id, updates), // merges updates into matching user
deleteUser(id), // removes user by id
getUserById(id), // returns single user or undefined
getUsersBySport(sport), // returns users where sports array includes sport
resetToSeed(), // restores SEED_USERS to localStorage
}
Load order: try JSON.parse(localStorage.getItem('statsphere_users_v1')) → fall back to SEED_USERS. Save: useEffect on users changes → localStorage.setItem(...).
Components
Nav.jsx
Two navigation bars rendered simultaneously:
Desktop top bar (visible ≥768px):
- Fixed, full-width, height
--nav-h(60px) - Background:
rgba(10,15,30,0.95)withbackdrop-filter: blur(12px) - Left: Logo mark (36px square div with gradient
#00d4ff → #a855f7, 8px radius, "S" text) + wordmark "STATSPHERE" where SPHERE is--accentcolored - Right: horizontal nav links — Home, Leaders, Search, Athletes, Register
- Active link:
--accentcolored text +rgba(0,212,255,0.08)background - Font:
--font-display, 12px, 700, uppercase, letter-spacing 0.08em
Mobile bottom nav (visible <768px):
- Fixed, full-width, height
var(--bottom-nav-h)(64px) - Background:
rgba(10,15,30,0.97)withbackdrop-filter: blur(12px) - 5 equal-width items: ⚡ Home, 🏆 Leaders, 🔍 Search, 👤 Athletes, ➕ Register
- Each item: icon (20px emoji) above label (10px, uppercase,
--font-display) - Active:
--accentcolor; inactive:--text-muted - Accounts for
env(safe-area-inset-bottom)padding
Routes: / /leaders /filter /athletes /register
Avatar.jsx
Props: user object, size (number, default 40)
If user.profileImage is set: render <img> with border-radius: 50%, object-fit: cover
Else: render div with:
border-radius: 50%- Background:
${user.avatarColor}22(10% opacity) - Border:
2px solid ${user.avatarColor}44(25% opacity) - Text: initials (
firstName[0] + lastName[0]),--font-display, 800 weight, coloreduser.avatarColor - Font size:
size * 0.35
SportBadge.jsx
Props: sport (string id)
Renders: <span className={\badge sport-${sport}`}>{emoji} {name}`
PWABanner.jsx
Listens for the browser beforeinstallprompt event. If not already dismissed (check localStorage.getItem('pwa_dismissed')), shows a fixed banner above the bottom nav when the event fires.
Banner content:
- ⚡ icon
- Title: "Install StatSphere"
- Subtitle: "Add to home screen for the best experience"
- "Install" button: calls
prompt.prompt()on click - "×" dismiss button: sets
localStorage.setItem('pwa_dismissed','1')and hides
Styling: .pwa-banner class — --bg-card2 background, 1px --accent border, 12px radius, box-shadow 0 4px 32px rgba(0,212,255,0.15)
Pages
Home.jsx — Dashboard (/)
Hero section:
- Full-width, centered
- Radial gradient overlay:
rgba(0,212,255,0.08)at top, transparent by 60% - Heading (responsive font clamp 36px–72px, 900 weight): "YOUR STATS," (white) + line break + "YOUR LEGACY" (cyan)
- Subtitle: 16px,
--text-secondary, max-width 500px - Two CTA buttons: "Register Athlete" (primary) + "View Leaders" (secondary)
Stats overview: 3-column .grid-3 of .card tiles — Total Athletes, Number of Sports (5), Multi-Sport Athletes count
Sports grid:
repeat(auto-fill, minmax(160px, 1fr))grid- Each sport is a card with: emoji (36px), sport name (13px, uppercase, sport's color), athlete count (12px, muted)
- Links to
/leaders?sport={id} - Hover: border-color → sport.color, translateY(-2px)
Recent Athletes: last 5 athletes by joinDate, shown as a stacked list inside a single .card. Each row: Avatar (42px) + name/city + sport badges. Links to /athletes/:id.
Leaders.jsx — Stat Leaders (/leaders)
Controls:
- Sport tab bar (5 tabs, horizontal scroll on mobile)
- "Sort By" select: all numeric stats for the current sport
- "Direction" select: "High → Low" / "Low → High"
- "Show" select: 25 (default) / 50 / 100
Behavior:
- Reads
?sport=query param on mount to set initial sport - When sport changes, resets sort key to the second numeric stat for that sport (first meaningful stat after position)
- Filtered list =
getUsersBySport(sport)→ only users withsportStats[sport]defined → sorted by selected stat → sliced to row limit
Table columns:
#— rank badge (1/2/3 gold/silver/bronze, rest neutral)- Athlete — Avatar (34px) + name (link to profile) + city
- Pos — position string from sportStats
- 10 numeric stat columns (first 10 numeric stats for the sport, skipping
position)
Active sort column: header colored --accent with ↑/↓ arrow; cell values colored --accent, 16px, 800 weight
Value formatting: 0 or null/undefined → display —; type decimal → .toFixed(2); type number → raw value
Table is horizontally scrollable on mobile via overflow-x: auto wrapper
Footer: "Showing N athletes · Sorted by {stat label}"
Filter.jsx — Search & Filter (/filter)
Three filter card sections:
Section 1 — General Search:
- Text input: search by name, city, country, bio (case-insensitive substring)
- "Sport" select: All Sports + 5 sports
- "Position" select: appears only when a specific sport is selected; populated dynamically from unique positions found in that sport's data
Section 2 — Sport Stat Filter:
- "Sport" select (defaults to football)
- "Stat" select: numeric stats for the selected sport
- Min / Max number inputs (2-column inline grid)
- Filters athletes where
sportStats[filterSport][filterKey]is within [min, max]
Section 3 — Biometric Filter:
- "Biometric" select: all non-text biometric fields
- Min / Max number inputs
Result controls:
- Sort By: Name / Age / Join Date
- Order: A→Z/Low→High or Z→A/High→Low
- Show: 25 / 50 / 100
- "Clear All" button resets every filter state
Results list: flat stacked list inside .card, same row format as Home recent athletes (Avatar + name/city/age/height + sport badges). Links to athlete profile. Shows count above list. Empty state: "No athletes match your filters"
Filtering is applied in order: text search → sport → position → stat filter → biometric filter → sort → slice.
Athletes.jsx — Athlete List (/athletes)
Controls: Text search (name or city) + sport dropdown filter, shown inline
Grid: repeat(auto-fill, minmax(280px, 1fr))
Each card:
- Avatar (48px) + name + city/country
- Sport badges row
- 3 quick-stat tiles in a flex row: Date of Birth, Height (cm), Years Played — each with
--font-displayvalue in--accentand tiny uppercase label - Hover: border-color →
--accent, translateY(-2px) - Links to
/athletes/:id
"+ Add" button (primary, small) top-right links to /register
Empty state: centered message
AthleteDetail.jsx — Athlete Profile (/athletes/:id)
If athlete not found: 🏟️ emoji + "Athlete not found" + back link
Header card:
- Gradient background:
linear-gradient(135deg, --bg-card 0%, --bg-card2 100%) - Avatar (80px) + name (32px, 900 weight) + city/country + join date
- Sport badges row
- Bio in italic quotes
- Edit link →
/register?edit={id}(secondary button) - Delete button (danger) with
confirm()dialog → deletes and navigates to/athletes
Social media row (if any socials exist):
- Separator line above
- Icons: 📘 facebook, 📷 instagram, 🦋 bluesky, 👻 snapchat
- Handle text next to each icon
Contact card:
- Email (colored
--accent) + Phone in 2-column grid
Biometrics card:
repeat(auto-fill, minmax(130px, 1fr))grid- Each field as a
.card2with.stat-lbl+.stat-val - Decimal fields:
.toFixed(1)— skip fields with no value
Sport Stats card:
- If athlete has >1 sport: show sport tab bar to switch between sports
- Active sport stats shown as
repeat(auto-fill, minmax(130px, 1fr))grid of.card2tiles - Skip stats with value
0or undefined (avoids showing irrelevant stats for wrong position) texttype stats (position): show as.stat-valat 16pxdecimaltype:.toFixed(2)
Back link → /athletes
Register.jsx — Registration / Edit Form (/register)
Dual mode: if ?edit={id} query param is present, loads existing user data and updates on submit. Otherwise creates new user.
4-step wizard: step indicator bar at top shows all 4 steps with completion state (✓ for completed, number for incomplete). Clicking a completed step navigates back to it.
Steps: ['Personal Info', 'Biometrics', 'Sport Stats', 'Review']
Step 1 — Personal Info
Profile photo section:
- 80px circular avatar preview (shows initials or uploaded image)
- File input for photo upload → reads as base64 DataURL → stored in
form.profileImage - 6 avatar color swatches (circular buttons):
['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899']— active swatch has 3px white border
Personal fields (2-column grid):
- First Name * (required)
- Last Name * (required)
- Email * (required)
- Phone
- City
- Country
Bio field: <textarea> maxLength 300, character counter bottom-right
Social media section (labeled "optional"):
- 4 inputs in 2-column grid: Facebook, Instagram, Bluesky, Snapchat — all accept
@handleformat
Sports selection:
- Instruction text: "Select all sports you compete in:"
- 5 sport badge buttons (toggle on/off); at least 1 must remain selected
- Active badges: full opacity, scale 1.05; inactive: opacity 0.4
- At least one sport always stays selected
Step 2 — Biometrics
All 12 biometric fields in 2-column grid:
texttype fields (dominant_hand,dominant_foot):<select>with options Right / Left / Both- All other fields:
<input type="number">, step 0.1 for decimal, step 1 for integer
Step 3 — Sport Stats
One card section per selected sport. Each card:
- Header:
{emoji} {sport name} Stats - 2-column grid of all 16 stat fields:
positionfield:<select>populated with positions for that sport- Decimal stats:
<input type="number" step="0.01"> - Integer stats:
<input type="number" step="1">
Step 4 — Review
Summary view:
- Avatar preview (64px) + full name + email + city/country
- Sport badges
- Bio in italic quotes
- Count: "N biometric fields filled · N sport stats filled"
- Submit / Save button
Form State Management
Single form object in state. setField(dotPath, value) function handles nested updates by deep-cloning and traversing the path (e.g. setField('sportStats.football.touchdowns', 42) or setField('socials.instagram', '@handle')).
Validation on submit: check firstName, lastName, email are non-empty — show toast on failure.
Toast: fixed bottom pill, green background, "Athlete registered!" or "Athlete updated!", auto-hides after 2.5s with CSS keyframe animation. On success, navigate to /athletes after 1.8s.
Edit mode: on mount if editId present, hydrate form from getUserById(editId). Merge existing socials with empty defaults so all 4 fields always exist.
PWA Configuration (vite.config.js)
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'StatSphere – Athlete Stats Platform',
short_name: 'StatSphere',
description: 'Track and compare athlete stats across multiple sports',
theme_color: '#0a0f1e',
background_color: '#0a0f1e',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
}
})
HTML Meta Tags (index.html)
Include:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0f1e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="StatSphere" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
Responsive Design
- Mobile-first layout via the
.pageclass and CSS Gridauto-fillcolumns - Navigation: desktop horizontal top bar ≥768px; mobile bottom bar <768px
- Bottom nav height: CSS variable
--bottom-nav-h— 64px on mobile, 0px on desktop (set via@mediarules in Nav component) - Tables: horizontally scrollable wrapper
overflow-x: autoon mobile - Grids:
.grid-2collapses to 1-col,.grid-3collapses to 2-col below 600px - Hero text:
clamp(36px, 8vw, 72px)for fluid scaling
Ubuntu 24.04 LXC Install Script (install.sh)
A bash script that:
apt-get update+ installcurl git nginx- Install Node.js 20 LTS via NodeSource setup script
- Create system user
statsphere - Copy app to
/opt/statsphere, set ownership npm installas statsphere usernpm run buildas statsphere user- Write
/etc/nginx/sites-available/statspherewith:listen 80+listen [::]:80root /opt/statsphere/disttry_files $uri $uri/ /index.html(SPA fallback)- Gzip on for js/css/html/json/svg/xml
- 1-year cache headers for static assets
- No-cache for
sw.js
- Enable site, remove default,
nginx -t && systemctl restart nginx - Create optional
statsphere-dev.servicesystemd unit runningnpm run previewon port 4173 - Print success message with server IP
Data Persistence Notes
- All data lives in
localStoragekeystatsphere_users_v1 - To reset:
localStorage.removeItem('statsphere_users_v1'); location.reload() - Profile images stored as base64 data URLs — large images may approach localStorage limits; no validation enforced
- No authentication; any user can edit/delete any athlete
Extensibility Notes
To add a new sport in the future:
- Add entry to
SPORTSinseedData.js - Add stat definitions array to
SPORT_STATS_DEFS - Add position list to
positionsin bothseedData.jsandRegister.jsx - Optionally add seed data generator function and call it in the generation loop
- Add sport CSS class
.sport-{id}toindex.css
No routing, component, or page changes required.