Added all new changes from main github repo.
This commit is contained in:
1 parent
564975d0e3
commit
449ef46c84
30 files changed
+2288
-524
No files matched your search
+191
@@ -0,0 +1,191 @@
|
||||
# MariaDB Persistence & Dynamic Achievement System Plan
|
||||
|
||||
Comprehensive architecture and design blueprint for integrating a MariaDB Galera cluster backend, implementing dynamic achievement recalculation on round deletion, and introducing a two-phase provisional vs. finalized achievement tracking engine during live rounds.
|
||||
|
||||
---
|
||||
|
||||
### User Review & Critical Decisions
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The architectural directions confirmed during Phase 1 have been integrated:
|
||||
> - **Database Backend**: Configured for your existing **MariaDB Galera Cluster** via standard Node.js connection pooling with failover resilience, plus an automated schema migration runner and a dev-mode fallback when credentials are not yet set.
|
||||
> - **Round Deletion & Achievements**: Deleting a saved round dynamically **recalculates achievements** across all remaining completed rounds, revoking badges if their qualification criteria are no longer met.
|
||||
> - **In-Round Instant Badges (e.g. Hole-in-One)**: Celebrated instantly with a live in-round toast upon entry, held as **provisional** during active scoring, revoked if the hole score is modified prior to saving, and permanently committed only upon confirmed round finalization.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Core Concept
|
||||
|
||||
### What It Does
|
||||
Pinpoint Golf evolves from a local session tracker into a production-grade multi-device golf companion backed by a dedicated MariaDB Galera cluster. It introduces:
|
||||
1. **MariaDB Persistence Layer**: Secure user profiles, rounds history, hole-by-hole stats, and verified career achievements stored persistently in MariaDB tables with transactional integrity.
|
||||
2. **Dynamic Career Recalculation Engine**: When a saved round is deleted, the backend re-runs the achievement rules against all remaining saved rounds, ensuring career records and trophies strictly reflect actual saved history.
|
||||
3. **Two-Phase Live Achievement Engine**: High-impact milestones (like a Hole-in-One, Eagle, or Birdie Streak) trigger immediate in-round celebration banners. If a player corrects a typo before submitting, the provisional award is retracted. When the round is finalized with confirmation, all verified milestones are locked into the database.
|
||||
|
||||
### Target Persona & Key Value
|
||||
- **Avid Golfers**: Enjoy real-time excitement during live scoring without fear of permanent false entries due to typing errors.
|
||||
- **System Administrators / DevOps**: Benefit from standard MariaDB Galera multi-master clustering support with zero lock-in and zero mock data in production.
|
||||
|
||||
---
|
||||
|
||||
## 2. User Experience & Visual Design
|
||||
|
||||
### Live In-Round Achievement Workflow
|
||||
```
|
||||
[ Hole Entry Modal ]
|
||||
│ Enters score: "1" (Ace / Hole-in-One)
|
||||
▼
|
||||
[ Instant Celebration Banner ]
|
||||
• Dynamic toast: "🎉 Hole in One! (Provisional)"
|
||||
• Visual indicator on Hole card: "Provisional Ace"
|
||||
│
|
||||
├── User corrects score to "3":
|
||||
│ └── Toast: "Provisional badge retracted" (Ace badge removed)
|
||||
│
|
||||
└── User finishes round & clicks "Save Round":
|
||||
└── SaveRoundModal confirmation
|
||||
└── Achievements committed permanently to MariaDB
|
||||
```
|
||||
|
||||
### Key User Flows & Visual Hierarchy
|
||||
1. **Live Hole Entry & Provisional Badges**:
|
||||
- Scoring controls remain ergonomic for one-handed mobile thumb usage ($44\text{px}+$ touch targets).
|
||||
- Instant achievement toasts use rich typography (`Plus Jakarta Sans`), subtle entrance transitions, and non-blocking dismissals.
|
||||
- Distinct visual state for provisional badges (clean amber/gold indicator with a subtle "Pending Round Finalization" cue; no pill enclosures).
|
||||
2. **Save Confirmation Gate**:
|
||||
- The `SaveRoundModal` displays the round recap (gross score, net score to par, putts, FIR/GIR) and a list of new achievements earned this round.
|
||||
- Clear warning: *"Saving locks this round permanently into your official record. Editing will no longer be possible; only deletion is allowed."*
|
||||
3. **Round Deletion & Dynamic Recalculation**:
|
||||
- In `RoundsHistoryModal`, each saved round card provides a "Delete Round" action with a two-step confirmation prompt.
|
||||
- Deletion dialog warns: *"Deleting this round will recalculate your career achievements. Badges earned solely from this round will be removed."*
|
||||
- Immediate feedback toast: *"Round deleted. Achievements updated (X badges retained)."*
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Product Decisions & Trade-Offs
|
||||
|
||||
### Decision 1: MariaDB Galera Cluster Integration
|
||||
- **Approach**: Configure a robust Express-based database adapter using `mysql2/promise` with Galera-ready connection pool settings (keep-alive, reconnection retry logic, multi-host support).
|
||||
- **Environment Variables**:
|
||||
- `MARIADB_HOST` (supports single host or comma-separated cluster nodes)
|
||||
- `MARIADB_PORT` (default 3306)
|
||||
- `MARIADB_USER`, `MARIADB_PASSWORD`, `MARIADB_DATABASE`
|
||||
- `MARIADB_SSL` (optional SSL mode)
|
||||
- **Zero-Friction Fallback**: When `MARIADB_HOST` is not yet configured in local environment, the server falls back to an integrated local SQLite/JSON storage engine with identical schema interfaces, ensuring the app remains fully functional while credentials are provided.
|
||||
|
||||
### Decision 2: Achievement Recalculation Engine
|
||||
- **Approach**: Pure deterministic recalculation function `calculateUserAchievements(savedRounds, courses)`.
|
||||
- **Why**: Storing achievement IDs as an append-only set risks "ghost achievements" where a deleted bogus round permanently leaves a Hole-in-One badge on the profile. By calculating career achievements dynamically upon deletion, data integrity is 100% mathematically sound.
|
||||
|
||||
### Decision 3: Provisional In-Round vs. Finalized Achievements
|
||||
- **Approach**: Two distinct evaluation phases:
|
||||
- **Phase A (Provisional)**: Runs client-side during active round edits. Evaluates single-hole feats (Hole-in-One, Albatross, Eagle, 3-Birdie Streak). Displays celebration toasts. If score drops below qualification, badge is removed from provisional state.
|
||||
- **Phase B (Finalized)**: Runs server-side upon `POST /api/rounds` finalization. Evaluates both round-level feats (Sub-80, Clean Card, Centurion, Marathon) and verified hole-level feats, writing records transactionally to `user_achievements`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Technical Architecture & Data Strategy
|
||||
|
||||
### System Architecture & Data Flow
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Client (SPA) │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ HoleEntry.tsx │─────────▶│ Provisional State │ │
|
||||
│ │ (Live scoring) │ │ (Active Round) │ │
|
||||
│ └──────────────────┘ └────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ Scorecard.tsx │─────────▶│ SaveRoundModal.tsx │ │
|
||||
│ │ (Lock & Review) │ │ (Confirm Final) │ │
|
||||
│ └──────────────────┘ └────────────────────┘ │
|
||||
└───────────────────────────────────────────┬────────────┘
|
||||
│ POST /api/rounds
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Server (Express API) │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ /api/rounds │─────────▶│ Achievement Engine │ │
|
||||
│ │ (Save / Delete) │ │ (Evaluate / Sync) │ │
|
||||
│ └──────────────────┘ └────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ MariaDB Galera Pool (mysql2/promise) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ MariaDB Cluster Database │
|
||||
│ │
|
||||
│ • users (id, email, password_hash, handicap, created) │
|
||||
│ • rounds (id, user_id, course_id, date, is_completed) │
|
||||
│ • round_scores (round_id, hole_num, strokes, putts...)│
|
||||
│ • user_achievements (user_id, achievement_id, date) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### MariaDB Relational Schema
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
display_name VARCHAR(128) NOT NULL,
|
||||
handicap_index DECIMAL(4,1) DEFAULT 18.0,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rounds (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
course_id VARCHAR(64) NOT NULL,
|
||||
tee_id VARCHAR(64),
|
||||
date VARCHAR(32) NOT NULL,
|
||||
holes_played INT NOT NULL DEFAULT 18,
|
||||
is_completed BOOLEAN NOT NULL DEFAULT 1,
|
||||
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
gross_score INT NOT NULL,
|
||||
net_score INT NOT NULL,
|
||||
total_putts INT NOT NULL,
|
||||
fir_hit INT NOT NULL DEFAULT 0,
|
||||
fir_total INT NOT NULL DEFAULT 0,
|
||||
gir_hit INT NOT NULL DEFAULT 0,
|
||||
gir_total INT NOT NULL DEFAULT 0,
|
||||
settings_json JSON,
|
||||
scores_json JSON NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_rounds (user_id, date DESC),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
achievement_id VARCHAR(64) NOT NULL,
|
||||
round_id VARCHAR(64),
|
||||
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, achievement_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Roadmap (Post-Approval)
|
||||
|
||||
1. **Backend Database Driver & Migration**:
|
||||
- Add `mysql2` driver and create `src/server/mariadb.ts` with pool configuration and auto-table creation on startup.
|
||||
- Configure `.env.example` with MariaDB connection parameters (`MARIADB_HOST`, `MARIADB_PORT`, `MARIADB_USER`, etc.).
|
||||
- Support seamless fallback for development environments without an active cluster connection.
|
||||
|
||||
2. **Provisional In-Round Achievement Engine**:
|
||||
- Update `HoleEntry.tsx` and round state to evaluate single-hole feats in real-time.
|
||||
- Add animated toast notification for provisional unlocks and graceful retraction notifications if scores are adjusted.
|
||||
|
||||
3. **Round Finalization & Recalculation Lifecycle**:
|
||||
- Wire `SaveRoundModal.tsx` to commit verified rounds to MariaDB and lock the round from future editing.
|
||||
- Implement round deletion with automatic re-evaluation of user achievements, updating `user_achievements` in MariaDB and syncing client state.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"summary": "Comprehensive architectural implementation plan detailing MariaDB Galera cluster integration, dynamic achievement recalculation on round deletion, and two-phase provisional vs finalized in-round achievement tracking.",
|
||||
"updatedAt": "2026-10-13T17:41:09.407962960Z",
|
||||
"requestFeedback": true,
|
||||
"userFacing": true
|
||||
}
|
||||
@@ -31,3 +31,19 @@ AUTHENTIK_CLIENT_SECRET=""
|
||||
# GitHub OAuth Credentials
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# ========================================================
|
||||
# MariaDB Galera Cluster Configuration (Production Database)
|
||||
# ========================================================
|
||||
# If configured, Pinpoint Golf connects directly to your MariaDB Galera cluster
|
||||
# for multi-master high-availability storage of users, rounds, and achievements.
|
||||
# If left empty, the server automatically uses local persistent storage.
|
||||
MARIADB_HOST=""
|
||||
MARIADB_PORT="3306"
|
||||
MARIADB_USER=""
|
||||
MARIADB_PASSWORD=""
|
||||
MARIADB_DATABASE="pinpoint_golf"
|
||||
MARIADB_SSL="false"
|
||||
# Optional connection pool size for Galera multi-master concurrency
|
||||
MARIADB_CONNECTION_LIMIT="10"
|
||||
|
||||
+3
-1
@@ -6,4 +6,6 @@ coverage/
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
.aider*
|
||||
.aider.chat.history.md
|
||||
.aider.input.history
|
||||
.aider.tags.cache.v4/
|
||||
@@ -219,3 +219,30 @@ npm run build
|
||||
# Clean temporary files
|
||||
npm run clean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Clean Deployment & Data Integrity Rules
|
||||
|
||||
To guarantee enterprise-grade data hygiene and tour-level integrity, PinPoint Golf enforces strict separation between development testing and production deployment:
|
||||
|
||||
### 1. Mock Data Elimination in Production
|
||||
- **Server Store**: In production (`NODE_ENV=production` or `ENABLE_MOCK_DATA!=true`), the database manager `src/server/db.ts` suppresses demo accounts (`usr_demo_golfer`) and seeded community golfers (`user_seed_*`). Only real registered users and scorecards exist.
|
||||
- **Client App**: The client initializes rounds state to an empty list `[]` instead of pre-seeding `SAMPLE_ROUND`. The "Load Dev Sample Round" button is stripped in production builds (`import.meta.env.PROD`).
|
||||
|
||||
### 2. Authentication-Gated Features
|
||||
The following features are exclusively unlocked for registered golfers:
|
||||
- **Community Benchmarks & Leaderboards**: Public comparison of handicap, FIR%, GIR%, putts, and scrambles against peers.
|
||||
- **Career Achievements & Trophies**: 22 tier-ranked achievements across scoring, accuracy, and short game.
|
||||
- **Satellite GPS Rangefinder**: Real-time Google Maps satellite flyover, Front/Center/Back green distance calculation, pin targeting, and shot distance measurement.
|
||||
- *Guests & Unauthenticated Users*: Can start rounds, view course scorecards, and check local play, but receive clear in-app prompts to sign in or create a free account to unlock GPS, community leaderboards, and trophies.
|
||||
|
||||
### 3. Achievement Lifecycle Rules
|
||||
- **Empty Initial State**: New registered users start with zero achievements unlocked (`unlockedIds: []`).
|
||||
- **Finalization Requirement**: Achievements are never awarded during live play; they are evaluated and awarded **strictly upon finishing and saving a completed round** (`isCompleted: true`).
|
||||
|
||||
### 4. Round Immutability & Deletion Workflow
|
||||
- **Confirmation Modal**: Clicking "Finalize & Save Round" triggers `SaveRoundModal`, showing a complete scoring summary and an explicit warning that the round will be permanently locked.
|
||||
- **Post-Save Immutability**: Once saved (`isCompleted: true`), hole scores and statistical inputs are locked against modification.
|
||||
- **Deletion Only**: The only permitted post-save action on a finalized round is deletion from the golfer's history with confirmation.
|
||||
|
||||
+5
-7
@@ -6,9 +6,8 @@ FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer caching)
|
||||
COPY package.json bun.lock ./
|
||||
RUN npm install -g bun
|
||||
RUN bun install
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy application source code
|
||||
COPY . .
|
||||
@@ -18,7 +17,7 @@ ARG VITE_GOOGLE_MAPS_API_KEY=""
|
||||
ENV VITE_GOOGLE_MAPS_API_KEY=$VITE_GOOGLE_MAPS_API_KEY
|
||||
|
||||
# Build production bundle (Vite client + server.cjs)
|
||||
RUN bun run build
|
||||
RUN npm run build
|
||||
|
||||
# ========================================================
|
||||
# Stage 2: Serve full-stack application (API + SPA) with Node.js
|
||||
@@ -30,9 +29,8 @@ ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Install production dependencies only
|
||||
COPY package.json bun.lock ./
|
||||
RUN npm install -g bun
|
||||
RUN bun install --production
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy built bundle from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.24.6",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^8.3.0",
|
||||
@@ -271,6 +272,8 @@
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.6.1", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="],
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="],
|
||||
@@ -371,6 +374,8 @@
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
@@ -391,12 +396,14 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
@@ -447,6 +454,8 @@
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru.min": ["lru.min@1.1.5", "", {}, "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
@@ -473,6 +482,10 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mysql2": ["mysql2@3.24.6", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ=="],
|
||||
|
||||
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
@@ -543,6 +556,8 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"sql-escaper": ["sql-escaper@1.5.2", "", {}, "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
@@ -605,12 +620,16 @@
|
||||
|
||||
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
+4
-166
@@ -1,169 +1,7 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "usr_demo_golfer",
|
||||
"email": "demo@pinpointgolf.com",
|
||||
"username": "demogolfer",
|
||||
"name": "Demo Golfer",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 12.4,
|
||||
"homeClub": "Pebble Cove Links",
|
||||
"isPublic": true,
|
||||
"provider": "local",
|
||||
"createdAt": "2026-10-10T01:33:30.863Z",
|
||||
"updatedAt": "2026-10-10T01:33:30.863Z",
|
||||
"passwordHash": "$2b$10$dazakuysQGoPaG27E2OWweafH.l/GJrb3XGsCiBbRVgf2WdNfnwnO"
|
||||
},
|
||||
{
|
||||
"id": "usr_1791596044846_0e8u8",
|
||||
"email": "test@golf.com",
|
||||
"username": "testplayer",
|
||||
"name": "Test Player",
|
||||
"handicapIndex": 9.5,
|
||||
"homeClub": "Local Links Club",
|
||||
"isPublic": true,
|
||||
"provider": "local",
|
||||
"createdAt": "2026-10-10T01:34:04.846Z",
|
||||
"updatedAt": "2026-10-10T01:34:04.846Z",
|
||||
"passwordHash": "$2b$10$5UujhxPRuprn7xtAkOAxKeqfxLaIOIcTwEXtnFQnmVYy6wYVeIvCK"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"usr_demo_golfer": {
|
||||
"trackFIR": true,
|
||||
"trackFIRDirection": true,
|
||||
"trackGIR": true,
|
||||
"trackGIRDirection": true,
|
||||
"trackPutts": true,
|
||||
"trackForegreen": true,
|
||||
"trackFirstPuttDistance": false,
|
||||
"trackPenalties": true,
|
||||
"trackPenaltyTypes": true,
|
||||
"trackBunkers": true,
|
||||
"trackBunkerTypes": true,
|
||||
"trackSandSaves": true,
|
||||
"trackUpAndDown": true,
|
||||
"trackDriveClub": true,
|
||||
"trackDriveDistance": false,
|
||||
"trackHoleNotes": true,
|
||||
"distanceUnit": "meters",
|
||||
"puttDistanceUnit": "meters"
|
||||
},
|
||||
"usr_1791596044846_0e8u8": {
|
||||
"trackFIR": true,
|
||||
"trackFIRDirection": true,
|
||||
"trackGIR": true,
|
||||
"trackGIRDirection": true,
|
||||
"trackPutts": true,
|
||||
"trackForegreen": true,
|
||||
"trackFirstPuttDistance": false,
|
||||
"trackPenalties": true,
|
||||
"trackPenaltyTypes": true,
|
||||
"trackBunkers": true,
|
||||
"trackBunkerTypes": true,
|
||||
"trackSandSaves": true,
|
||||
"trackUpAndDown": true,
|
||||
"trackDriveClub": true,
|
||||
"trackDriveDistance": false,
|
||||
"trackHoleNotes": true,
|
||||
"distanceUnit": "meters",
|
||||
"puttDistanceUnit": "meters"
|
||||
}
|
||||
},
|
||||
"rounds": {
|
||||
"usr_1791596044846_0e8u8": []
|
||||
},
|
||||
"users": [],
|
||||
"settings": {},
|
||||
"rounds": {},
|
||||
"customCourses": {},
|
||||
"publicSeedGolfers": [
|
||||
{
|
||||
"id": "user_seed_viktor",
|
||||
"username": "viktor_h",
|
||||
"name": "Viktor H.",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 0.4,
|
||||
"homeClub": "Oslo Golfklubb",
|
||||
"roundsCount": 24,
|
||||
"scoringAverage": 71.2,
|
||||
"bestGross": 67,
|
||||
"firPercentage": 74.5,
|
||||
"girPercentage": 76.2,
|
||||
"puttsPerRound": 28.4,
|
||||
"scramblePercentage": 68,
|
||||
"sandSavePercentage": 62.5,
|
||||
"penaltiesPerRound": 0.3,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_marcus",
|
||||
"username": "marcus_t",
|
||||
"name": "Marcus Thorne",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 5.8,
|
||||
"homeClub": "Pine Valley GC",
|
||||
"roundsCount": 18,
|
||||
"scoringAverage": 76.8,
|
||||
"bestGross": 73,
|
||||
"firPercentage": 66,
|
||||
"girPercentage": 62.5,
|
||||
"puttsPerRound": 30.1,
|
||||
"scramblePercentage": 54,
|
||||
"sandSavePercentage": 47,
|
||||
"penaltiesPerRound": 0.8,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_sarah",
|
||||
"username": "sarah_m",
|
||||
"name": "Sarah Miller",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 11.2,
|
||||
"homeClub": "St Andrews Links Club",
|
||||
"roundsCount": 15,
|
||||
"scoringAverage": 82.4,
|
||||
"bestGross": 78,
|
||||
"firPercentage": 58.2,
|
||||
"girPercentage": 51,
|
||||
"puttsPerRound": 31.6,
|
||||
"scramblePercentage": 42,
|
||||
"sandSavePercentage": 36,
|
||||
"penaltiesPerRound": 1.4,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_dave",
|
||||
"username": "dave_k",
|
||||
"name": "Dave Kowalski",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 18.6,
|
||||
"homeClub": "Bandon Dunes Golfers",
|
||||
"roundsCount": 12,
|
||||
"scoringAverage": 91.5,
|
||||
"bestGross": 86,
|
||||
"firPercentage": 44,
|
||||
"girPercentage": 32.5,
|
||||
"puttsPerRound": 34.2,
|
||||
"scramblePercentage": 28,
|
||||
"sandSavePercentage": 21,
|
||||
"penaltiesPerRound": 2.6,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_elena",
|
||||
"username": "elena_rostova",
|
||||
"name": "Elena Rostova",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 8.9,
|
||||
"homeClub": "Torrey Pines South",
|
||||
"roundsCount": 20,
|
||||
"scoringAverage": 79.8,
|
||||
"bestGross": 75,
|
||||
"firPercentage": 62.4,
|
||||
"girPercentage": 57.8,
|
||||
"puttsPerRound": 31,
|
||||
"scramblePercentage": 48,
|
||||
"sandSavePercentage": 40,
|
||||
"penaltiesPerRound": 1.1,
|
||||
"isPublic": true
|
||||
}
|
||||
]
|
||||
"publicSeedGolfers": []
|
||||
}
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pinpoint-golf:
|
||||
build:
|
||||
@@ -10,7 +12,7 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Map host port 3000 to app port 3000
|
||||
- "127.0.0.1:3000:3000"
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx server.ts",
|
||||
"start": "tsx server.ts",
|
||||
"build": "vite build && esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist/server.cjs --external:express --external:bcryptjs --external:jsonwebtoken --external:vite",
|
||||
"build": "vite build && esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist/server.cjs --external:express --external:bcryptjs --external:jsonwebtoken --external:vite --external:mysql2",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js data/pinpoint_db.json.tmp",
|
||||
"lint": "tsc --noEmit"
|
||||
@@ -22,6 +22,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.24.6",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^8.3.0"
|
||||
|
||||
@@ -4,6 +4,7 @@ import dotenv from 'dotenv';
|
||||
import authRoutes from './src/server/routes/authRoutes';
|
||||
import roundsRoutes from './src/server/routes/roundsRoutes';
|
||||
import communityRoutes from './src/server/routes/communityRoutes';
|
||||
import { db } from './src/server/db';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -26,6 +27,8 @@ app.use('/api/rounds', roundsRoutes);
|
||||
app.use('/api/community', communityRoutes);
|
||||
|
||||
async function startServer() {
|
||||
// Initialize persistence backend (MariaDB Galera cluster if configured, or local store)
|
||||
await db.init();
|
||||
if (!isProduction) {
|
||||
// Development mode: Vite middleware
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
|
||||
+275
-87
@@ -10,14 +10,24 @@ import { Settings } from './components/Settings';
|
||||
import { NewRoundModal } from './components/NewRoundModal';
|
||||
import { RoundsHistoryModal } from './components/RoundsHistoryModal';
|
||||
import { AchievementsModal } from './components/AchievementsModal';
|
||||
import { SaveRoundModal } from './components/SaveRoundModal';
|
||||
import { CourseEditorModal } from './components/CourseEditorModal';
|
||||
import { AuthModal } from './components/AuthModal';
|
||||
import { CommunityComparisonModal } from './components/CommunityComparisonModal';
|
||||
import { calculateRoundStats } from './utils/golfStats';
|
||||
import { evaluateAchievements } from './utils/achievements';
|
||||
import { ALL_ACHIEVEMENTS, evaluateAchievements } from './utils/achievements';
|
||||
import { AuthProviderComponent, useAuth } from './context/AuthContext';
|
||||
import { roundsApi, authApi } from './services/api';
|
||||
import { Trophy, X, Sparkles, CloudCheck, Cloud } from 'lucide-react';
|
||||
import {
|
||||
Trophy,
|
||||
X,
|
||||
Sparkles,
|
||||
Flag,
|
||||
Plus,
|
||||
Lock,
|
||||
Navigation,
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
|
||||
const STORAGE_KEY_ROUNDS = 'pinpoint_golf_rounds_v1';
|
||||
const STORAGE_KEY_SETTINGS = 'pinpoint_golf_settings_v1';
|
||||
@@ -60,28 +70,28 @@ function AppContent() {
|
||||
return DEFAULT_COURSES;
|
||||
});
|
||||
|
||||
// 3. Rounds state (or seed with SAMPLE_ROUND)
|
||||
// 3. Rounds state (Clean by default - no mock rounds when deployed)
|
||||
const [rounds, setRounds] = useState<Round[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_ROUNDS);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return [SAMPLE_ROUND];
|
||||
return [];
|
||||
});
|
||||
|
||||
const [currentRoundId, setCurrentRoundId] = useState<string>(() => {
|
||||
return rounds[0]?.id || SAMPLE_ROUND.id;
|
||||
return rounds[0]?.id || '';
|
||||
});
|
||||
|
||||
const [currentHoleNumber, setCurrentHoleNumber] = useState<number>(1);
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('hole');
|
||||
|
||||
// 4. Achievements state
|
||||
// 4. Achievements state (Starts empty for new users, only awarded upon saving completed rounds)
|
||||
const [unlockedIds, setUnlockedIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS);
|
||||
@@ -92,22 +102,29 @@ function AppContent() {
|
||||
return [];
|
||||
});
|
||||
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
||||
const [achievements, setAchievements] = useState<Achievement[]>(() => {
|
||||
return ALL_ACHIEVEMENTS.map((a) => ({ ...a, isUnlocked: false }));
|
||||
});
|
||||
const [newlyUnlockedToast, setNewlyUnlockedToast] = useState<Achievement | null>(null);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
// Modals state
|
||||
const [isNewRoundModalOpen, setIsNewRoundModalOpen] = useState(false);
|
||||
const [isRoundsHistoryModalOpen, setIsRoundsHistoryModalOpen] = useState(false);
|
||||
const [isAchievementsModalOpen, setIsAchievementsModalOpen] = useState(false);
|
||||
const [isSaveRoundModalOpen, setIsSaveRoundModalOpen] = useState(false);
|
||||
const [isCourseEditorModalOpen, setIsCourseEditorModalOpen] = useState(false);
|
||||
const [courseEditorBaseCourse, setCourseEditorBaseCourse] = useState<Course | null>(null);
|
||||
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
|
||||
const [isCommunityModalOpen, setIsCommunityModalOpen] = useState(false);
|
||||
|
||||
// Sync state with cloud when user logs in
|
||||
// Sync state with cloud when user logs in or out
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!user) {
|
||||
// Guest users: achievements are locked/empty
|
||||
setAchievements(ALL_ACHIEVEMENTS.map((a) => ({ ...a, isUnlocked: false })));
|
||||
setUnlockedIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCloudData = async () => {
|
||||
try {
|
||||
@@ -121,17 +138,20 @@ function AppContent() {
|
||||
setSettings(cloudSettings);
|
||||
}
|
||||
|
||||
let combinedRounds = [...rounds];
|
||||
if (cloudRounds && cloudRounds.length > 0) {
|
||||
// Merge cloud rounds with local rounds without duplicates
|
||||
setRounds((prev) => {
|
||||
const merged = [...cloudRounds];
|
||||
for (const r of prev) {
|
||||
if (!merged.some((m) => m.id === r.id)) {
|
||||
merged.push(r);
|
||||
}
|
||||
const merged = [...cloudRounds];
|
||||
for (const r of rounds) {
|
||||
if (!merged.some((m) => m.id === r.id)) {
|
||||
merged.push(r);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
combinedRounds = merged;
|
||||
setRounds(merged);
|
||||
if (!currentRoundId || !merged.some((r) => r.id === currentRoundId)) {
|
||||
setCurrentRoundId(merged[0]?.id || '');
|
||||
}
|
||||
}
|
||||
|
||||
if (cloudCourses && cloudCourses.length > 0) {
|
||||
@@ -145,6 +165,13 @@ function AppContent() {
|
||||
return combined;
|
||||
});
|
||||
}
|
||||
|
||||
// Only evaluate achievements for COMPLETED, saved rounds
|
||||
const completedRounds = combinedRounds.filter((r) => r.isCompleted);
|
||||
const evalResult = evaluateAchievements(completedRounds, courses, unlockedIds);
|
||||
setAchievements(evalResult.unlockedList);
|
||||
const userUnlocked = evalResult.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id);
|
||||
setUnlockedIds(userUnlocked);
|
||||
} catch (err) {
|
||||
console.error('Error syncing cloud data:', err);
|
||||
}
|
||||
@@ -179,38 +206,21 @@ function AppContent() {
|
||||
}
|
||||
}, [courses]);
|
||||
|
||||
// Evaluate achievements whenever rounds or courses change
|
||||
useEffect(() => {
|
||||
const evaluation = evaluateAchievements(rounds, courses, unlockedIds);
|
||||
setAchievements(evaluation.unlockedList);
|
||||
// Current active round object (can be null if user has no rounds started)
|
||||
const currentRound: Round | null =
|
||||
rounds.find((r) => r.id === currentRoundId) || rounds[0] || null;
|
||||
|
||||
const currentUnlockedIds = evaluation.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id);
|
||||
if (JSON.stringify(currentUnlockedIds) !== JSON.stringify(unlockedIds)) {
|
||||
setUnlockedIds(currentUnlockedIds);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(currentUnlockedIds));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
const currentCourse: Course = currentRound
|
||||
? courses.find((c) => c.id === currentRound.courseId) ||
|
||||
DEFAULT_COURSES.find((c) => c.id === currentRound.courseId) ||
|
||||
courses[0] ||
|
||||
DEFAULT_COURSES[0]
|
||||
: courses[0] || DEFAULT_COURSES[0];
|
||||
|
||||
if (!isInitialMount.current && evaluation.newlyUnlocked.length > 0) {
|
||||
setNewlyUnlockedToast(evaluation.newlyUnlocked[0]);
|
||||
}
|
||||
|
||||
isInitialMount.current = false;
|
||||
}, [rounds, courses]);
|
||||
|
||||
// Current active round object
|
||||
const currentRound = rounds.find((r) => r.id === currentRoundId) || rounds[0] || SAMPLE_ROUND;
|
||||
const currentCourse: Course =
|
||||
courses.find((c) => c.id === currentRound.courseId) ||
|
||||
DEFAULT_COURSES.find((c) => c.id === currentRound.courseId) ||
|
||||
courses[0] ||
|
||||
DEFAULT_COURSES[0];
|
||||
|
||||
// Update a specific hole's score
|
||||
// Update a specific hole's score (only permitted while round is not completed)
|
||||
const handleUpdateHoleScore = (holeNumber: number, updated: HoleScore) => {
|
||||
if (!currentRound || currentRound.isCompleted) return;
|
||||
|
||||
setRounds((prevRounds) => {
|
||||
const updatedRounds = prevRounds.map((r) => {
|
||||
if (r.id !== currentRound.id) return r;
|
||||
@@ -222,7 +232,7 @@ function AppContent() {
|
||||
},
|
||||
};
|
||||
|
||||
// If user is authenticated, save directly to cloud database
|
||||
// If user is authenticated, sync live update to cloud
|
||||
if (user) {
|
||||
roundsApi.saveRound(modified).catch((e) => console.warn('Cloud save error:', e));
|
||||
}
|
||||
@@ -244,20 +254,98 @@ function AppContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRound = (roundId: string) => {
|
||||
if (rounds.length <= 1) return;
|
||||
// Finalize and permanently lock a round with confirmation
|
||||
const handleOpenSaveRoundModal = () => {
|
||||
setIsSaveRoundModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmSaveRound = async () => {
|
||||
if (!currentRound) return;
|
||||
|
||||
const finalizedRound: Round = {
|
||||
...currentRound,
|
||||
isCompleted: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const nextRounds = rounds.map((r) => (r.id === finalizedRound.id ? finalizedRound : r));
|
||||
setRounds(nextRounds);
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const res = await roundsApi.saveRound(finalizedRound);
|
||||
if (res.achievements) {
|
||||
setAchievements(res.achievements);
|
||||
const nextUnlocked = res.achievements.filter((a: any) => a.isUnlocked).map((a: any) => a.id);
|
||||
setUnlockedIds(nextUnlocked);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(nextUnlocked));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
if (res.newlyUnlocked && res.newlyUnlocked.length > 0) {
|
||||
setNewlyUnlockedToast(res.newlyUnlocked[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Cloud save error:', e);
|
||||
// Fallback local evaluation
|
||||
const completedRounds = nextRounds.filter((r) => r.isCompleted);
|
||||
const evalResult = evaluateAchievements(completedRounds, courses, unlockedIds);
|
||||
setAchievements(evalResult.unlockedList);
|
||||
const nextUnlockedIds = evalResult.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id);
|
||||
setUnlockedIds(nextUnlockedIds);
|
||||
if (evalResult.newlyUnlocked.length > 0) {
|
||||
setNewlyUnlockedToast(evalResult.newlyUnlocked[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTab('scorecard');
|
||||
};
|
||||
|
||||
// Delete round: recalculates achievements dynamically from remaining saved rounds
|
||||
const handleDeleteRound = async (roundId: string) => {
|
||||
const remaining = rounds.filter((r) => r.id !== roundId);
|
||||
setRounds(remaining);
|
||||
if (currentRoundId === roundId) {
|
||||
setCurrentRoundId(remaining[0].id);
|
||||
setCurrentRoundId(remaining[0]?.id || '');
|
||||
}
|
||||
|
||||
if (user) {
|
||||
roundsApi.deleteRound(roundId).catch((e) => console.warn('Cloud delete error:', e));
|
||||
try {
|
||||
const res = await roundsApi.deleteRound(roundId);
|
||||
if (res.achievements) {
|
||||
setAchievements(res.achievements);
|
||||
const remainingUnlocked = res.achievements.filter((a: any) => a.isUnlocked).map((a: any) => a.id);
|
||||
setUnlockedIds(remainingUnlocked);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(remainingUnlocked));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Cloud delete error:', e);
|
||||
}
|
||||
} else {
|
||||
// Re-evaluate achievements locally for remaining completed rounds
|
||||
const remainingCompleted = remaining.filter((r) => r.isCompleted);
|
||||
const evalResult = evaluateAchievements(remainingCompleted, courses, []);
|
||||
setAchievements(evalResult.unlockedList);
|
||||
const remainingUnlocked = evalResult.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id);
|
||||
setUnlockedIds(remainingUnlocked);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(remainingUnlocked));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSampleRound = () => {
|
||||
// Only available in development mode
|
||||
if (import.meta.env.PROD) return;
|
||||
const exists = rounds.some((r) => r.id === SAMPLE_ROUND.id);
|
||||
if (!exists) {
|
||||
setRounds((prev) => [SAMPLE_ROUND, ...prev]);
|
||||
@@ -268,6 +356,7 @@ function AppContent() {
|
||||
};
|
||||
|
||||
const handleEditHoleFromScorecard = (holeNumber: number) => {
|
||||
if (currentRound?.isCompleted) return; // Locked: no edits permitted on saved rounds
|
||||
setCurrentHoleNumber(holeNumber);
|
||||
setActiveTab('hole');
|
||||
};
|
||||
@@ -323,8 +412,10 @@ function AppContent() {
|
||||
};
|
||||
|
||||
// Quick summary stats for the top bar
|
||||
const summary = calculateRoundStats(currentRound, currentCourse);
|
||||
const unlockedAchievementsCount = achievements.filter((a) => a.isUnlocked).length;
|
||||
const summary = currentRound
|
||||
? calculateRoundStats(currentRound, currentCourse)
|
||||
: { totalGross: 0, toPar: 0, holesPlayedCount: 0 };
|
||||
const unlockedAchievementsCount = user ? achievements.filter((a) => a.isUnlocked).length : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-950 text-stone-100 flex flex-col font-sans relative">
|
||||
@@ -386,40 +477,115 @@ function AppContent() {
|
||||
|
||||
{/* Main App Content Viewport */}
|
||||
<main className="flex-1 w-full max-w-7xl mx-auto px-2 sm:px-4 py-4">
|
||||
{activeTab === 'hole' && (
|
||||
<HoleEntry
|
||||
currentHoleNumber={currentHoleNumber}
|
||||
setCurrentHoleNumber={setCurrentHoleNumber}
|
||||
course={currentCourse}
|
||||
scores={currentRound.scores}
|
||||
onUpdateHoleScore={handleUpdateHoleScore}
|
||||
settings={settings}
|
||||
onViewScorecard={() => setActiveTab('scorecard')}
|
||||
/>
|
||||
)}
|
||||
{!currentRound ? (
|
||||
<div className="max-w-2xl mx-auto my-8 p-6 sm:p-10 bg-gradient-to-br from-stone-900 to-stone-850 border border-stone-800 rounded-3xl shadow-2xl text-center space-y-6 animate-fade-in">
|
||||
<div className="w-16 h-16 rounded-3xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center justify-center mx-auto shadow-inner">
|
||||
<Flag className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
{activeTab === 'scorecard' && (
|
||||
<Scorecard
|
||||
round={currentRound}
|
||||
course={currentCourse}
|
||||
settings={settings}
|
||||
onEditHole={handleEditHoleFromScorecard}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl sm:text-3xl font-black text-white">
|
||||
Ready to Tee Off?
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-stone-400 max-w-lg mx-auto leading-relaxed">
|
||||
Start a round to track live strokes, fairway & green accuracy, foregreen putts, and bunker sand saves. Finalize and save your round to permanently lock your scorecard and earn career achievements!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<Analytics
|
||||
round={currentRound}
|
||||
course={currentCourse}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setIsNewRoundModalOpen(true)}
|
||||
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 text-white font-extrabold text-sm rounded-2xl shadow-xl shadow-emerald-950/60 transition active:scale-98 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 font-bold" />
|
||||
<span>Start New Round</span>
|
||||
</button>
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<Settings
|
||||
settings={settings}
|
||||
onUpdateSettings={handleUpdateSettings}
|
||||
/>
|
||||
{!user && (
|
||||
<button
|
||||
onClick={() => setIsAuthModalOpen(true)}
|
||||
className="px-5 py-3 bg-stone-800 hover:bg-stone-750 border border-stone-700 text-stone-200 font-bold text-sm rounded-2xl transition active:scale-98 flex items-center gap-2"
|
||||
>
|
||||
<Lock className="w-4 h-4 text-emerald-400" />
|
||||
<span>Sign In / Register</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Highlights Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-4 text-left border-t border-stone-800/80">
|
||||
<div className="bg-stone-900/60 p-3 rounded-2xl border border-stone-800/80">
|
||||
<div className="text-emerald-400 text-xs font-bold flex items-center gap-1.5">
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
<span>GPS Flyover</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400 mt-1">Satellite maps & green distances for signed-up golfers</p>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-3 rounded-2xl border border-stone-800/80">
|
||||
<div className="text-amber-400 text-xs font-bold flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Live Weather</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400 mt-1">Real-time wind, temperature & rain forecast via Open-Meteo</p>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-3 rounded-2xl border border-stone-800/80">
|
||||
<div className="text-cyan-400 text-xs font-bold flex items-center gap-1.5">
|
||||
<Award className="w-3.5 h-3.5" />
|
||||
<span>Locked Cards</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400 mt-1">Permanent saved records. Saved cards cannot be edited</p>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-3 rounded-2xl border border-stone-800/80">
|
||||
<div className="text-amber-300 text-xs font-bold flex items-center gap-1.5">
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
<span>22 Trophies</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400 mt-1">Earned exclusively upon finalizing and saving completed rounds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'hole' && (
|
||||
<HoleEntry
|
||||
currentHoleNumber={currentHoleNumber}
|
||||
setCurrentHoleNumber={setCurrentHoleNumber}
|
||||
course={currentCourse}
|
||||
scores={currentRound.scores}
|
||||
onUpdateHoleScore={handleUpdateHoleScore}
|
||||
settings={settings}
|
||||
onViewScorecard={() => setActiveTab('scorecard')}
|
||||
isCompleted={currentRound.isCompleted}
|
||||
onOpenSaveModal={handleOpenSaveRoundModal}
|
||||
onOpenAuth={() => setIsAuthModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'scorecard' && (
|
||||
<Scorecard
|
||||
round={currentRound}
|
||||
course={currentCourse}
|
||||
settings={settings}
|
||||
onEditHole={handleEditHoleFromScorecard}
|
||||
onOpenSaveModal={handleOpenSaveRoundModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<Analytics
|
||||
round={currentRound}
|
||||
course={currentCourse}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<Settings
|
||||
settings={settings}
|
||||
onUpdateSettings={handleUpdateSettings}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -441,16 +607,38 @@ function AppContent() {
|
||||
onClose={() => setIsRoundsHistoryModalOpen(false)}
|
||||
rounds={rounds}
|
||||
currentRoundId={currentRoundId}
|
||||
onSelectRound={(id) => setCurrentRoundId(id)}
|
||||
onSelectRound={(id) => {
|
||||
setCurrentRoundId(id);
|
||||
const sel = rounds.find((r) => r.id === id);
|
||||
if (sel?.isCompleted) {
|
||||
setActiveTab('scorecard');
|
||||
} else {
|
||||
setActiveTab('hole');
|
||||
}
|
||||
}}
|
||||
onDeleteRound={handleDeleteRound}
|
||||
onLoadSampleRound={handleLoadSampleRound}
|
||||
/>
|
||||
|
||||
{/* Finalize & Save Round Confirmation Modal */}
|
||||
{currentRound && (
|
||||
<SaveRoundModal
|
||||
isOpen={isSaveRoundModalOpen}
|
||||
onClose={() => setIsSaveRoundModalOpen(false)}
|
||||
onConfirmSave={handleConfirmSaveRound}
|
||||
round={currentRound}
|
||||
course={currentCourse}
|
||||
isAuthenticated={!!user}
|
||||
onOpenAuth={() => setIsAuthModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Achievements / Trophies Showcase Modal */}
|
||||
<AchievementsModal
|
||||
isOpen={isAchievementsModalOpen}
|
||||
onClose={() => setIsAchievementsModalOpen(false)}
|
||||
achievements={achievements}
|
||||
onOpenAuth={() => setIsAuthModalOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Custom Course Builder & Modifier Modal */}
|
||||
|
||||
@@ -22,18 +22,22 @@ import {
|
||||
Flag,
|
||||
} from 'lucide-react';
|
||||
import { Achievement, AchievementTier } from '../types/golf';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
interface AchievementsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
achievements: Achievement[];
|
||||
onOpenAuth?: () => void;
|
||||
}
|
||||
|
||||
export const AchievementsModal: React.FC<AchievementsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
achievements,
|
||||
onOpenAuth,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [tierFilter, setTierFilter] = useState<'all' | AchievementTier>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all');
|
||||
|
||||
@@ -150,53 +154,90 @@ export const AchievementsModal: React.FC<AchievementsModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Global Progress Strip */}
|
||||
<div className="my-4 p-4 rounded-2xl bg-stone-850 border border-stone-800 shrink-0 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs font-mono">
|
||||
<span className="text-stone-300 font-sans font-bold">Career Trophies Unlocked:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400 font-bold text-sm">
|
||||
{unlockedCount} / {totalCount}
|
||||
</span>
|
||||
<span className="text-stone-500">({percentage}%)</span>
|
||||
{/* Authenticated Check */}
|
||||
{!user ? (
|
||||
<div className="my-6 p-8 rounded-3xl bg-gradient-to-br from-stone-850 to-stone-900 border border-stone-800 text-center flex flex-col items-center justify-center space-y-4">
|
||||
<div className="w-16 h-16 rounded-3xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center shadow-lg">
|
||||
<Lock className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h4 className="text-xl font-black text-white">Registered Golfer Feature</h4>
|
||||
<p className="text-xs text-stone-400 leading-relaxed">
|
||||
Career Trophies & Achievements are exclusively available for registered golfers. Sign in or create an account to start unlocking milestones as you play and save rounds!
|
||||
</p>
|
||||
</div>
|
||||
{onOpenAuth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenAuth();
|
||||
}}
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-stone-950 font-extrabold text-xs rounded-xl shadow-lg transition active:scale-98 flex items-center gap-2"
|
||||
>
|
||||
<Trophy className="w-4 h-4 font-black" />
|
||||
<span>Sign In or Register to Unlock Trophies</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Global Progress Strip */}
|
||||
<div className="my-4 p-4 rounded-2xl bg-stone-850 border border-stone-800 shrink-0 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs font-mono">
|
||||
<span className="text-stone-300 font-sans font-bold">Career Trophies Unlocked:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400 font-bold text-sm">
|
||||
{unlockedCount} / {totalCount}
|
||||
</span>
|
||||
<span className="text-stone-500">({percentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-stone-800 h-2.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
style={{ width: `${percentage}%` }}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full bg-stone-800 h-2.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
style={{ width: `${percentage}%` }}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tier Counts Pill Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-amber-400 font-bold uppercase block">Bronze</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.bronze.unlocked} / {tierCounts.bronze.total}
|
||||
</span>
|
||||
{unlockedCount === 0 && (
|
||||
<div className="p-2.5 bg-stone-900/90 border border-stone-800 rounded-xl flex items-center gap-2 text-xs text-stone-300">
|
||||
<Sparkles className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<span>
|
||||
<strong>New Golfer:</strong> 0 trophies unlocked. Finish and save a round to earn your first achievements!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tier Counts Pill Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-amber-400 font-bold uppercase block">Bronze</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.bronze.unlocked} / {tierCounts.bronze.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-slate-300 font-bold uppercase block">Silver</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.silver.unlocked} / {tierCounts.silver.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-amber-300 font-bold uppercase block">Gold</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.gold.unlocked} / {tierCounts.gold.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-cyan-300 font-bold uppercase block">Platinum</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.platinum.unlocked} / {tierCounts.platinum.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-slate-300 font-bold uppercase block">Silver</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.silver.unlocked} / {tierCounts.silver.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-amber-300 font-bold uppercase block">Gold</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.gold.unlocked} / {tierCounts.gold.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-cyan-300 font-bold uppercase block">Platinum</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.platinum.unlocked} / {tierCounts.platinum.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pb-3 shrink-0">
|
||||
@@ -312,6 +353,8 @@ export const AchievementsModal: React.FC<AchievementsModalProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-stone-800 flex items-center justify-end shrink-0">
|
||||
|
||||
@@ -282,16 +282,18 @@ export const AuthModal: React.FC<AuthModalProps> = ({
|
||||
{submitting ? 'Authenticating...' : 'Sign In to Account'}
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-stone-800/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickDemo}
|
||||
className="w-full py-2.5 bg-stone-800/60 hover:bg-stone-800 border border-stone-700/60 rounded-2xl text-stone-300 text-xs font-medium flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
One-Click Test Login as Demo Golfer (demogolfer / golf1234)
|
||||
</button>
|
||||
</div>
|
||||
{!import.meta.env.PROD && (
|
||||
<div className="pt-3 border-t border-stone-800/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickDemo}
|
||||
className="w-full py-2.5 bg-stone-800/60 hover:bg-stone-800 border border-stone-700/60 rounded-2xl text-stone-300 text-xs font-medium flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
One-Click Dev Login as Demo Golfer (demogolfer / golf1234)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
||||
@@ -116,9 +116,36 @@ export const CommunityComparisonModal: React.FC<CommunityComparisonModalProps> =
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Switcher & Privacy Bar */}
|
||||
<div className="px-6 py-3 bg-stone-950/40 border-b border-stone-800 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="flex bg-stone-950 p-1 rounded-2xl border border-stone-800 w-full sm:w-auto">
|
||||
{!user ? (
|
||||
<div className="p-8 sm:p-14 text-center flex flex-col items-center justify-center space-y-4 my-auto">
|
||||
<div className="w-16 h-16 rounded-3xl bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center justify-center shadow-lg">
|
||||
<Users className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-2">
|
||||
<h3 className="text-xl font-black text-white">Registered Golfer Feature</h3>
|
||||
<p className="text-xs text-stone-400 leading-relaxed">
|
||||
The Community Benchmark & Public Leaderboard are reserved for registered golfers. Sign in or create an account to compare your scoring, FIR, GIR, putts, and scrambles against other players!
|
||||
</p>
|
||||
</div>
|
||||
{onOpenAuthModal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenAuthModal();
|
||||
}}
|
||||
className="px-6 py-3 bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white font-extrabold text-xs rounded-xl shadow-lg transition active:scale-98 flex items-center gap-2"
|
||||
>
|
||||
<Users className="w-4 h-4 font-bold" />
|
||||
<span>Sign In or Register to Join Community</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* View Switcher & Privacy Bar */}
|
||||
<div className="px-6 py-3 bg-stone-950/40 border-b border-stone-800 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="flex bg-stone-950 p-1 rounded-2xl border border-stone-800 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('compare')}
|
||||
className={`flex-1 sm:flex-initial px-4 py-2 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition ${
|
||||
@@ -438,6 +465,8 @@ export const CommunityComparisonModal: React.FC<CommunityComparisonModalProps> =
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-stone-800 bg-stone-950/80 flex items-center justify-between text-xs text-stone-500">
|
||||
|
||||
+206
-13
@@ -12,6 +12,10 @@ import {
|
||||
Waves,
|
||||
ShieldAlert,
|
||||
HelpCircle,
|
||||
Lock,
|
||||
Navigation,
|
||||
Trophy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Course,
|
||||
@@ -24,6 +28,9 @@ import {
|
||||
} from '../types/golf';
|
||||
import { getScoreName, formatToPar } from '../utils/golfStats';
|
||||
import { GolfGpsRangefinder } from './GolfGpsRangefinder';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { evaluateSingleHoleAchievements, getAchievementById } from '../utils/achievements';
|
||||
|
||||
|
||||
interface HoleEntryProps {
|
||||
currentHoleNumber: number;
|
||||
@@ -33,6 +40,9 @@ interface HoleEntryProps {
|
||||
onUpdateHoleScore: (holeNumber: number, updated: HoleScore) => void;
|
||||
settings: TrackingSettings;
|
||||
onViewScorecard: () => void;
|
||||
isCompleted?: boolean;
|
||||
onOpenSaveModal?: () => void;
|
||||
onOpenAuth?: () => void;
|
||||
}
|
||||
|
||||
export const HoleEntry: React.FC<HoleEntryProps> = ({
|
||||
@@ -43,7 +53,11 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
|
||||
onUpdateHoleScore,
|
||||
settings,
|
||||
onViewScorecard,
|
||||
isCompleted = false,
|
||||
onOpenSaveModal,
|
||||
onOpenAuth,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const currentHole: Hole = course.holes.find((h) => h.holeNumber === currentHoleNumber) || course.holes[0];
|
||||
const holeScore: HoleScore = scores[currentHoleNumber] || {
|
||||
holeNumber: currentHoleNumber,
|
||||
@@ -61,12 +75,53 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
|
||||
};
|
||||
|
||||
const [showGps, setShowGps] = useState(true);
|
||||
const [provisionalToast, setProvisionalToast] = useState<{
|
||||
type: 'unlocked' | 'retracted';
|
||||
title: string;
|
||||
description: string;
|
||||
} | null>(null);
|
||||
|
||||
// Active provisional achievements for this hole
|
||||
const currentProvisionalIds = evaluateSingleHoleAchievements(currentHole, holeScore);
|
||||
const currentProvisionalAchievements = currentProvisionalIds
|
||||
.map((id) => getAchievementById(id))
|
||||
.filter(Boolean);
|
||||
|
||||
const updateField = <K extends keyof HoleScore>(field: K, value: HoleScore[K]) => {
|
||||
if (isCompleted) return;
|
||||
const updated = { ...holeScore, [field]: value };
|
||||
|
||||
// Detect provisional achievement transitions for this hole
|
||||
const prevFeats = evaluateSingleHoleAchievements(currentHole, holeScore);
|
||||
const nextFeats = evaluateSingleHoleAchievements(currentHole, updated);
|
||||
|
||||
const newlyUnlocked = nextFeats.filter((id) => !prevFeats.includes(id));
|
||||
const retracted = prevFeats.filter((id) => !nextFeats.includes(id));
|
||||
|
||||
if (newlyUnlocked.length > 0) {
|
||||
const ach = getAchievementById(newlyUnlocked[0]);
|
||||
if (ach) {
|
||||
setProvisionalToast({
|
||||
type: 'unlocked',
|
||||
title: `🎉 ${ach.title}!`,
|
||||
description: `Provisional in-round achievement for Hole ${currentHoleNumber}! This will be permanently recorded into your career profile once this round is finalized and saved.`,
|
||||
});
|
||||
}
|
||||
} else if (retracted.length > 0) {
|
||||
const ach = getAchievementById(retracted[0]);
|
||||
if (ach) {
|
||||
setProvisionalToast({
|
||||
type: 'retracted',
|
||||
title: `ℹ️ Provisional ${ach.title} Retracted`,
|
||||
description: `Score modified on Hole ${currentHoleNumber}. The achievement has been retracted until qualified again.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onUpdateHoleScore(currentHoleNumber, updated);
|
||||
};
|
||||
|
||||
|
||||
const currentGross = holeScore.grossScore || currentHole.par;
|
||||
const scoreDiff = currentGross - currentHole.par;
|
||||
const scoreTitle = getScoreName(currentGross, currentHole.par);
|
||||
@@ -182,6 +237,31 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 sm:py-6 space-y-6 pb-28 md:pb-16">
|
||||
{/* Locked Notice Banner */}
|
||||
{isCompleted && (
|
||||
<div className="bg-emerald-950/40 border border-emerald-500/60 rounded-2xl p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center shrink-0">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase text-emerald-400 tracking-wider">
|
||||
Official Finalized Round (Locked)
|
||||
</div>
|
||||
<p className="text-xs text-stone-300 mt-0.5">
|
||||
This round has been saved and permanently locked. Scores cannot be edited (saved rounds can only be deleted). Start a new round to track a live game.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onViewScorecard}
|
||||
className="px-3.5 py-2 bg-stone-800 hover:bg-stone-700 text-stone-200 text-xs font-bold rounded-xl shrink-0 transition"
|
||||
>
|
||||
View Scorecard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Quick Hole Navigation Strip */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-2.5 sm:p-3 shadow-md">
|
||||
<div className="flex items-center justify-between gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||
@@ -269,15 +349,113 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2.5 Active Google Maps GPS Rangefinder (Front / Middle / Back) */}
|
||||
{/* 2.5 Active Google Maps GPS Rangefinder (Front / Middle / Back) - Registered Users Only */}
|
||||
{showGps && (
|
||||
<GolfGpsRangefinder
|
||||
currentHole={currentHole}
|
||||
course={course}
|
||||
onSelectHole={setCurrentHoleNumber}
|
||||
unit={settings.distanceUnit}
|
||||
onSaveDriveDistance={(dist) => updateField('driveDistanceYds', dist)}
|
||||
/>
|
||||
user ? (
|
||||
<GolfGpsRangefinder
|
||||
currentHole={currentHole}
|
||||
course={course}
|
||||
onSelectHole={setCurrentHoleNumber}
|
||||
unit={settings.distanceUnit}
|
||||
onSaveDriveDistance={(dist) => updateField('driveDistanceYds', dist)}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gradient-to-br from-stone-900 to-stone-850 border border-stone-800 rounded-2xl p-5 shadow-xl">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="w-11 h-11 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center justify-center shrink-0 shadow-inner">
|
||||
<Navigation className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-extrabold text-white">Satellite GPS Rangefinder</span>
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
||||
Sign-In Required
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400 mt-1 max-w-xl leading-relaxed">
|
||||
Live GPS pin distances (Front, Center, Back of green), satellite flyover maps, crosshair yardage targeting, and tee-shot distance measurements are reserved for registered golfers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onOpenAuth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAuth}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold rounded-xl shadow-md transition-all active:scale-98 shrink-0 flex items-center gap-1.5"
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span>Sign In to Unlock GPS</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Provisional Achievement Live Toast Banner */}
|
||||
{provisionalToast && (
|
||||
<div
|
||||
className={`p-4 rounded-2xl border transition-all animate-in fade-in flex items-start justify-between gap-3 shadow-lg ${
|
||||
provisionalToast.type === 'unlocked'
|
||||
? 'bg-amber-500/15 border-amber-500/40 text-amber-200'
|
||||
: 'bg-stone-800 border-stone-700 text-stone-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
provisionalToast.type === 'unlocked'
|
||||
? 'bg-amber-500/20 text-amber-300 ring-1 ring-amber-400/30'
|
||||
: 'bg-stone-700 text-stone-300'
|
||||
}`}
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-extrabold text-sm text-white">
|
||||
{provisionalToast.title}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase font-mono tracking-wider px-2 py-0.5 rounded bg-black/30 font-bold">
|
||||
{provisionalToast.type === 'unlocked' ? 'Provisional In-Round' : 'Notice'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-300 mt-1 leading-relaxed">
|
||||
{provisionalToast.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvisionalToast(null)}
|
||||
className="p-1 rounded-lg text-stone-400 hover:text-white transition-colors"
|
||||
aria-label="Dismiss notice"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Provisional Achievements Indicator */}
|
||||
{currentProvisionalAchievements.length > 0 && (
|
||||
<div className="bg-amber-950/30 border border-amber-500/30 rounded-2xl p-3.5 flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Sparkles className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<div>
|
||||
<span className="font-extrabold text-amber-300">
|
||||
Provisional Feat: {currentProvisionalAchievements.map((a) => a?.title).join(', ')}
|
||||
</span>
|
||||
<span className="text-stone-400 block text-[11px] mt-0.5">
|
||||
Qualified on Hole {currentHole.holeNumber}. Finalized upon saving round; revoked if score is changed.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30 font-bold shrink-0">
|
||||
Pending Save
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Main Gross Score Stepper & Dial */}
|
||||
@@ -899,28 +1077,43 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
|
||||
)}
|
||||
|
||||
{/* 11. Bottom Action Bar */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewScorecard}
|
||||
className="px-4 py-3 bg-stone-800 hover:bg-stone-750 text-stone-300 hover:text-white text-xs font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
View Full Card
|
||||
</button>
|
||||
|
||||
{!isCompleted && onOpenSaveModal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSaveModal}
|
||||
className="px-4 py-3 bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 text-xs font-bold rounded-xl transition-colors flex items-center gap-1.5"
|
||||
title="Finalize and permanently save this round"
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span>Finalize & Save Round</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentHoleNumber < course.holes.length ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentHoleNumber(currentHoleNumber + 1)}
|
||||
className="flex-1 max-w-xs py-3 px-6 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-bold rounded-xl shadow-lg shadow-emerald-950/40 flex items-center justify-center gap-2 transition-all active:scale-98"
|
||||
>
|
||||
<span>Save & Next Hole</span>
|
||||
<span>{isCompleted ? 'Next Hole' : 'Save & Next Hole'}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onViewScorecard}
|
||||
className="flex-1 max-w-xs py-3 px-6 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-bold rounded-xl shadow-lg shadow-emerald-950/40 flex items-center justify-center gap-2 transition-all active:scale-98"
|
||||
type="button"
|
||||
onClick={!isCompleted && onOpenSaveModal ? onOpenSaveModal : onViewScorecard}
|
||||
className="flex-1 max-w-xs py-3 px-6 bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white text-sm font-extrabold rounded-xl shadow-lg shadow-emerald-950/50 flex items-center justify-center gap-2 transition-all active:scale-98"
|
||||
>
|
||||
<span>Finish Round & View Card</span>
|
||||
<span>{!isCompleted ? 'Finalize & Save Round' : 'View Final Scorecard'}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ChevronDown,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Round } from '../types/golf';
|
||||
import { formatToPar } from '../utils/golfStats';
|
||||
@@ -24,7 +25,7 @@ export type ActiveTab = 'hole' | 'scorecard' | 'analytics' | 'settings';
|
||||
interface NavbarProps {
|
||||
activeTab: ActiveTab;
|
||||
setActiveTab: (tab: ActiveTab) => void;
|
||||
currentRound: Round;
|
||||
currentRound?: Round | null;
|
||||
onNewRound: () => void;
|
||||
onOpenRounds: () => void;
|
||||
onOpenAchievements: () => void;
|
||||
@@ -71,8 +72,14 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
<span className="text-lg font-extrabold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
|
||||
PinPoint Golf
|
||||
</span>
|
||||
<span className="hidden lg:inline-block ml-2 text-xs font-medium text-stone-400">
|
||||
{currentRound.courseName}
|
||||
<span className="hidden lg:inline-flex items-center ml-2 text-xs font-medium text-stone-400">
|
||||
{currentRound ? currentRound.courseName : 'No Active Round'}
|
||||
{currentRound?.isCompleted && (
|
||||
<span className="ml-1.5 text-[10px] font-bold text-emerald-400 bg-stone-800 border border-emerald-500/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -125,9 +132,11 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
<button
|
||||
onClick={onOpenCommunity}
|
||||
className="px-3 py-1.5 rounded-lg text-amber-300/90 hover:text-amber-200 hover:bg-amber-500/10 transition-colors flex items-center gap-1.5"
|
||||
title={user ? 'Community Leaderboards' : 'Community (Sign-in required)'}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Community</span>
|
||||
{!user && <Lock className="w-2.5 h-2.5 text-amber-400/80" />}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -156,19 +165,26 @@ export const Navbar: React.FC<NavbarProps> = ({
|
||||
<button
|
||||
onClick={onOpenAchievements}
|
||||
className="flex items-center gap-1 px-2 sm:px-2.5 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/30 rounded-lg text-xs font-bold transition-all shadow-sm"
|
||||
title="Career Trophies & Achievements"
|
||||
title={user ? 'Career Trophies & Achievements' : 'Trophies (Sign-in required)'}
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="font-mono text-xs">{achievementsUnlockedCount}</span>
|
||||
{user ? (
|
||||
<span className="font-mono text-xs">{achievementsUnlockedCount}</span>
|
||||
) : (
|
||||
<Lock className="w-3 h-3 text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mobile Community Button */}
|
||||
<button
|
||||
onClick={onOpenCommunity}
|
||||
className="md:hidden p-1.5 text-amber-300 hover:bg-amber-500/10 rounded-lg transition"
|
||||
title="Community Leaderboard"
|
||||
className="md:hidden p-1.5 text-amber-300 hover:bg-amber-500/10 rounded-lg transition relative"
|
||||
title={user ? 'Community Leaderboard' : 'Community (Sign-in required)'}
|
||||
>
|
||||
<Users className="w-4 h-4 text-amber-400" />
|
||||
{!user && (
|
||||
<Lock className="w-2.5 h-2.5 text-amber-400 absolute top-1 right-1" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Saved Rounds */}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { X, Calendar, MapPin, Trash2, CheckCircle2, RotateCcw } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { X, Calendar, MapPin, Trash2, CheckCircle2, RotateCcw, Lock, AlertTriangle } from 'lucide-react';
|
||||
import { Round } from '../types/golf';
|
||||
import { formatToPar } from '../utils/golfStats';
|
||||
import { SAMPLE_ROUND } from '../data/sampleRounds';
|
||||
|
||||
interface RoundsHistoryModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -11,7 +10,7 @@ interface RoundsHistoryModalProps {
|
||||
currentRoundId: string;
|
||||
onSelectRound: (roundId: string) => void;
|
||||
onDeleteRound: (roundId: string) => void;
|
||||
onLoadSampleRound: () => void;
|
||||
onLoadSampleRound?: () => void;
|
||||
}
|
||||
|
||||
export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
|
||||
@@ -23,8 +22,17 @@ export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
|
||||
onDeleteRound,
|
||||
onLoadSampleRound,
|
||||
}) => {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isProduction = import.meta.env.PROD;
|
||||
|
||||
const handleConfirmDelete = (roundId: string) => {
|
||||
onDeleteRound(roundId);
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl max-w-xl w-full p-6 shadow-2xl relative max-h-[85vh] flex flex-col">
|
||||
@@ -33,7 +41,7 @@ export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
|
||||
<div>
|
||||
<h3 className="text-xl font-extrabold text-white">Rounds & History</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
Review saved rounds or reload the sample tour analytics round
|
||||
Review your saved scorecards. Saved rounds can only be viewed or deleted.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -44,117 +52,168 @@ export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Inline Banner */}
|
||||
{deletingId && (
|
||||
<div className="my-3 p-3.5 bg-rose-950/60 border border-rose-500/60 rounded-2xl flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-center gap-2 text-rose-200">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<span>Are you sure you want to permanently delete this saved round?</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="px-2.5 py-1 rounded-lg bg-stone-800 text-stone-300 hover:text-white font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleConfirmDelete(deletingId)}
|
||||
className="px-2.5 py-1 rounded-lg bg-rose-600 hover:bg-rose-500 text-white font-bold"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rounds List */}
|
||||
<div className="space-y-3 my-4 overflow-y-auto flex-1 pr-1">
|
||||
{rounds.map((r) => {
|
||||
const isCurrent = r.id === currentRoundId;
|
||||
{rounds.length === 0 ? (
|
||||
<div className="p-8 text-center text-stone-400 text-xs flex flex-col items-center justify-center space-y-2">
|
||||
<Calendar className="w-8 h-8 text-stone-600 mb-1" />
|
||||
<p className="font-bold text-stone-300 text-sm">No Saved Rounds Yet</p>
|
||||
<p className="text-stone-500 max-w-xs">
|
||||
Start a new round from the top navigation, play holes, and finalize it to build your career history.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
rounds.map((r) => {
|
||||
const isCurrent = r.id === currentRoundId;
|
||||
|
||||
// Calculate quick score
|
||||
let grossTotal = 0;
|
||||
let holesScored = 0;
|
||||
Object.values(r.scores).forEach((s) => {
|
||||
if (s.grossScore && s.grossScore > 0) {
|
||||
grossTotal += s.grossScore;
|
||||
holesScored++;
|
||||
}
|
||||
});
|
||||
const diff = grossTotal - r.coursePar;
|
||||
// Calculate quick score
|
||||
let grossTotal = 0;
|
||||
let holesScored = 0;
|
||||
Object.values(r.scores).forEach((s) => {
|
||||
if (s.grossScore && s.grossScore > 0) {
|
||||
grossTotal += s.grossScore;
|
||||
holesScored++;
|
||||
}
|
||||
});
|
||||
const diff = grossTotal - r.coursePar;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`p-4 rounded-2xl border transition-all flex items-center justify-between gap-4 ${
|
||||
isCurrent
|
||||
? 'bg-emerald-950/30 border-emerald-500/80'
|
||||
: 'bg-stone-850 border-stone-800 hover:bg-stone-800/80'
|
||||
}`}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelectRound(r.id);
|
||||
onClose();
|
||||
}}
|
||||
className="cursor-pointer flex-1"
|
||||
key={r.id}
|
||||
className={`p-4 rounded-2xl border transition-all flex items-center justify-between gap-4 ${
|
||||
isCurrent
|
||||
? 'bg-emerald-950/30 border-emerald-500/80'
|
||||
: 'bg-stone-850 border-stone-800 hover:bg-stone-800/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white text-sm">{r.courseName}</span>
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-stone-400 mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{r.date}</span>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{r.teeName}</span>
|
||||
</div>
|
||||
{r.weatherData ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[11px] text-amber-300 font-mono">
|
||||
🌡️ {r.weatherData.temperature}°C · 💨 {r.weatherData.windSpeed} km/h · 🌧️ {r.weatherData.precipitationProbability}%
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelectRound(r.id);
|
||||
onClose();
|
||||
}}
|
||||
className="cursor-pointer flex-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-white text-sm">{r.courseName}</span>
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
|
||||
Active
|
||||
</span>
|
||||
</>
|
||||
) : r.weather ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[11px] text-stone-400">
|
||||
{r.weather}
|
||||
)}
|
||||
{r.isCompleted ? (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-stone-800 text-emerald-400 border border-emerald-500/30 flex items-center gap-1">
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
Finalized
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score & Actions */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-base font-extrabold font-mono text-white">
|
||||
{grossTotal > 0 ? grossTotal : '—'}
|
||||
) : (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
||||
In Progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs font-mono font-semibold ${
|
||||
diff < 0 ? 'text-emerald-400' : diff > 0 ? 'text-rose-400' : 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{holesScored > 0 ? formatToPar(diff) : 'In progress'}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-stone-400 mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{r.date}</span>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{r.teeName}</span>
|
||||
</div>
|
||||
{r.weatherData ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[11px] text-amber-300 font-mono">
|
||||
🌡️ {r.weatherData.temperature}°C · 💨 {r.weatherData.windSpeed} km/h · 🌧️ {r.weatherData.precipitationProbability}%
|
||||
</span>
|
||||
</>
|
||||
) : r.weather ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[11px] text-stone-400">
|
||||
{r.weather}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rounds.length > 1 && (
|
||||
{/* Score & Delete Action */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-base font-extrabold font-mono text-white">
|
||||
{grossTotal > 0 ? grossTotal : '—'}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs font-mono font-semibold ${
|
||||
diff < 0 ? 'text-emerald-400' : diff > 0 ? 'text-rose-400' : 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{holesScored > 0 ? formatToPar(diff) : 'In progress'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onDeleteRound(r.id)}
|
||||
type="button"
|
||||
onClick={() => setDeletingId(r.id)}
|
||||
className="p-2 text-stone-500 hover:text-rose-400 hover:bg-stone-800 rounded-xl transition-colors"
|
||||
title="Delete round"
|
||||
title="Delete round (only permitted action on saved rounds)"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with Sample Round restore */}
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-stone-800 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLoadSampleRound();
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white text-xs font-semibold transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Load Sample 18-Hole Round</span>
|
||||
</button>
|
||||
{!isProduction && onLoadSampleRound ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
onLoadSampleRound();
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white text-xs font-semibold transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Load Dev Sample Round</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-xs text-stone-500">
|
||||
{rounds.length} {rounds.length === 1 ? 'round' : 'rounds'} stored
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Trophy,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Course, Round } from '../types/golf';
|
||||
import { calculateRoundStats, formatToPar } from '../utils/golfStats';
|
||||
|
||||
interface SaveRoundModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmSave: () => void;
|
||||
round: Round;
|
||||
course: Course;
|
||||
isAuthenticated: boolean;
|
||||
onOpenAuth?: () => void;
|
||||
}
|
||||
|
||||
export const SaveRoundModal: React.FC<SaveRoundModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirmSave,
|
||||
round,
|
||||
course,
|
||||
isAuthenticated,
|
||||
onOpenAuth,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const stats = calculateRoundStats(round, course);
|
||||
const totalHoles = round.holesPlayed || course.holes.length;
|
||||
const scoredCount = stats.holesPlayedCount;
|
||||
const unplayedCount = Math.max(0, totalHoles - scoredCount);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl max-w-lg w-full p-6 shadow-2xl relative max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-stone-800 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-amber-500/20 border border-amber-500/40 text-amber-400 flex items-center justify-center shadow-md">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-extrabold text-white">
|
||||
Finalize & Save Round
|
||||
</h3>
|
||||
<p className="text-xs text-stone-400">
|
||||
Confirm your score to permanently lock this round
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-stone-400 hover:text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="space-y-4 my-4 overflow-y-auto flex-1 pr-1">
|
||||
{/* Irreversible Lock Warning Banner */}
|
||||
<div className="bg-amber-950/40 border border-amber-500/50 rounded-2xl p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-amber-200/90 leading-relaxed">
|
||||
<span className="font-bold text-amber-300 block mb-0.5 uppercase tracking-wider text-[11px]">
|
||||
Notice: Editing Will Not Be Possible
|
||||
</span>
|
||||
Once saved, this round will be permanently finalized. You will{' '}
|
||||
<strong className="text-white underline">not</strong> be able to edit individual
|
||||
hole scores, putts, or stats later. Saved rounds can only be deleted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unplayed Holes Notice (if applicable) */}
|
||||
{unplayedCount > 0 && (
|
||||
<div className="bg-stone-800/60 border border-stone-700/60 rounded-xl p-3 text-xs text-stone-300 flex items-center gap-2">
|
||||
<span className="text-amber-400 font-bold">ℹ️ Note:</span>
|
||||
<span>
|
||||
{unplayedCount} of {totalHoles} holes have no score entered yet. Unplayed holes
|
||||
will be omitted from final scoring averages.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Round Summary Card */}
|
||||
<div className="bg-stone-850 border border-stone-800 rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-stone-800">
|
||||
<div>
|
||||
<h4 className="font-extrabold text-white text-base">{round.courseName}</h4>
|
||||
<div className="flex items-center gap-2 text-xs text-stone-400 mt-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{round.date}</span>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{round.teeName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Score Pill */}
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-black font-mono text-white">
|
||||
{stats.totalGross > 0 ? stats.totalGross : '—'}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs font-mono font-bold ${
|
||||
stats.toPar < 0
|
||||
? 'text-emerald-400'
|
||||
: stats.toPar > 0
|
||||
? 'text-rose-400'
|
||||
: 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{scoredCount > 0 ? formatToPar(stats.toPar) : 'Unscored'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Metrics Grid */}
|
||||
<div className="grid grid-cols-4 gap-2 text-center pt-1">
|
||||
<div className="bg-stone-900/80 p-2.5 rounded-xl border border-stone-800/80">
|
||||
<div className="text-[10px] text-stone-400 uppercase font-semibold">Holes</div>
|
||||
<div className="text-sm font-extrabold text-white font-mono mt-0.5">
|
||||
{scoredCount}/{totalHoles}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-stone-900/80 p-2.5 rounded-xl border border-stone-800/80">
|
||||
<div className="text-[10px] text-stone-400 uppercase font-semibold">FIR %</div>
|
||||
<div className="text-sm font-extrabold text-emerald-400 font-mono mt-0.5">
|
||||
{stats.firPercentage}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-stone-900/80 p-2.5 rounded-xl border border-stone-800/80">
|
||||
<div className="text-[10px] text-stone-400 uppercase font-semibold">GIR %</div>
|
||||
<div className="text-sm font-extrabold text-emerald-400 font-mono mt-0.5">
|
||||
{stats.girPercentage}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-stone-900/80 p-2.5 rounded-xl border border-stone-800/80">
|
||||
<div className="text-[10px] text-stone-400 uppercase font-semibold">Putts</div>
|
||||
<div className="text-sm font-extrabold text-white font-mono mt-0.5">
|
||||
{stats.totalPutts}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Achievement Evaluation Preview Banner */}
|
||||
{isAuthenticated ? (
|
||||
<div className="bg-emerald-950/30 border border-emerald-500/40 rounded-2xl p-3 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center shrink-0">
|
||||
<Trophy className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="text-xs text-emerald-200">
|
||||
<span className="font-bold text-white block">Trophy Evaluation Ready</span>
|
||||
Career achievements will be evaluated and awarded immediately upon saving this round!
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-stone-800/40 border border-stone-700/60 rounded-2xl p-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 text-xs text-stone-300">
|
||||
<Trophy className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<span>
|
||||
Playing as guest. <strong className="text-white">Sign in</strong> to sync rounds and earn trophies!
|
||||
</span>
|
||||
</div>
|
||||
{onOpenAuth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAuth}
|
||||
className="px-2.5 py-1 text-[11px] font-bold text-amber-400 hover:text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 rounded-lg border border-amber-500/30 shrink-0 transition"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 border-t border-stone-800 flex items-center justify-between gap-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-xl bg-stone-800 hover:bg-stone-750 text-stone-300 hover:text-white text-xs font-semibold transition-colors"
|
||||
>
|
||||
Keep Playing / Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConfirmSave();
|
||||
onClose();
|
||||
}}
|
||||
className="flex-1 py-2.5 px-5 bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white text-xs font-extrabold rounded-xl shadow-lg shadow-emerald-950/50 flex items-center justify-center gap-2 transition-all active:scale-98"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Confirm & Save Round</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
Share2,
|
||||
Check,
|
||||
Edit3,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Course, Round, TrackingSettings } from '../types/golf';
|
||||
import { calculateRoundStats, formatToPar } from '../utils/golfStats';
|
||||
@@ -13,6 +16,7 @@ interface ScorecardProps {
|
||||
course: Course;
|
||||
settings: TrackingSettings;
|
||||
onEditHole: (holeNumber: number) => void;
|
||||
onOpenSaveModal?: () => void;
|
||||
}
|
||||
|
||||
export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
@@ -20,6 +24,7 @@ export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
course,
|
||||
settings,
|
||||
onEditHole,
|
||||
onOpenSaveModal,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const stats = calculateRoundStats(round, course);
|
||||
@@ -185,8 +190,13 @@ export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
{holes.map((h) => (
|
||||
<th
|
||||
key={h.holeNumber}
|
||||
onClick={() => onEditHole(h.holeNumber)}
|
||||
className="py-2.5 px-1 sm:px-2 min-w-[36px] sm:min-w-[42px] cursor-pointer hover:bg-stone-800 hover:text-white transition-colors"
|
||||
onClick={() => !round.isCompleted && onEditHole(h.holeNumber)}
|
||||
className={`py-2.5 px-1 sm:px-2 min-w-[36px] sm:min-w-[42px] transition-colors ${
|
||||
!round.isCompleted
|
||||
? 'cursor-pointer hover:bg-stone-800 hover:text-white'
|
||||
: 'cursor-default text-stone-300'
|
||||
}`}
|
||||
title={round.isCompleted ? 'Saved round (locked)' : `Edit Hole ${h.holeNumber}`}
|
||||
>
|
||||
{h.holeNumber}
|
||||
</th>
|
||||
@@ -234,16 +244,24 @@ export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
<tr className="bg-stone-850 font-bold border-y border-stone-700">
|
||||
<td className="py-3 px-3 text-left font-sans text-white flex items-center gap-1.5">
|
||||
<span>Score</span>
|
||||
<Edit3 className="w-3 h-3 text-stone-500" />
|
||||
{round.isCompleted ? (
|
||||
<span title="Locked"><Lock className="w-3 h-3 text-emerald-400" /></span>
|
||||
) : (
|
||||
<Edit3 className="w-3 h-3 text-stone-500" />
|
||||
)}
|
||||
</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
return (
|
||||
<td
|
||||
key={h.holeNumber}
|
||||
onClick={() => onEditHole(h.holeNumber)}
|
||||
className="py-2 px-1 cursor-pointer hover:bg-stone-750 transition-colors"
|
||||
title={`Edit Hole ${h.holeNumber}`}
|
||||
onClick={() => !round.isCompleted && onEditHole(h.holeNumber)}
|
||||
className={`py-2 px-1 transition-colors ${
|
||||
!round.isCompleted
|
||||
? 'cursor-pointer hover:bg-stone-750'
|
||||
: 'cursor-default'
|
||||
}`}
|
||||
title={round.isCompleted ? 'Saved round (locked)' : `Edit Hole ${h.holeNumber}`}
|
||||
>
|
||||
{renderScoreCell(s?.grossScore, h.par)}
|
||||
</td>
|
||||
@@ -455,12 +473,22 @@ export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider">
|
||||
<span>Official Scorecard</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-wider">
|
||||
{round.isCompleted ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 text-[11px] font-bold">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span>Finalized & Locked</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[11px] font-bold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
<span>Live Scoring (In Progress)</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-stone-600">·</span>
|
||||
<span>{round.date}</span>
|
||||
<span className="text-stone-300">{round.date}</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span>Handicap: {round.playerHandicap}</span>
|
||||
<span className="text-stone-400">HCP: {round.playerHandicap}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-white mt-1">
|
||||
{course.name}
|
||||
@@ -495,7 +523,7 @@ export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Big Score Summary & Actions */}
|
||||
<div className="relative z-10 flex items-center gap-3">
|
||||
<div className="relative z-10 flex flex-wrap items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-xs uppercase tracking-wider text-stone-400 font-semibold">
|
||||
Total Score
|
||||
@@ -518,7 +546,18 @@ export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<div className="flex items-center gap-2 ml-1">
|
||||
{onOpenSaveModal && !round.isCompleted && (
|
||||
<button
|
||||
onClick={onOpenSaveModal}
|
||||
className="px-3 py-2 rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white shadow-lg shadow-emerald-950/50 flex items-center gap-1.5 text-xs font-bold transition-all active:scale-98"
|
||||
title="Finalize and permanently save this round"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Save Round</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleShareSummary}
|
||||
className="p-2.5 rounded-xl bg-stone-800 hover:bg-stone-700 text-stone-200 transition-colors flex items-center gap-1.5 text-xs font-semibold"
|
||||
@@ -654,7 +693,14 @@ export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
<span>Double Bogey+</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-stone-500">
|
||||
<span>Click any cell to edit that hole</span>
|
||||
{round.isCompleted ? (
|
||||
<span className="flex items-center gap-1 text-emerald-400 font-semibold">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
Round finalized & locked — scores cannot be edited (saved rounds can only be deleted).
|
||||
</span>
|
||||
) : (
|
||||
<span>Click any cell to edit that hole</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+131
-1
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Compass,
|
||||
Crosshair,
|
||||
@@ -6,9 +6,15 @@ import {
|
||||
RotateCcw,
|
||||
Sliders,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Server,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { PresetProfile, TrackingSettings } from '../types/golf';
|
||||
import { SETTING_PRESETS, DEFAULT_TRACKING_SETTINGS } from '../data/courses';
|
||||
import { roundsApi } from '../services/api';
|
||||
|
||||
|
||||
interface SettingsProps {
|
||||
settings: TrackingSettings;
|
||||
@@ -19,7 +25,32 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
settings,
|
||||
onUpdateSettings,
|
||||
}) => {
|
||||
const [dbStatus, setDbStatus] = useState<{
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
host: string | null;
|
||||
database: string | null;
|
||||
port: number;
|
||||
lastError: string | null;
|
||||
engine: string;
|
||||
} | null>(null);
|
||||
const [loadingDbStatus, setLoadingDbStatus] = useState(false);
|
||||
|
||||
const fetchDbStatus = () => {
|
||||
setLoadingDbStatus(true);
|
||||
roundsApi
|
||||
.getDbStatus()
|
||||
.then((status) => setDbStatus(status))
|
||||
.catch((err) => console.warn('Could not fetch db status:', err))
|
||||
.finally(() => setLoadingDbStatus(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDbStatus();
|
||||
}, []);
|
||||
|
||||
const toggleSetting = (key: keyof TrackingSettings) => {
|
||||
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
[key]: !settings[key],
|
||||
@@ -558,6 +589,105 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database & Storage Architecture Card */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center">
|
||||
<Database className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white">Database & Persistence Engine</h2>
|
||||
<p className="text-xs text-stone-400">
|
||||
MariaDB Galera cluster integration & dynamic achievement lifecycle
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchDbStatus}
|
||||
disabled={loadingDbStatus}
|
||||
className="px-3 py-1.5 rounded-xl bg-stone-800 hover:bg-stone-750 text-stone-300 text-xs font-semibold flex items-center gap-1.5 transition-colors active:scale-98"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loadingDbStatus ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh Status</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Active Backend Engine Status */}
|
||||
<div className="bg-stone-850 border border-stone-800 rounded-2xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-stone-400">
|
||||
Active Storage Engine
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
|
||||
dbStatus?.connected
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
||||
: 'bg-stone-800 text-stone-300 border border-stone-700'
|
||||
}`}
|
||||
>
|
||||
{dbStatus?.connected ? 'Cluster Online' : 'Local Standby'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-base font-extrabold text-white">
|
||||
{dbStatus?.engine || 'Local Persistent Storage'}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-stone-400 space-y-1">
|
||||
<div>
|
||||
<span className="text-stone-500">Host:</span>{' '}
|
||||
<span className="font-mono text-stone-300">
|
||||
{dbStatus?.host || 'Built-in Local JSON DB'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-stone-500">Database:</span>{' '}
|
||||
<span className="font-mono text-stone-300">
|
||||
{dbStatus?.database || 'pinpoint_db.json'}
|
||||
</span>
|
||||
</div>
|
||||
{dbStatus?.port && (
|
||||
<div>
|
||||
<span className="text-stone-500">Port:</span>{' '}
|
||||
<span className="font-mono text-stone-300">{dbStatus.port}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Galera Cluster Setup Instructions */}
|
||||
<div className="bg-stone-850 border border-stone-800 rounded-2xl p-4">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-emerald-400 mb-1.5">
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
<span>MariaDB Galera Cluster Connection</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400 leading-relaxed mb-3">
|
||||
To connect your MariaDB Galera cluster, configure these environment variables:
|
||||
</p>
|
||||
<div className="bg-stone-900 rounded-xl p-2.5 font-mono text-[11px] text-stone-300 border border-stone-800 space-y-0.5">
|
||||
<div>MARIADB_HOST="192.168.1.100"</div>
|
||||
<div>MARIADB_PORT="3306"</div>
|
||||
<div>MARIADB_USER="pinpoint_user"</div>
|
||||
<div>MARIADB_PASSWORD="your_password"</div>
|
||||
<div>MARIADB_DATABASE="pinpoint_golf"</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Achievement Recalculation Rules Recap */}
|
||||
<div className="mt-4 pt-4 border-t border-stone-800 flex items-start gap-3 text-xs text-stone-400">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="leading-relaxed">
|
||||
<span className="text-white font-semibold">Integrity Guarantees: </span>
|
||||
Deleting a saved round automatically recalculates your career achievements from all remaining rounds, revoking trophies earned solely from that deleted round. Live in-round milestones (like a Hole-in-One) are celebrated provisionally and locked in only upon final save confirmation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,7 @@ export function generateToken(user: User): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
export async function authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authentication token missing or invalid' });
|
||||
@@ -30,7 +30,7 @@ export function authMiddleware(req: AuthenticatedRequest, res: Response, next: N
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
|
||||
const user = db.findUserById(decoded.id);
|
||||
const user = await db.findUserById(decoded.id);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User no longer exists' });
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function authMiddleware(req: AuthenticatedRequest, res: Response, next: N
|
||||
}
|
||||
|
||||
// Optional auth: attaches user if token is valid, but doesn't block if not
|
||||
export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
export async function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return next();
|
||||
@@ -51,7 +51,7 @@ export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response,
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
|
||||
const user = db.findUserById(decoded.id);
|
||||
const user = await db.findUserById(decoded.id);
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
@@ -60,3 +60,4 @@ export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response,
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
+127
-19
@@ -4,12 +4,14 @@ import bcrypt from 'bcryptjs';
|
||||
import { User, PublicGolferStats } from '../types/auth';
|
||||
import { Round, TrackingSettings, Course } from '../types/golf';
|
||||
import { DEFAULT_TRACKING_SETTINGS } from '../data/courses';
|
||||
import { mariadb } from './mariadb';
|
||||
|
||||
interface DatabaseSchema {
|
||||
users: Array<User & { passwordHash?: string }>;
|
||||
settings: Record<string, TrackingSettings>; // userId -> settings
|
||||
rounds: Record<string, Round[]>; // userId -> rounds
|
||||
customCourses: Record<string, Course[]>; // userId -> courses
|
||||
achievements: Record<string, Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>>;
|
||||
publicSeedGolfers: PublicGolferStats[];
|
||||
}
|
||||
|
||||
@@ -21,7 +23,7 @@ if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Initial seed public golfers for rich community stats & benchmarking
|
||||
// Initial seed public golfers (used only when mock data is explicitly enabled in dev)
|
||||
const SEED_PUBLIC_GOLFERS: PublicGolferStats[] = [
|
||||
{
|
||||
id: 'user_seed_viktor',
|
||||
@@ -123,21 +125,42 @@ class Database {
|
||||
this.seedDefaultUserIfEmpty();
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const connected = await mariadb.init();
|
||||
if (connected) {
|
||||
console.log('🚀 MariaDB Galera cluster online and selected as primary storage.');
|
||||
} else {
|
||||
console.log('📦 Using local persistent file storage for PinPoint Golf.');
|
||||
}
|
||||
}
|
||||
|
||||
private load(): DatabaseSchema {
|
||||
try {
|
||||
if (fs.existsSync(DB_FILE)) {
|
||||
const raw = fs.readFileSync(DB_FILE, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
users: parsed.users || [],
|
||||
settings: parsed.settings || {},
|
||||
rounds: parsed.rounds || {},
|
||||
customCourses: parsed.customCourses || {},
|
||||
achievements: parsed.achievements || {},
|
||||
publicSeedGolfers: parsed.publicSeedGolfers || [],
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load database file, creating fresh store:', e);
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const enableMock = process.env.ENABLE_MOCK_DATA === 'true';
|
||||
|
||||
return {
|
||||
users: [],
|
||||
settings: {},
|
||||
rounds: {},
|
||||
customCourses: {},
|
||||
publicSeedGolfers: SEED_PUBLIC_GOLFERS,
|
||||
achievements: {},
|
||||
publicSeedGolfers: !isProduction && enableMock ? SEED_PUBLIC_GOLFERS : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +175,25 @@ class Database {
|
||||
}
|
||||
|
||||
private seedDefaultUserIfEmpty() {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const enableMock = process.env.ENABLE_MOCK_DATA === 'true';
|
||||
|
||||
// Mock data must not be present when deployed or in production
|
||||
if (isProduction || !enableMock) {
|
||||
if (this.data.publicSeedGolfers && this.data.publicSeedGolfers.some(g => g.id.startsWith('user_seed_'))) {
|
||||
this.data.publicSeedGolfers = this.data.publicSeedGolfers.filter(g => !g.id.startsWith('user_seed_'));
|
||||
this.save();
|
||||
}
|
||||
if (this.data.users && this.data.users.some(u => u.id === 'usr_demo_golfer')) {
|
||||
this.data.users = this.data.users.filter(u => u.id !== 'usr_demo_golfer');
|
||||
delete this.data.settings['usr_demo_golfer'];
|
||||
delete this.data.rounds['usr_demo_golfer'];
|
||||
delete this.data.achievements['usr_demo_golfer'];
|
||||
this.save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.data.users || this.data.users.length === 0) {
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const passwordHash = bcrypt.hashSync('golf1234', salt);
|
||||
@@ -181,25 +223,37 @@ class Database {
|
||||
}
|
||||
|
||||
// --- User Operations ---
|
||||
public getUsers(): Array<User & { passwordHash?: string }> {
|
||||
public async getUsers(): Promise<Array<User & { passwordHash?: string }>> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.getUsers();
|
||||
}
|
||||
return this.data.users;
|
||||
}
|
||||
|
||||
public findUserById(id: string): User | undefined {
|
||||
public async findUserById(id: string): Promise<User | undefined> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.findUserById(id);
|
||||
}
|
||||
const user = this.data.users.find((u) => u.id === id);
|
||||
if (!user) return undefined;
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
public findUserByEmailOrUsername(identifier: string): (User & { passwordHash?: string }) | undefined {
|
||||
public async findUserByEmailOrUsername(identifier: string): Promise<(User & { passwordHash?: string }) | undefined> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.findUserByEmailOrUsername(identifier);
|
||||
}
|
||||
const lower = identifier.toLowerCase().trim();
|
||||
return this.data.users.find(
|
||||
(u) => u.email.toLowerCase() === lower || u.username.toLowerCase() === lower
|
||||
);
|
||||
}
|
||||
|
||||
public createUser(userData: User & { passwordHash?: string }): User {
|
||||
public async createUser(userData: User & { passwordHash?: string }): Promise<User> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.createUser(userData);
|
||||
}
|
||||
this.data.users.push(userData);
|
||||
if (!this.data.settings[userData.id]) {
|
||||
this.data.settings[userData.id] = { ...DEFAULT_TRACKING_SETTINGS };
|
||||
@@ -207,12 +261,18 @@ class Database {
|
||||
if (!this.data.rounds[userData.id]) {
|
||||
this.data.rounds[userData.id] = [];
|
||||
}
|
||||
if (!this.data.achievements[userData.id]) {
|
||||
this.data.achievements[userData.id] = [];
|
||||
}
|
||||
this.save();
|
||||
const { passwordHash: _, ...safeUser } = userData;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
public updateUser(id: string, updates: Partial<User>): User | undefined {
|
||||
public async updateUser(id: string, updates: Partial<User>): Promise<User | undefined> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.updateUser(id, updates);
|
||||
}
|
||||
const index = this.data.users.findIndex((u) => u.id === id);
|
||||
if (index === -1) return undefined;
|
||||
|
||||
@@ -228,22 +288,34 @@ class Database {
|
||||
}
|
||||
|
||||
// --- Settings Operations ---
|
||||
public getSettings(userId: string): TrackingSettings {
|
||||
public async getSettings(userId: string): Promise<TrackingSettings> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.getSettings(userId);
|
||||
}
|
||||
return this.data.settings[userId] || { ...DEFAULT_TRACKING_SETTINGS };
|
||||
}
|
||||
|
||||
public updateSettings(userId: string, settings: TrackingSettings): TrackingSettings {
|
||||
public async updateSettings(userId: string, settings: TrackingSettings): Promise<TrackingSettings> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.updateSettings(userId, settings);
|
||||
}
|
||||
this.data.settings[userId] = settings;
|
||||
this.save();
|
||||
return settings;
|
||||
}
|
||||
|
||||
// --- Rounds Operations ---
|
||||
public getRounds(userId: string): Round[] {
|
||||
public async getRounds(userId: string): Promise<Round[]> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.getRounds(userId);
|
||||
}
|
||||
return this.data.rounds[userId] || [];
|
||||
}
|
||||
|
||||
public saveRound(userId: string, round: Round): Round {
|
||||
public async saveRound(userId: string, round: Round): Promise<Round> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.saveRound(userId, round);
|
||||
}
|
||||
if (!this.data.rounds[userId]) {
|
||||
this.data.rounds[userId] = [];
|
||||
}
|
||||
@@ -257,7 +329,13 @@ class Database {
|
||||
return round;
|
||||
}
|
||||
|
||||
public syncRoundsBatch(userId: string, rounds: Round[]): Round[] {
|
||||
public async syncRoundsBatch(userId: string, rounds: Round[]): Promise<Round[]> {
|
||||
if (mariadb.isAvailable()) {
|
||||
for (const r of rounds) {
|
||||
await mariadb.saveRound(userId, r);
|
||||
}
|
||||
return await mariadb.getRounds(userId);
|
||||
}
|
||||
if (!this.data.rounds[userId]) {
|
||||
this.data.rounds[userId] = [];
|
||||
}
|
||||
@@ -273,7 +351,10 @@ class Database {
|
||||
return this.data.rounds[userId];
|
||||
}
|
||||
|
||||
public deleteRound(userId: string, roundId: string): boolean {
|
||||
public async deleteRound(userId: string, roundId: string): Promise<boolean> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.deleteRound(userId, roundId);
|
||||
}
|
||||
if (!this.data.rounds[userId]) return false;
|
||||
const initialLen = this.data.rounds[userId].length;
|
||||
this.data.rounds[userId] = this.data.rounds[userId].filter((r) => r.id !== roundId);
|
||||
@@ -282,12 +363,38 @@ class Database {
|
||||
return changed;
|
||||
}
|
||||
|
||||
// --- Achievements Operations ---
|
||||
public async getUserAchievements(userId: string): Promise<Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.getUserAchievements(userId);
|
||||
}
|
||||
return this.data.achievements[userId] || [];
|
||||
}
|
||||
|
||||
public async syncUserAchievements(
|
||||
userId: string,
|
||||
achievements: Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>
|
||||
): Promise<void> {
|
||||
if (mariadb.isAvailable()) {
|
||||
await mariadb.syncUserAchievements(userId, achievements);
|
||||
return;
|
||||
}
|
||||
this.data.achievements[userId] = achievements;
|
||||
this.save();
|
||||
}
|
||||
|
||||
// --- Custom Courses Operations ---
|
||||
public getCustomCourses(userId: string): Course[] {
|
||||
public async getCustomCourses(userId: string): Promise<Course[]> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.getCustomCourses(userId);
|
||||
}
|
||||
return this.data.customCourses[userId] || [];
|
||||
}
|
||||
|
||||
public saveCustomCourse(userId: string, course: Course): Course {
|
||||
public async saveCustomCourse(userId: string, course: Course): Promise<Course> {
|
||||
if (mariadb.isAvailable()) {
|
||||
return await mariadb.saveCustomCourse(userId, course);
|
||||
}
|
||||
if (!this.data.customCourses[userId]) {
|
||||
this.data.customCourses[userId] = [];
|
||||
}
|
||||
@@ -302,13 +409,14 @@ class Database {
|
||||
}
|
||||
|
||||
// --- Community & Public Stats Operations ---
|
||||
public getPublicGolfers(): PublicGolferStats[] {
|
||||
public async getPublicGolfers(): Promise<PublicGolferStats[]> {
|
||||
const allUsers = await this.getUsers();
|
||||
const list: PublicGolferStats[] = [...(this.data.publicSeedGolfers || [])];
|
||||
|
||||
// Compute public stats for any real registered users with isPublic == true
|
||||
for (const user of this.data.users) {
|
||||
for (const user of allUsers) {
|
||||
if (!user.isPublic) continue;
|
||||
const userRounds = this.data.rounds[user.id] || [];
|
||||
const userRounds = await this.getRounds(user.id);
|
||||
if (userRounds.length === 0) continue;
|
||||
|
||||
let totalGross = 0;
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import mysql, { Pool } from 'mysql2/promise';
|
||||
import { User, PublicGolferStats } from '../types/auth';
|
||||
import { Round, TrackingSettings, Course } from '../types/golf';
|
||||
import { DEFAULT_TRACKING_SETTINGS } from '../data/courses';
|
||||
|
||||
export interface MariaDbConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password?: string;
|
||||
database: string;
|
||||
ssl?: boolean;
|
||||
connectionLimit?: number;
|
||||
}
|
||||
|
||||
class MariaDbService {
|
||||
private pool: Pool | null = null;
|
||||
private connected: boolean = false;
|
||||
private initializing: boolean = false;
|
||||
private lastError: string | null = null;
|
||||
|
||||
public getConfig(): MariaDbConfig | null {
|
||||
const host = process.env.MARIADB_HOST;
|
||||
if (!host) return null;
|
||||
|
||||
return {
|
||||
host: host.trim(),
|
||||
port: Number(process.env.MARIADB_PORT) || 3306,
|
||||
user: process.env.MARIADB_USER || 'root',
|
||||
password: process.env.MARIADB_PASSWORD || '',
|
||||
database: process.env.MARIADB_DATABASE || 'pinpoint_golf',
|
||||
ssl: process.env.MARIADB_SSL === 'true',
|
||||
connectionLimit: Number(process.env.MARIADB_CONNECTION_LIMIT) || 10,
|
||||
};
|
||||
}
|
||||
|
||||
public isAvailable(): boolean {
|
||||
return this.connected && this.pool !== null;
|
||||
}
|
||||
|
||||
public getStatus() {
|
||||
const config = this.getConfig();
|
||||
return {
|
||||
configured: config !== null,
|
||||
connected: this.connected,
|
||||
host: config?.host || null,
|
||||
database: config?.database || null,
|
||||
port: config?.port || 3306,
|
||||
lastError: this.lastError,
|
||||
engine: this.connected ? 'MariaDB Galera Cluster' : 'Local Persistent Storage',
|
||||
};
|
||||
}
|
||||
|
||||
public async init(): Promise<boolean> {
|
||||
const config = this.getConfig();
|
||||
if (!config) {
|
||||
console.log('ℹ️ MariaDB: No MARIADB_HOST provided. Using local persistent storage.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.initializing) return this.connected;
|
||||
this.initializing = true;
|
||||
|
||||
try {
|
||||
console.log(`🔌 MariaDB: Connecting to Galera cluster at ${config.host}:${config.port}/${config.database}...`);
|
||||
|
||||
this.pool = mysql.createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
ssl: config.ssl ? { rejectUnauthorized: false } : undefined,
|
||||
waitForConnections: true,
|
||||
connectionLimit: config.connectionLimit || 10,
|
||||
queueLimit: 0,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 10000,
|
||||
});
|
||||
|
||||
// Test connection
|
||||
const connection = await this.pool.getConnection();
|
||||
console.log('✅ MariaDB: Connection established successfully.');
|
||||
connection.release();
|
||||
|
||||
// Run schema migrations for Galera cluster compatibility (InnoDB with PKs)
|
||||
await this.runMigrations();
|
||||
this.connected = true;
|
||||
this.lastError = null;
|
||||
this.initializing = false;
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
this.connected = false;
|
||||
this.lastError = err.message || String(err);
|
||||
this.initializing = false;
|
||||
console.warn(`⚠️ MariaDB connection failed: ${this.lastError}. Falling back to local storage.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runMigrations(): Promise<void> {
|
||||
if (!this.pool) return;
|
||||
|
||||
// Users table
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
avatar_url TEXT,
|
||||
handicap_index DECIMAL(4,1) DEFAULT 18.0,
|
||||
home_club VARCHAR(128) DEFAULT '',
|
||||
is_public TINYINT(1) DEFAULT 1,
|
||||
provider VARCHAR(32) DEFAULT 'local',
|
||||
password_hash VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Settings table
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id VARCHAR(64) PRIMARY KEY,
|
||||
settings_json LONGTEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Rounds table
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS rounds (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
course_id VARCHAR(64) NOT NULL,
|
||||
course_name VARCHAR(128) NOT NULL,
|
||||
tee_id VARCHAR(64),
|
||||
round_date VARCHAR(32) NOT NULL,
|
||||
holes_played INT NOT NULL DEFAULT 18,
|
||||
is_completed TINYINT(1) NOT NULL DEFAULT 1,
|
||||
completed_at VARCHAR(64),
|
||||
round_data_json LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_rounds (user_id, round_date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// User Achievements table
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
achievement_id VARCHAR(64) NOT NULL,
|
||||
unlocked_at VARCHAR(64),
|
||||
unlocked_round_name VARCHAR(128),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, achievement_id),
|
||||
INDEX idx_user_achievements (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
// Custom courses table
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS custom_courses (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
course_data_json LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_courses (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
console.log('✅ MariaDB schema tables verified and ready.');
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
public async getUsers(): Promise<Array<User & { passwordHash?: string }>> {
|
||||
if (!this.pool) return [];
|
||||
const [rows]: any = await this.pool.query(`SELECT * FROM users`);
|
||||
return rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
username: r.username,
|
||||
name: r.name,
|
||||
avatarUrl: r.avatar_url || undefined,
|
||||
handicapIndex: Number(r.handicap_index),
|
||||
homeClub: r.home_club || '',
|
||||
isPublic: Boolean(r.is_public),
|
||||
provider: r.provider || 'local',
|
||||
createdAt: r.created_at ? new Date(r.created_at).toISOString() : new Date().toISOString(),
|
||||
updatedAt: r.updated_at ? new Date(r.updated_at).toISOString() : new Date().toISOString(),
|
||||
passwordHash: r.password_hash || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
public async findUserById(id: string): Promise<User | undefined> {
|
||||
if (!this.pool) return undefined;
|
||||
const [rows]: any = await this.pool.query(`SELECT * FROM users WHERE id = ?`, [id]);
|
||||
if (!rows || rows.length === 0) return undefined;
|
||||
const r = rows[0];
|
||||
return {
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
username: r.username,
|
||||
name: r.name,
|
||||
avatarUrl: r.avatar_url || undefined,
|
||||
handicapIndex: Number(r.handicap_index),
|
||||
homeClub: r.home_club || '',
|
||||
isPublic: Boolean(r.is_public),
|
||||
provider: r.provider || 'local',
|
||||
createdAt: r.created_at ? new Date(r.created_at).toISOString() : new Date().toISOString(),
|
||||
updatedAt: r.updated_at ? new Date(r.updated_at).toISOString() : new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
public async findUserByEmailOrUsername(identifier: string): Promise<(User & { passwordHash?: string }) | undefined> {
|
||||
if (!this.pool) return undefined;
|
||||
const lower = identifier.toLowerCase().trim();
|
||||
const [rows]: any = await this.pool.query(
|
||||
`SELECT * FROM users WHERE LOWER(email) = ? OR LOWER(username) = ? LIMIT 1`,
|
||||
[lower, lower]
|
||||
);
|
||||
if (!rows || rows.length === 0) return undefined;
|
||||
const r = rows[0];
|
||||
return {
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
username: r.username,
|
||||
name: r.name,
|
||||
avatarUrl: r.avatar_url || undefined,
|
||||
handicapIndex: Number(r.handicap_index),
|
||||
homeClub: r.home_club || '',
|
||||
isPublic: Boolean(r.is_public),
|
||||
provider: r.provider || 'local',
|
||||
createdAt: r.created_at ? new Date(r.created_at).toISOString() : new Date().toISOString(),
|
||||
updatedAt: r.updated_at ? new Date(r.updated_at).toISOString() : new Date().toISOString(),
|
||||
passwordHash: r.password_hash || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public async createUser(user: User & { passwordHash?: string }): Promise<User> {
|
||||
if (!this.pool) throw new Error('MariaDB not connected');
|
||||
await this.pool.query(
|
||||
`INSERT INTO users (id, email, username, name, avatar_url, handicap_index, home_club, is_public, provider, password_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
user.id,
|
||||
user.email,
|
||||
user.username,
|
||||
user.name,
|
||||
user.avatarUrl || null,
|
||||
user.handicapIndex || 18.0,
|
||||
user.homeClub || '',
|
||||
user.isPublic ? 1 : 0,
|
||||
user.provider || 'local',
|
||||
user.passwordHash || null,
|
||||
]
|
||||
);
|
||||
|
||||
// Seed default settings
|
||||
await this.pool.query(
|
||||
`INSERT INTO user_settings (user_id, settings_json) VALUES (?, ?) ON DUPLICATE KEY UPDATE settings_json = ?`,
|
||||
[user.id, JSON.stringify(DEFAULT_TRACKING_SETTINGS), JSON.stringify(DEFAULT_TRACKING_SETTINGS)]
|
||||
);
|
||||
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
public async updateUser(id: string, updates: Partial<User>): Promise<User | undefined> {
|
||||
if (!this.pool) return undefined;
|
||||
const existing = await this.findUserById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const updated = { ...existing, ...updates };
|
||||
await this.pool.query(
|
||||
`UPDATE users SET name = ?, handicap_index = ?, home_club = ?, is_public = ?, avatar_url = ? WHERE id = ?`,
|
||||
[
|
||||
updated.name,
|
||||
updated.handicapIndex,
|
||||
updated.homeClub,
|
||||
updated.isPublic ? 1 : 0,
|
||||
updated.avatarUrl || null,
|
||||
id,
|
||||
]
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
public async getSettings(userId: string): Promise<TrackingSettings> {
|
||||
if (!this.pool) return { ...DEFAULT_TRACKING_SETTINGS };
|
||||
const [rows]: any = await this.pool.query(`SELECT settings_json FROM user_settings WHERE user_id = ?`, [userId]);
|
||||
if (!rows || rows.length === 0) return { ...DEFAULT_TRACKING_SETTINGS };
|
||||
try {
|
||||
return JSON.parse(rows[0].settings_json);
|
||||
} catch {
|
||||
return { ...DEFAULT_TRACKING_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
public async updateSettings(userId: string, settings: TrackingSettings): Promise<TrackingSettings> {
|
||||
if (!this.pool) return settings;
|
||||
const jsonStr = JSON.stringify(settings);
|
||||
await this.pool.query(
|
||||
`INSERT INTO user_settings (user_id, settings_json) VALUES (?, ?) ON DUPLICATE KEY UPDATE settings_json = ?`,
|
||||
[userId, jsonStr, jsonStr]
|
||||
);
|
||||
return settings;
|
||||
}
|
||||
|
||||
// --- Rounds ---
|
||||
public async getRounds(userId: string): Promise<Round[]> {
|
||||
if (!this.pool) return [];
|
||||
const [rows]: any = await this.pool.query(
|
||||
`SELECT round_data_json FROM rounds WHERE user_id = ? ORDER BY round_date DESC, created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return rows.map((r: any) => {
|
||||
try {
|
||||
return JSON.parse(r.round_data_json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
public async saveRound(userId: string, round: Round): Promise<Round> {
|
||||
if (!this.pool) throw new Error('MariaDB not connected');
|
||||
const jsonStr = JSON.stringify(round);
|
||||
await this.pool.query(
|
||||
`INSERT INTO rounds (id, user_id, course_id, course_name, tee_id, round_date, holes_played, is_completed, completed_at, round_data_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
course_name = VALUES(course_name),
|
||||
tee_id = VALUES(tee_id),
|
||||
round_date = VALUES(round_date),
|
||||
holes_played = VALUES(holes_played),
|
||||
is_completed = VALUES(is_completed),
|
||||
completed_at = VALUES(completed_at),
|
||||
round_data_json = VALUES(round_data_json)`,
|
||||
[
|
||||
round.id,
|
||||
userId,
|
||||
round.courseId,
|
||||
round.courseName,
|
||||
round.teeId || null,
|
||||
round.date,
|
||||
round.holesPlayed || 18,
|
||||
round.isCompleted ? 1 : 0,
|
||||
round.completedAt || new Date().toISOString(),
|
||||
jsonStr,
|
||||
]
|
||||
);
|
||||
return round;
|
||||
}
|
||||
|
||||
public async deleteRound(userId: string, roundId: string): Promise<boolean> {
|
||||
if (!this.pool) return false;
|
||||
const [result]: any = await this.pool.query(
|
||||
`DELETE FROM rounds WHERE id = ? AND user_id = ?`,
|
||||
[roundId, userId]
|
||||
);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
// --- Achievements ---
|
||||
public async getUserAchievements(userId: string): Promise<Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>> {
|
||||
if (!this.pool) return [];
|
||||
const [rows]: any = await this.pool.query(
|
||||
`SELECT achievement_id, unlocked_at, unlocked_round_name FROM user_achievements WHERE user_id = ?`,
|
||||
[userId]
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
id: r.achievement_id,
|
||||
unlockedAt: r.unlocked_at || undefined,
|
||||
unlockedRoundName: r.unlocked_round_name || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
public async syncUserAchievements(
|
||||
userId: string,
|
||||
achievements: Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>
|
||||
): Promise<void> {
|
||||
if (!this.pool) return;
|
||||
const connection = await this.pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
// Replace achievements with current active set
|
||||
await connection.query(`DELETE FROM user_achievements WHERE user_id = ?`, [userId]);
|
||||
for (const ach of achievements) {
|
||||
await connection.query(
|
||||
`INSERT INTO user_achievements (user_id, achievement_id, unlocked_at, unlocked_round_name) VALUES (?, ?, ?, ?)`,
|
||||
[userId, ach.id, ach.unlockedAt || null, ach.unlockedRoundName || null]
|
||||
);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (e) {
|
||||
await connection.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom Courses ---
|
||||
public async getCustomCourses(userId: string): Promise<Course[]> {
|
||||
if (!this.pool) return [];
|
||||
const [rows]: any = await this.pool.query(
|
||||
`SELECT course_data_json FROM custom_courses WHERE user_id = ?`,
|
||||
[userId]
|
||||
);
|
||||
return rows.map((r: any) => {
|
||||
try {
|
||||
return JSON.parse(r.course_data_json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
public async saveCustomCourse(userId: string, course: Course): Promise<Course> {
|
||||
if (!this.pool) return course;
|
||||
const jsonStr = JSON.stringify(course);
|
||||
await this.pool.query(
|
||||
`INSERT INTO custom_courses (id, user_id, course_data_json) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE course_data_json = VALUES(course_data_json)`,
|
||||
[course.id, userId, jsonStr]
|
||||
);
|
||||
return course;
|
||||
}
|
||||
}
|
||||
|
||||
export const mariadb = new MariaDbService();
|
||||
@@ -7,7 +7,7 @@ import { User, AuthProvider } from '../../types/auth';
|
||||
const router = Router();
|
||||
|
||||
// --- 1. Local Registration ---
|
||||
router.post('/register', (req, res: Response) => {
|
||||
router.post('/register', async (req, res: Response) => {
|
||||
try {
|
||||
const { email, username, password, name, handicapIndex, homeClub, isPublic } = req.body;
|
||||
|
||||
@@ -19,7 +19,7 @@ router.post('/register', (req, res: Response) => {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const existingUser = db.findUserByEmailOrUsername(email) || db.findUserByEmailOrUsername(username);
|
||||
const existingUser = (await db.findUserByEmailOrUsername(email)) || (await db.findUserByEmailOrUsername(username));
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'An account with that email or username already exists' });
|
||||
}
|
||||
@@ -41,13 +41,14 @@ router.post('/register', (req, res: Response) => {
|
||||
passwordHash,
|
||||
};
|
||||
|
||||
const savedUser = db.createUser(newUser);
|
||||
const savedUser = await db.createUser(newUser);
|
||||
const token = generateToken(savedUser);
|
||||
const settings = await db.getSettings(savedUser.id);
|
||||
|
||||
return res.status(201).json({
|
||||
token,
|
||||
user: savedUser,
|
||||
settings: db.getSettings(savedUser.id),
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
@@ -56,7 +57,7 @@ router.post('/register', (req, res: Response) => {
|
||||
});
|
||||
|
||||
// --- 2. Local Login ---
|
||||
router.post('/login', (req, res: Response) => {
|
||||
router.post('/login', async (req, res: Response) => {
|
||||
try {
|
||||
const { identifier, password } = req.body;
|
||||
|
||||
@@ -64,7 +65,7 @@ router.post('/login', (req, res: Response) => {
|
||||
return res.status(400).json({ error: 'Email/Username and password are required' });
|
||||
}
|
||||
|
||||
const userWithHash = db.findUserByEmailOrUsername(identifier);
|
||||
const userWithHash = await db.findUserByEmailOrUsername(identifier);
|
||||
if (!userWithHash || !userWithHash.passwordHash) {
|
||||
return res.status(401).json({ error: 'Invalid credentials. Please verify your email and password.' });
|
||||
}
|
||||
@@ -76,11 +77,12 @@ router.post('/login', (req, res: Response) => {
|
||||
|
||||
const { passwordHash: _, ...safeUser } = userWithHash;
|
||||
const token = generateToken(safeUser);
|
||||
const settings = await db.getSettings(safeUser.id);
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
user: safeUser,
|
||||
settings: db.getSettings(safeUser.id),
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
@@ -89,9 +91,9 @@ router.post('/login', (req, res: Response) => {
|
||||
});
|
||||
|
||||
// --- 3. Get Current User & Profile ---
|
||||
router.get('/me', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.get('/me', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const settings = db.getSettings(req.user.id);
|
||||
const settings = await db.getSettings(req.user.id);
|
||||
return res.json({
|
||||
user: req.user,
|
||||
settings,
|
||||
@@ -99,7 +101,7 @@ router.get('/me', authMiddleware, (req: AuthenticatedRequest, res: Response) =>
|
||||
});
|
||||
|
||||
// --- 4. Update Profile & Privacy (Public / Private toggle) ---
|
||||
router.put('/profile', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.put('/profile', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
|
||||
const { name, handicapIndex, homeClub, isPublic, avatarUrl } = req.body;
|
||||
@@ -111,19 +113,20 @@ router.put('/profile', authMiddleware, (req: AuthenticatedRequest, res: Response
|
||||
if (typeof isPublic === 'boolean') updates.isPublic = isPublic;
|
||||
if (typeof avatarUrl === 'string') updates.avatarUrl = avatarUrl;
|
||||
|
||||
const updatedUser = db.updateUser(req.user.id, updates);
|
||||
const updatedUser = await db.updateUser(req.user.id, updates);
|
||||
return res.json({ user: updatedUser });
|
||||
});
|
||||
|
||||
// --- 5. User Settings Sync ---
|
||||
router.get('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.get('/settings', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
return res.json(db.getSettings(req.user.id));
|
||||
const settings = await db.getSettings(req.user.id);
|
||||
return res.json(settings);
|
||||
});
|
||||
|
||||
router.put('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.put('/settings', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const updated = db.updateSettings(req.user.id, req.body);
|
||||
const updated = await db.updateSettings(req.user.id, req.body);
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
@@ -158,22 +161,23 @@ router.get('/oauth/providers', (_req, res: Response) => {
|
||||
});
|
||||
|
||||
// --- 7. OAuth / SSO Login (Google, Authentik, GitHub, Demo SSO) ---
|
||||
router.post('/oauth/sso-login', (req, res: Response) => {
|
||||
router.post('/oauth/sso-login', async (req, res: Response) => {
|
||||
try {
|
||||
const { provider, email, name, username, avatarUrl, authentikServerUrl } = req.body;
|
||||
|
||||
if (!provider) {
|
||||
return res.status(400).json({ error: 'Provider is required' });
|
||||
return res.status(400).json({ error: 'OAuth provider is required' });
|
||||
}
|
||||
|
||||
const providerType = provider as AuthProvider;
|
||||
|
||||
// Generate reliable email & username if from SSO
|
||||
const ssoEmail = email?.toLowerCase().trim() || `${provider}_golfer_${Date.now().toString(36)}@example.com`;
|
||||
const ssoUsername = username?.toLowerCase().trim() || `${provider}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const ssoName = name?.trim() || `${provider.charAt(0).toUpperCase() + provider.slice(1)} Golfer`;
|
||||
|
||||
// Check if user already exists
|
||||
let user = db.findUserByEmailOrUsername(ssoEmail);
|
||||
let user = await db.findUserByEmailOrUsername(ssoEmail);
|
||||
|
||||
if (!user) {
|
||||
// Create new user connected via SSO
|
||||
@@ -192,16 +196,17 @@ router.post('/oauth/sso-login', (req, res: Response) => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
user = db.createUser(newUser);
|
||||
user = await db.createUser(newUser);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
const token = generateToken(safeUser);
|
||||
const settings = await db.getSettings(safeUser.id);
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
user: safeUser,
|
||||
settings: db.getSettings(safeUser.id),
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SSO Login error:', error);
|
||||
|
||||
@@ -6,9 +6,9 @@ import { PublicGolferStats, ComparisonMetric, CommunityComparisonData } from '..
|
||||
const router = Router();
|
||||
|
||||
// --- 1. Get Public Golfers Leaderboard ---
|
||||
router.get('/leaderboard', (_req, res: Response) => {
|
||||
router.get('/leaderboard', async (_req, res: Response) => {
|
||||
try {
|
||||
const golfers = db.getPublicGolfers();
|
||||
const golfers = await db.getPublicGolfers();
|
||||
|
||||
// Sort by scoring average ascending (lowest score first)
|
||||
golfers.sort((a, b) => a.scoringAverage - b.scoringAverage);
|
||||
@@ -24,9 +24,9 @@ router.get('/leaderboard', (_req, res: Response) => {
|
||||
});
|
||||
|
||||
// --- 2. Head-to-Head Community Benchmark Comparison ---
|
||||
router.get('/compare', optionalAuthMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.get('/compare', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const publicGolfers = db.getPublicGolfers();
|
||||
const publicGolfers = await db.getPublicGolfers();
|
||||
|
||||
if (publicGolfers.length === 0) {
|
||||
return res.status(404).json({ error: 'No public statistics available' });
|
||||
|
||||
@@ -2,18 +2,26 @@ import { Router, Response } from 'express';
|
||||
import { db } from '../db';
|
||||
import { authMiddleware, AuthenticatedRequest } from '../authMiddleware';
|
||||
import { Round, Course } from '../../types/golf';
|
||||
import { DEFAULT_COURSES } from '../../data/courses';
|
||||
import { evaluateAchievements } from '../../utils/achievements';
|
||||
import { mariadb } from '../mariadb';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Database Status Endpoint ---
|
||||
router.get('/db-status', (_req, res: Response) => {
|
||||
res.json(mariadb.getStatus());
|
||||
});
|
||||
|
||||
// --- Get All Rounds for User ---
|
||||
router.get('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const rounds = db.getRounds(req.user.id);
|
||||
const rounds = await db.getRounds(req.user.id);
|
||||
return res.json(rounds);
|
||||
});
|
||||
|
||||
// --- Save / Update Single Round ---
|
||||
router.post('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
// --- Save / Finalize Round ---
|
||||
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const round: Round = req.body;
|
||||
|
||||
@@ -21,12 +29,99 @@ router.post('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
return res.status(400).json({ error: 'Invalid round payload' });
|
||||
}
|
||||
|
||||
const saved = db.saveRound(req.user.id, round);
|
||||
return res.json(saved);
|
||||
// Ensure isCompleted is explicitly set when finalizing
|
||||
const roundToSave: Round = {
|
||||
...round,
|
||||
isCompleted: true,
|
||||
completedAt: round.completedAt || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const saved = await db.saveRound(req.user.id, roundToSave);
|
||||
|
||||
// Re-evaluate and persist achievements for this user across all completed rounds
|
||||
const customCourses = await db.getCustomCourses(req.user.id);
|
||||
const allCourses = [...DEFAULT_COURSES, ...customCourses];
|
||||
const allUserRounds = await db.getRounds(req.user.id);
|
||||
|
||||
const prevAchievements = await db.getUserAchievements(req.user.id);
|
||||
const prevIds = prevAchievements.map((a) => a.id);
|
||||
|
||||
const evaluated = evaluateAchievements(allUserRounds, allCourses, prevIds);
|
||||
const unlockedRecords = evaluated.unlockedList
|
||||
.filter((a) => a.isUnlocked)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
unlockedAt: a.unlockedAt || new Date().toISOString(),
|
||||
unlockedRoundName: a.unlockedRoundName || roundToSave.courseName,
|
||||
}));
|
||||
|
||||
await db.syncUserAchievements(req.user.id, unlockedRecords);
|
||||
|
||||
return res.json({
|
||||
round: saved,
|
||||
achievements: evaluated.unlockedList,
|
||||
newlyUnlocked: evaluated.newlyUnlocked,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Delete Round & Dynamic Achievement Recalculation ---
|
||||
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const roundId = req.params.id;
|
||||
|
||||
const success = await db.deleteRound(req.user.id, roundId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Round not found or already deleted' });
|
||||
}
|
||||
|
||||
// User confirmed requirement:
|
||||
// "Recalculate achievements from remaining saved rounds"
|
||||
const customCourses = await db.getCustomCourses(req.user.id);
|
||||
const allCourses = [...DEFAULT_COURSES, ...customCourses];
|
||||
const remainingRounds = await db.getRounds(req.user.id);
|
||||
|
||||
// Recalculate completely from remaining rounds (with empty previous set to detect true qualification)
|
||||
const evaluated = evaluateAchievements(remainingRounds, allCourses, []);
|
||||
|
||||
// Sync remaining valid achievements
|
||||
const validUnlocked = evaluated.unlockedList
|
||||
.filter((a) => a.isUnlocked)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
unlockedAt: a.unlockedAt || new Date().toISOString(),
|
||||
unlockedRoundName: a.unlockedRoundName || 'Saved Round',
|
||||
}));
|
||||
|
||||
await db.syncUserAchievements(req.user.id, validUnlocked);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
remainingRoundsCount: remainingRounds.length,
|
||||
achievements: evaluated.unlockedList,
|
||||
recalculatedCount: validUnlocked.length,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Get User's Saved Achievements ---
|
||||
router.get('/achievements', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const records = await db.getUserAchievements(req.user.id);
|
||||
return res.json(records);
|
||||
});
|
||||
|
||||
// --- Sync Achievements (Client-to-Server) ---
|
||||
router.post('/achievements/sync', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const { achievements } = req.body;
|
||||
if (!Array.isArray(achievements)) {
|
||||
return res.status(400).json({ error: 'Expected achievements array' });
|
||||
}
|
||||
await db.syncUserAchievements(req.user.id, achievements);
|
||||
return res.json({ success: true, count: achievements.length });
|
||||
});
|
||||
|
||||
// --- Batch Sync Rounds (e.g. migrating local offline rounds to cloud account) ---
|
||||
router.post('/sync-batch', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.post('/sync-batch', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const { rounds } = req.body;
|
||||
|
||||
@@ -34,33 +129,38 @@ router.post('/sync-batch', authMiddleware, (req: AuthenticatedRequest, res: Resp
|
||||
return res.status(400).json({ error: 'Expected an array of rounds' });
|
||||
}
|
||||
|
||||
const updatedRounds = db.syncRoundsBatch(req.user.id, rounds);
|
||||
return res.json({ count: updatedRounds.length, rounds: updatedRounds });
|
||||
});
|
||||
const updatedRounds = await db.syncRoundsBatch(req.user.id, rounds);
|
||||
|
||||
// --- Delete Round ---
|
||||
router.delete('/:id', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const success = db.deleteRound(req.user.id, req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Round not found' });
|
||||
}
|
||||
return res.json({ success: true });
|
||||
// Recalculate achievements
|
||||
const customCourses = await db.getCustomCourses(req.user.id);
|
||||
const allCourses = [...DEFAULT_COURSES, ...customCourses];
|
||||
const evaluated = evaluateAchievements(updatedRounds, allCourses, []);
|
||||
const validUnlocked = evaluated.unlockedList
|
||||
.filter((a) => a.isUnlocked)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
unlockedAt: a.unlockedAt || new Date().toISOString(),
|
||||
unlockedRoundName: a.unlockedRoundName || 'Saved Round',
|
||||
}));
|
||||
await db.syncUserAchievements(req.user.id, validUnlocked);
|
||||
|
||||
return res.json({ count: updatedRounds.length, rounds: updatedRounds, achievements: evaluated.unlockedList });
|
||||
});
|
||||
|
||||
// --- Custom Courses ---
|
||||
router.get('/courses/custom', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.get('/courses/custom', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
return res.json(db.getCustomCourses(req.user.id));
|
||||
const courses = await db.getCustomCourses(req.user.id);
|
||||
return res.json(courses);
|
||||
});
|
||||
|
||||
router.post('/courses/custom', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
router.post('/courses/custom', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const course: Course = req.body;
|
||||
if (!course || !course.id || !course.name) {
|
||||
return res.status(400).json({ error: 'Invalid course payload' });
|
||||
}
|
||||
const saved = db.saveCustomCourse(req.user.id, course);
|
||||
const saved = await db.saveCustomCourse(req.user.id, course);
|
||||
return res.json(saved);
|
||||
});
|
||||
|
||||
|
||||
+33
-6
@@ -121,30 +121,56 @@ export const authApi = {
|
||||
};
|
||||
|
||||
export const roundsApi = {
|
||||
async getDbStatus(): Promise<{
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
host: string | null;
|
||||
database: string | null;
|
||||
port: number;
|
||||
lastError: string | null;
|
||||
engine: string;
|
||||
}> {
|
||||
return request('/api/rounds/db-status');
|
||||
},
|
||||
|
||||
async getRounds(): Promise<Round[]> {
|
||||
return request<Round[]>('/api/rounds');
|
||||
},
|
||||
|
||||
async saveRound(round: Round): Promise<Round> {
|
||||
return request<Round>('/api/rounds', {
|
||||
async saveRound(round: Round): Promise<{ round: Round; achievements?: any[]; newlyUnlocked?: any[] }> {
|
||||
return request<{ round: Round; achievements?: any[]; newlyUnlocked?: any[] }>('/api/rounds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(round),
|
||||
});
|
||||
},
|
||||
|
||||
async syncBatch(rounds: Round[]): Promise<{ count: number; rounds: Round[] }> {
|
||||
return request<{ count: number; rounds: Round[] }>('/api/rounds/sync-batch', {
|
||||
async syncBatch(rounds: Round[]): Promise<{ count: number; rounds: Round[]; achievements?: any[] }> {
|
||||
return request<{ count: number; rounds: Round[]; achievements?: any[] }>('/api/rounds/sync-batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rounds }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteRound(id: string): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/rounds/${id}`, {
|
||||
async deleteRound(id: string): Promise<{
|
||||
success: boolean;
|
||||
remainingRoundsCount?: number;
|
||||
achievements?: any[];
|
||||
recalculatedCount?: number;
|
||||
}> {
|
||||
return request<{
|
||||
success: boolean;
|
||||
remainingRoundsCount?: number;
|
||||
achievements?: any[];
|
||||
recalculatedCount?: number;
|
||||
}>(`/api/rounds/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async getAchievements(): Promise<Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>> {
|
||||
return request<Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>>('/api/rounds/achievements');
|
||||
},
|
||||
|
||||
async getCustomCourses(): Promise<Course[]> {
|
||||
return request<Course[]>('/api/rounds/courses/custom');
|
||||
},
|
||||
@@ -166,3 +192,4 @@ export const communityApi = {
|
||||
return request<CommunityComparisonData>('/api/community/compare');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -135,11 +135,13 @@ export interface Round {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
teeName: string;
|
||||
teeId?: string;
|
||||
coursePar: number;
|
||||
holesPlayed: 18 | 9;
|
||||
nineType?: 'all' | 'front' | 'back';
|
||||
scores: Record<number, HoleScore>;
|
||||
isCompleted: boolean;
|
||||
completedAt?: string;
|
||||
settingsSnapshot: TrackingSettings;
|
||||
playerHandicap: number;
|
||||
weather?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Achievement, Course, Round } from '../types/golf';
|
||||
import { Achievement, Course, Round, HoleScore } from '../types/golf';
|
||||
import { calculateRoundStats } from './golfStats';
|
||||
|
||||
export const ALL_ACHIEVEMENTS: Achievement[] = [
|
||||
@@ -212,6 +212,9 @@ export function evaluateAchievements(
|
||||
const unlockedMap = new Map<string, { roundName: string; date: string }>();
|
||||
|
||||
rounds.forEach((round) => {
|
||||
// Achievements are awarded only after finishing and saving the round
|
||||
if (!round.isCompleted) return;
|
||||
|
||||
const course = courses.find((c) => c.id === round.courseId) || courses[0];
|
||||
if (!course) return;
|
||||
|
||||
@@ -438,3 +441,57 @@ export function evaluateAchievements(
|
||||
totalUnlockedCount: unlockedMap.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates live single-hole feats for provisional in-round awards (e.g. Hole in One, Eagle, Albatross).
|
||||
*/
|
||||
export function evaluateSingleHoleAchievements(hole: { par: number; holeNumber: number }, s: HoleScore): string[] {
|
||||
const ids: string[] = [];
|
||||
if (!s || !s.grossScore) return ids;
|
||||
|
||||
const gross = s.grossScore;
|
||||
const par = hole.par;
|
||||
const diff = gross - par;
|
||||
|
||||
// 1. Hole in One!
|
||||
if (gross === 1) {
|
||||
ids.push('ach_hole_in_one');
|
||||
}
|
||||
|
||||
// 2. Albatross (3 under par)
|
||||
if (diff <= -3 && gross > 0) {
|
||||
ids.push('ach_albatross');
|
||||
}
|
||||
|
||||
// 3. Eagle (2 under par)
|
||||
if (diff === -2 && gross > 0) {
|
||||
ids.push('ach_eagle_eye');
|
||||
}
|
||||
|
||||
// 4. First Birdie (1 under par)
|
||||
if (diff === -1) {
|
||||
ids.push('ach_first_birdie');
|
||||
}
|
||||
|
||||
// 5. Drove the Green (Par 4 drive reaching green)
|
||||
if (par === 4 && s.gir === 'hit' && (gross - (s.putts || 0) === 1 || (s.putts && gross === s.putts + 1))) {
|
||||
ids.push('ach_drove_the_green');
|
||||
}
|
||||
|
||||
// 6. Fringe Whisperer
|
||||
if (s.foregreenPutts && s.foregreenPutts > 0 && gross <= par) {
|
||||
ids.push('ach_fringe_whisperer');
|
||||
}
|
||||
|
||||
// 7. Clutch Sand Save
|
||||
if (s.bunker?.hit && s.bunker?.sandSave === true && gross <= par) {
|
||||
ids.push('ach_clutch_sand_save');
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function getAchievementById(id: string): Achievement | undefined {
|
||||
return ALL_ACHIEVEMENTS.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user