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

29 KiB
Raw Blame History

StatSphere 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. All data is stored in the browser via localStorage. No backend or database is required.


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 700900, uppercase, letter-spacing 0.020.1em
  • Body: Barlow — weight 400600
  • Section titles: 28px, weight 800, uppercase, --font-display
  • Stat values: 2024px, 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
  • .cardbackground: --bg-card, 1px border, radius 12px, padding 20px
  • .card2background: --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-card bg, 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, 14px
  • th: 10px 12px padding, --font-display, 11px, 700, uppercase, --text-muted, bottom border, pointer cursor, hover → --accent
  • td: 12px 12px padding, bottom border
  • Row hover: background rgba(0,212,255,0.03)
  • Active sort column header: --accent colored
  • 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)

Football (16 stats): position (text), touchdowns, passing_yards, rushing_yards, receiving_yards, completions, attempts, completion_pct (decimal), interceptions, sacks (decimal), tackles, receptions, fumbles, field_goals, games_played, passer_rating (decimal)

Hockey (16 stats): 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,
    age: Number,
    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','OL','DE','DT','LB','CB','S','K']
hockey:     ['C','LW','RW','D','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 175205, w 85140
  • Hockey: h 175198, w 82105
  • Baseball: h 170200, w 82110
  • Soccer: h 165195, w 7090
  • Basketball: h 185220, w 85120

Common biometrics: age 2038, reach = height + 520, body_fat_pct 618%, vo2_max 4872, vertical_jump 5590cm, 40_yard_dash 4.305.20s, bench_press_reps 1035, years_pro 118

Stat generation is position-aware. Key rules:

  • Football: QBs get passing_yards (28005200), 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.9000.935), gaa (1.83.5), shutouts (112); skaters get goals (555), assists, points; Centers get faceoff_pct (4258%)
  • Baseball: Pitchers (SP/RP) get era (2.105.80), wins (422), whip (0.921.60), strikeouts_p; position players get batting_avg (0.2200.350), home_runs, obp, slg, ops
  • Soccer: GK gets save_pct (6582%), 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 04 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):

  1. "Dedicated athlete with a passion for the game and improving every day."
  2. "Competitive spirit who gives 110% on and off the field."
  3. "Team player first, stats second. Love the game, love my teammates."
  4. "Training hard to be the best version of myself every season."
  5. "Multiple-sport athlete who believes cross-training builds champions."
  6. "Community advocate and sports mentor for youth programs."
  7. "Playing to inspire the next generation of athletes."
  8. "Fueled by competition and the pursuit of excellence."
  9. "Veteran player with years of experience and a championship mindset."
  10. "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) with backdrop-filter: blur(12px)
  • Left: Logo mark (36px square div with gradient #00d4ff → #a855f7, 8px radius, "S" text) + wordmark "STATSPHERE" where SPHERE is --accent colored
  • Right: horizontal nav links — Home, Leaders, Search, Athletes, Register
  • Active link: --accent colored 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) with backdrop-filter: blur(12px)
  • 5 equal-width items: Home, 🏆 Leaders, 🔍 Search, 👤 Athletes, Register
  • Each item: icon (20px emoji) above label (10px, uppercase, --font-display)
  • Active: --accent color; 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, colored user.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 36px72px, 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 with sportStats[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: Age, Height (cm), Years Pro — each with --font-display value in --accent and 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 .card2 with .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 .card2 tiles
  • Skip stats with value 0 or undefined (avoids showing irrelevant stats for wrong position)
  • text type stats (position): show as .stat-val at 16px
  • decimal type: .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 @handle format

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:

  • text type 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:
    • position field: <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 .page class and CSS Grid auto-fill columns
  • 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 @media rules in Nav component)
  • Tables: horizontally scrollable wrapper overflow-x: auto on mobile
  • Grids: .grid-2 collapses to 1-col, .grid-3 collapses 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:

  1. apt-get update + install curl git nginx
  2. Install Node.js 20 LTS via NodeSource setup script
  3. Create system user statsphere
  4. Copy app to /opt/statsphere, set ownership
  5. npm install as statsphere user
  6. npm run build as statsphere user
  7. Write /etc/nginx/sites-available/statsphere with:
    • listen 80 + listen [::]:80
    • root /opt/statsphere/dist
    • try_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
  8. Enable site, remove default, nginx -t && systemctl restart nginx
  9. Create optional statsphere-dev.service systemd unit running npm run preview on port 4173
  10. Print success message with server IP

Data Persistence Notes

  • All data lives in localStorage key statsphere_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:

  1. Add entry to SPORTS in seedData.js
  2. Add stat definitions array to SPORT_STATS_DEFS
  3. Add position list to positions in both seedData.js and Register.jsx
  4. Optionally add seed data generator function and call it in the generation loop
  5. Add sport CSS class .sport-{id} to index.css

No routing, component, or page changes required.