Compare commits

..
6 Commits
30 changed files with 2293 additions and 520 deletions

No files matched your search

@@ -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.
@@ -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
}
+16
View File
@@ -31,3 +31,19 @@ AUTHENTIK_CLIENT_SECRET=""
# GitHub OAuth Credentials # GitHub OAuth Credentials
GITHUB_CLIENT_ID="" GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET="" 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
View File
@@ -6,4 +6,6 @@ coverage/
*.log *.log
.env* .env*
!.env.example !.env.example
.aider* .aider.chat.history.md
.aider.input.history
.aider.tags.cache.v4/
+27
View File
@@ -219,3 +219,30 @@ npm run build
# Clean temporary files # Clean temporary files
npm run clean 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.
+1 -1
View File
@@ -18,7 +18,7 @@ ARG VITE_GOOGLE_MAPS_API_KEY=""
ENV VITE_GOOGLE_MAPS_API_KEY=$VITE_GOOGLE_MAPS_API_KEY ENV VITE_GOOGLE_MAPS_API_KEY=$VITE_GOOGLE_MAPS_API_KEY
# Build production bundle (Vite client + server.cjs) # 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 # Stage 2: Serve full-stack application (API + SPA) with Node.js
+20 -1
View File
@@ -15,6 +15,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"mysql2": "^3.24.6",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"vite": "^8.3.0", "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+4 -166
View File
@@ -1,169 +1,7 @@
{ {
"users": [ "users": [],
{ "settings": {},
"id": "usr_demo_golfer", "rounds": {},
"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": []
},
"customCourses": {}, "customCourses": {},
"publicSeedGolfers": [ "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
}
]
} }
+12 -3
View File
@@ -6,11 +6,11 @@ services:
args: args:
# Pass your Google Maps API key at build time (or load from .env) # Pass your Google Maps API key at build time (or load from .env)
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
container_name: pinpoint-golf-app container_name: pinpoint-golf
restart: unless-stopped restart: unless-stopped
ports: ports:
# Map host port 3000 to app port 3000 # Map host port 3000 to app port 3000
- "127.0.0.1:3000:3000" - "127.0.0.1:3474:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
@@ -24,11 +24,20 @@ services:
# GitHub OAuth Credentials (optional) # GitHub OAuth Credentials (optional)
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
# Database connection
- MARIADB_HOST=${MARIADB_HOST:-}
- MARIADB_PORT=${MARIADB_PORT:-3306}
- MARIADB_USER=${MARIADB_USER:-}
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-}
- MARIADB_DATABASE=${MARIADB_DATABASE:-pinpoint_golf}
- MARIADB_SSL=${MARIADB_SSL:-false}
# Optional connection pool size for Galera multi-master concurrency
- MARIADB_CONNECTION_LIMIT=${MARIADB_CONNECTION_LIMIT:-10}
volumes: volumes:
# Persistent volume for users, scorecards, custom courses, and community stats # Persistent volume for users, scorecards, custom courses, and community stats
- pinpoint_data:/app/data - pinpoint_data:/app/data
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/healthz"] test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://golf.jista.eu/healthz"]
interval: 30s interval: 30s
timeout: 3s timeout: 3s
retries: 3 retries: 3
+2 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "tsx server.ts", "dev": "tsx server.ts",
"start": "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", "preview": "vite preview",
"clean": "rm -rf dist server.js data/pinpoint_db.json.tmp", "clean": "rm -rf dist server.js data/pinpoint_db.json.tmp",
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
@@ -22,6 +22,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"mysql2": "^3.24.6",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"vite": "^8.3.0" "vite": "^8.3.0"
+3
View File
@@ -4,6 +4,7 @@ import dotenv from 'dotenv';
import authRoutes from './src/server/routes/authRoutes'; import authRoutes from './src/server/routes/authRoutes';
import roundsRoutes from './src/server/routes/roundsRoutes'; import roundsRoutes from './src/server/routes/roundsRoutes';
import communityRoutes from './src/server/routes/communityRoutes'; import communityRoutes from './src/server/routes/communityRoutes';
import { db } from './src/server/db';
dotenv.config(); dotenv.config();
@@ -26,6 +27,8 @@ app.use('/api/rounds', roundsRoutes);
app.use('/api/community', communityRoutes); app.use('/api/community', communityRoutes);
async function startServer() { async function startServer() {
// Initialize persistence backend (MariaDB Galera cluster if configured, or local store)
await db.init();
if (!isProduction) { if (!isProduction) {
// Development mode: Vite middleware // Development mode: Vite middleware
const { createServer: createViteServer } = await import('vite'); const { createServer: createViteServer } = await import('vite');
+275 -87
View File
@@ -10,14 +10,24 @@ import { Settings } from './components/Settings';
import { NewRoundModal } from './components/NewRoundModal'; import { NewRoundModal } from './components/NewRoundModal';
import { RoundsHistoryModal } from './components/RoundsHistoryModal'; import { RoundsHistoryModal } from './components/RoundsHistoryModal';
import { AchievementsModal } from './components/AchievementsModal'; import { AchievementsModal } from './components/AchievementsModal';
import { SaveRoundModal } from './components/SaveRoundModal';
import { CourseEditorModal } from './components/CourseEditorModal'; import { CourseEditorModal } from './components/CourseEditorModal';
import { AuthModal } from './components/AuthModal'; import { AuthModal } from './components/AuthModal';
import { CommunityComparisonModal } from './components/CommunityComparisonModal'; import { CommunityComparisonModal } from './components/CommunityComparisonModal';
import { calculateRoundStats } from './utils/golfStats'; import { calculateRoundStats } from './utils/golfStats';
import { evaluateAchievements } from './utils/achievements'; import { ALL_ACHIEVEMENTS, evaluateAchievements } from './utils/achievements';
import { AuthProviderComponent, useAuth } from './context/AuthContext'; import { AuthProviderComponent, useAuth } from './context/AuthContext';
import { roundsApi, authApi } from './services/api'; 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_ROUNDS = 'pinpoint_golf_rounds_v1';
const STORAGE_KEY_SETTINGS = 'pinpoint_golf_settings_v1'; const STORAGE_KEY_SETTINGS = 'pinpoint_golf_settings_v1';
@@ -60,28 +70,28 @@ function AppContent() {
return DEFAULT_COURSES; 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[]>(() => { const [rounds, setRounds] = useState<Round[]>(() => {
try { try {
const saved = localStorage.getItem(STORAGE_KEY_ROUNDS); const saved = localStorage.getItem(STORAGE_KEY_ROUNDS);
if (saved) { if (saved) {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) return parsed; if (Array.isArray(parsed)) return parsed;
} }
} catch { } catch {
// fallback // fallback
} }
return [SAMPLE_ROUND]; return [];
}); });
const [currentRoundId, setCurrentRoundId] = useState<string>(() => { const [currentRoundId, setCurrentRoundId] = useState<string>(() => {
return rounds[0]?.id || SAMPLE_ROUND.id; return rounds[0]?.id || '';
}); });
const [currentHoleNumber, setCurrentHoleNumber] = useState<number>(1); const [currentHoleNumber, setCurrentHoleNumber] = useState<number>(1);
const [activeTab, setActiveTab] = useState<ActiveTab>('hole'); 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[]>(() => { const [unlockedIds, setUnlockedIds] = useState<string[]>(() => {
try { try {
const saved = localStorage.getItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS); const saved = localStorage.getItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS);
@@ -92,22 +102,29 @@ function AppContent() {
return []; 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 [newlyUnlockedToast, setNewlyUnlockedToast] = useState<Achievement | null>(null);
const isInitialMount = useRef(true);
// Modals state // Modals state
const [isNewRoundModalOpen, setIsNewRoundModalOpen] = useState(false); const [isNewRoundModalOpen, setIsNewRoundModalOpen] = useState(false);
const [isRoundsHistoryModalOpen, setIsRoundsHistoryModalOpen] = useState(false); const [isRoundsHistoryModalOpen, setIsRoundsHistoryModalOpen] = useState(false);
const [isAchievementsModalOpen, setIsAchievementsModalOpen] = useState(false); const [isAchievementsModalOpen, setIsAchievementsModalOpen] = useState(false);
const [isSaveRoundModalOpen, setIsSaveRoundModalOpen] = useState(false);
const [isCourseEditorModalOpen, setIsCourseEditorModalOpen] = useState(false); const [isCourseEditorModalOpen, setIsCourseEditorModalOpen] = useState(false);
const [courseEditorBaseCourse, setCourseEditorBaseCourse] = useState<Course | null>(null); const [courseEditorBaseCourse, setCourseEditorBaseCourse] = useState<Course | null>(null);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [isCommunityModalOpen, setIsCommunityModalOpen] = 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(() => { 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 () => { const loadCloudData = async () => {
try { try {
@@ -121,17 +138,20 @@ function AppContent() {
setSettings(cloudSettings); setSettings(cloudSettings);
} }
let combinedRounds = [...rounds];
if (cloudRounds && cloudRounds.length > 0) { if (cloudRounds && cloudRounds.length > 0) {
// Merge cloud rounds with local rounds without duplicates // Merge cloud rounds with local rounds without duplicates
setRounds((prev) => { const merged = [...cloudRounds];
const merged = [...cloudRounds]; for (const r of rounds) {
for (const r of prev) { if (!merged.some((m) => m.id === r.id)) {
if (!merged.some((m) => m.id === r.id)) { merged.push(r);
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) { if (cloudCourses && cloudCourses.length > 0) {
@@ -145,6 +165,13 @@ function AppContent() {
return combined; 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) { } catch (err) {
console.error('Error syncing cloud data:', err); console.error('Error syncing cloud data:', err);
} }
@@ -179,38 +206,21 @@ function AppContent() {
} }
}, [courses]); }, [courses]);
// Evaluate achievements whenever rounds or courses change // Current active round object (can be null if user has no rounds started)
useEffect(() => { const currentRound: Round | null =
const evaluation = evaluateAchievements(rounds, courses, unlockedIds); rounds.find((r) => r.id === currentRoundId) || rounds[0] || null;
setAchievements(evaluation.unlockedList);
const currentUnlockedIds = evaluation.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id); const currentCourse: Course = currentRound
if (JSON.stringify(currentUnlockedIds) !== JSON.stringify(unlockedIds)) { ? courses.find((c) => c.id === currentRound.courseId) ||
setUnlockedIds(currentUnlockedIds); DEFAULT_COURSES.find((c) => c.id === currentRound.courseId) ||
try { courses[0] ||
localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(currentUnlockedIds)); DEFAULT_COURSES[0]
} catch { : courses[0] || DEFAULT_COURSES[0];
// fallback
}
}
if (!isInitialMount.current && evaluation.newlyUnlocked.length > 0) { // Update a specific hole's score (only permitted while round is not completed)
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
const handleUpdateHoleScore = (holeNumber: number, updated: HoleScore) => { const handleUpdateHoleScore = (holeNumber: number, updated: HoleScore) => {
if (!currentRound || currentRound.isCompleted) return;
setRounds((prevRounds) => { setRounds((prevRounds) => {
const updatedRounds = prevRounds.map((r) => { const updatedRounds = prevRounds.map((r) => {
if (r.id !== currentRound.id) return 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) { if (user) {
roundsApi.saveRound(modified).catch((e) => console.warn('Cloud save error:', e)); roundsApi.saveRound(modified).catch((e) => console.warn('Cloud save error:', e));
} }
@@ -244,20 +254,98 @@ function AppContent() {
} }
}; };
const handleDeleteRound = (roundId: string) => { // Finalize and permanently lock a round with confirmation
if (rounds.length <= 1) return; 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); const remaining = rounds.filter((r) => r.id !== roundId);
setRounds(remaining); setRounds(remaining);
if (currentRoundId === roundId) { if (currentRoundId === roundId) {
setCurrentRoundId(remaining[0].id); setCurrentRoundId(remaining[0]?.id || '');
} }
if (user) { 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 = () => { const handleLoadSampleRound = () => {
// Only available in development mode
if (import.meta.env.PROD) return;
const exists = rounds.some((r) => r.id === SAMPLE_ROUND.id); const exists = rounds.some((r) => r.id === SAMPLE_ROUND.id);
if (!exists) { if (!exists) {
setRounds((prev) => [SAMPLE_ROUND, ...prev]); setRounds((prev) => [SAMPLE_ROUND, ...prev]);
@@ -268,6 +356,7 @@ function AppContent() {
}; };
const handleEditHoleFromScorecard = (holeNumber: number) => { const handleEditHoleFromScorecard = (holeNumber: number) => {
if (currentRound?.isCompleted) return; // Locked: no edits permitted on saved rounds
setCurrentHoleNumber(holeNumber); setCurrentHoleNumber(holeNumber);
setActiveTab('hole'); setActiveTab('hole');
}; };
@@ -323,8 +412,10 @@ function AppContent() {
}; };
// Quick summary stats for the top bar // Quick summary stats for the top bar
const summary = calculateRoundStats(currentRound, currentCourse); const summary = currentRound
const unlockedAchievementsCount = achievements.filter((a) => a.isUnlocked).length; ? calculateRoundStats(currentRound, currentCourse)
: { totalGross: 0, toPar: 0, holesPlayedCount: 0 };
const unlockedAchievementsCount = user ? achievements.filter((a) => a.isUnlocked).length : 0;
return ( return (
<div className="min-h-screen bg-stone-950 text-stone-100 flex flex-col font-sans relative"> <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 App Content Viewport */}
<main className="flex-1 w-full max-w-7xl mx-auto px-2 sm:px-4 py-4"> <main className="flex-1 w-full max-w-7xl mx-auto px-2 sm:px-4 py-4">
{activeTab === 'hole' && ( {!currentRound ? (
<HoleEntry <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">
currentHoleNumber={currentHoleNumber} <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">
setCurrentHoleNumber={setCurrentHoleNumber} <Flag className="w-8 h-8" />
course={currentCourse} </div>
scores={currentRound.scores}
onUpdateHoleScore={handleUpdateHoleScore}
settings={settings}
onViewScorecard={() => setActiveTab('scorecard')}
/>
)}
{activeTab === 'scorecard' && ( <div className="space-y-2">
<Scorecard <h2 className="text-2xl sm:text-3xl font-black text-white">
round={currentRound} Ready to Tee Off?
course={currentCourse} </h2>
settings={settings} <p className="text-xs sm:text-sm text-stone-400 max-w-lg mx-auto leading-relaxed">
onEditHole={handleEditHoleFromScorecard} 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' && ( <div className="flex flex-wrap items-center justify-center gap-3 pt-2">
<Analytics <button
round={currentRound} onClick={() => setIsNewRoundModalOpen(true)}
course={currentCourse} 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"
settings={settings} >
/> <Plus className="w-4 h-4 font-bold" />
)} <span>Start New Round</span>
</button>
{activeTab === 'settings' && ( {!user && (
<Settings <button
settings={settings} onClick={() => setIsAuthModalOpen(true)}
onUpdateSettings={handleUpdateSettings} 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> </main>
@@ -441,16 +607,38 @@ function AppContent() {
onClose={() => setIsRoundsHistoryModalOpen(false)} onClose={() => setIsRoundsHistoryModalOpen(false)}
rounds={rounds} rounds={rounds}
currentRoundId={currentRoundId} 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} onDeleteRound={handleDeleteRound}
onLoadSampleRound={handleLoadSampleRound} 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 */} {/* Achievements / Trophies Showcase Modal */}
<AchievementsModal <AchievementsModal
isOpen={isAchievementsModalOpen} isOpen={isAchievementsModalOpen}
onClose={() => setIsAchievementsModalOpen(false)} onClose={() => setIsAchievementsModalOpen(false)}
achievements={achievements} achievements={achievements}
onOpenAuth={() => setIsAuthModalOpen(true)}
/> />
{/* Custom Course Builder & Modifier Modal */} {/* Custom Course Builder & Modifier Modal */}
+85 -42
View File
@@ -22,18 +22,22 @@ import {
Flag, Flag,
} from 'lucide-react'; } from 'lucide-react';
import { Achievement, AchievementTier } from '../types/golf'; import { Achievement, AchievementTier } from '../types/golf';
import { useAuth } from '../context/AuthContext';
interface AchievementsModalProps { interface AchievementsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
achievements: Achievement[]; achievements: Achievement[];
onOpenAuth?: () => void;
} }
export const AchievementsModal: React.FC<AchievementsModalProps> = ({ export const AchievementsModal: React.FC<AchievementsModalProps> = ({
isOpen, isOpen,
onClose, onClose,
achievements, achievements,
onOpenAuth,
}) => { }) => {
const { user } = useAuth();
const [tierFilter, setTierFilter] = useState<'all' | AchievementTier>('all'); const [tierFilter, setTierFilter] = useState<'all' | AchievementTier>('all');
const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all');
@@ -150,53 +154,90 @@ export const AchievementsModal: React.FC<AchievementsModalProps> = ({
</button> </button>
</div> </div>
{/* Global Progress Strip */} {/* Authenticated Check */}
<div className="my-4 p-4 rounded-2xl bg-stone-850 border border-stone-800 shrink-0 space-y-3"> {!user ? (
<div className="flex items-center justify-between text-xs font-mono"> <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">
<span className="text-stone-300 font-sans font-bold">Career Trophies Unlocked:</span> <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">
<div className="flex items-center gap-2"> <Lock className="w-8 h-8" />
<span className="text-emerald-400 font-bold text-sm">
{unlockedCount} / {totalCount}
</span>
<span className="text-stone-500">({percentage}%)</span>
</div> </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> </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 className="w-full bg-stone-800 h-2.5 rounded-full overflow-hidden">
<div <div
style={{ width: `${percentage}%` }} style={{ width: `${percentage}%` }}
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500" className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
/> />
</div> </div>
{/* Tier Counts Pill Bar */} {unlockedCount === 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1"> <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">
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center"> <Sparkles className="w-4 h-4 text-amber-400 shrink-0" />
<span className="text-[10px] text-amber-400 font-bold uppercase block">Bronze</span> <span>
<span className="text-xs font-mono font-bold text-white"> <strong>New Golfer:</strong> 0 trophies unlocked. Finish and save a round to earn your first achievements!
{tierCounts.bronze.unlocked} / {tierCounts.bronze.total} </span>
</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>
<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 */} {/* Filters */}
<div className="flex flex-wrap items-center justify-between gap-2 pb-3 shrink-0"> <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> </div>
</>
)}
{/* Footer */} {/* Footer */}
<div className="pt-4 border-t border-stone-800 flex items-center justify-end shrink-0"> <div className="pt-4 border-t border-stone-800 flex items-center justify-end shrink-0">
+12 -10
View File
@@ -282,16 +282,18 @@ export const AuthModal: React.FC<AuthModalProps> = ({
{submitting ? 'Authenticating...' : 'Sign In to Account'} {submitting ? 'Authenticating...' : 'Sign In to Account'}
</button> </button>
<div className="pt-3 border-t border-stone-800/80"> {!import.meta.env.PROD && (
<button <div className="pt-3 border-t border-stone-800/80">
type="button" <button
onClick={handleQuickDemo} type="button"
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" 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) <Sparkles className="w-3.5 h-3.5 text-amber-400" />
</button> One-Click Dev Login as Demo Golfer (demogolfer / golf1234)
</div> </button>
</div>
)}
</form> </form>
)} )}
+32 -3
View File
@@ -116,9 +116,36 @@ export const CommunityComparisonModal: React.FC<CommunityComparisonModalProps> =
</button> </button>
</div> </div>
{/* View Switcher & Privacy Bar */} {!user ? (
<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="p-8 sm:p-14 text-center flex flex-col items-center justify-center space-y-4 my-auto">
<div className="flex bg-stone-950 p-1 rounded-2xl border border-stone-800 w-full sm:w-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 <button
onClick={() => setActiveTab('compare')} 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 ${ 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> </div>
) : null} ) : null}
</div> </div>
</>
)}
{/* Footer */} {/* 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"> <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
View File
@@ -12,6 +12,10 @@ import {
Waves, Waves,
ShieldAlert, ShieldAlert,
HelpCircle, HelpCircle,
Lock,
Navigation,
Trophy,
X,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Course, Course,
@@ -24,6 +28,9 @@ import {
} from '../types/golf'; } from '../types/golf';
import { getScoreName, formatToPar } from '../utils/golfStats'; import { getScoreName, formatToPar } from '../utils/golfStats';
import { GolfGpsRangefinder } from './GolfGpsRangefinder'; import { GolfGpsRangefinder } from './GolfGpsRangefinder';
import { useAuth } from '../context/AuthContext';
import { evaluateSingleHoleAchievements, getAchievementById } from '../utils/achievements';
interface HoleEntryProps { interface HoleEntryProps {
currentHoleNumber: number; currentHoleNumber: number;
@@ -33,6 +40,9 @@ interface HoleEntryProps {
onUpdateHoleScore: (holeNumber: number, updated: HoleScore) => void; onUpdateHoleScore: (holeNumber: number, updated: HoleScore) => void;
settings: TrackingSettings; settings: TrackingSettings;
onViewScorecard: () => void; onViewScorecard: () => void;
isCompleted?: boolean;
onOpenSaveModal?: () => void;
onOpenAuth?: () => void;
} }
export const HoleEntry: React.FC<HoleEntryProps> = ({ export const HoleEntry: React.FC<HoleEntryProps> = ({
@@ -43,7 +53,11 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
onUpdateHoleScore, onUpdateHoleScore,
settings, settings,
onViewScorecard, onViewScorecard,
isCompleted = false,
onOpenSaveModal,
onOpenAuth,
}) => { }) => {
const { user } = useAuth();
const currentHole: Hole = course.holes.find((h) => h.holeNumber === currentHoleNumber) || course.holes[0]; const currentHole: Hole = course.holes.find((h) => h.holeNumber === currentHoleNumber) || course.holes[0];
const holeScore: HoleScore = scores[currentHoleNumber] || { const holeScore: HoleScore = scores[currentHoleNumber] || {
holeNumber: currentHoleNumber, holeNumber: currentHoleNumber,
@@ -61,12 +75,53 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
}; };
const [showGps, setShowGps] = useState(true); 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]) => { const updateField = <K extends keyof HoleScore>(field: K, value: HoleScore[K]) => {
if (isCompleted) return;
const updated = { ...holeScore, [field]: value }; 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); onUpdateHoleScore(currentHoleNumber, updated);
}; };
const currentGross = holeScore.grossScore || currentHole.par; const currentGross = holeScore.grossScore || currentHole.par;
const scoreDiff = currentGross - currentHole.par; const scoreDiff = currentGross - currentHole.par;
const scoreTitle = getScoreName(currentGross, currentHole.par); const scoreTitle = getScoreName(currentGross, currentHole.par);
@@ -182,6 +237,31 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
return ( return (
<div className="max-w-4xl mx-auto px-4 py-4 sm:py-6 space-y-6 pb-28 md:pb-16"> <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 */} {/* 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="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"> <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>
</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 && ( {showGps && (
<GolfGpsRangefinder user ? (
currentHole={currentHole} <GolfGpsRangefinder
course={course} currentHole={currentHole}
onSelectHole={setCurrentHoleNumber} course={course}
unit={settings.distanceUnit} onSelectHole={setCurrentHoleNumber}
onSaveDriveDistance={(dist) => updateField('driveDistanceYds', dist)} 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 */} {/* 3. Main Gross Score Stepper & Dial */}
@@ -899,28 +1077,43 @@ export const HoleEntry: React.FC<HoleEntryProps> = ({
)} )}
{/* 11. Bottom Action Bar */} {/* 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 <button
type="button"
onClick={onViewScorecard} 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" 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 View Full Card
</button> </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 ? ( {currentHoleNumber < course.holes.length ? (
<button <button
type="button"
onClick={() => setCurrentHoleNumber(currentHoleNumber + 1)} 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" 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" /> <ChevronRight className="w-4 h-4" />
</button> </button>
) : ( ) : (
<button <button
onClick={onViewScorecard} type="button"
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" 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" /> <ChevronRight className="w-4 h-4" />
</button> </button>
)} )}
+23 -7
View File
@@ -14,6 +14,7 @@ import {
Eye, Eye,
EyeOff, EyeOff,
ChevronDown, ChevronDown,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { Round } from '../types/golf'; import { Round } from '../types/golf';
import { formatToPar } from '../utils/golfStats'; import { formatToPar } from '../utils/golfStats';
@@ -24,7 +25,7 @@ export type ActiveTab = 'hole' | 'scorecard' | 'analytics' | 'settings';
interface NavbarProps { interface NavbarProps {
activeTab: ActiveTab; activeTab: ActiveTab;
setActiveTab: (tab: ActiveTab) => void; setActiveTab: (tab: ActiveTab) => void;
currentRound: Round; currentRound?: Round | null;
onNewRound: () => void; onNewRound: () => void;
onOpenRounds: () => void; onOpenRounds: () => void;
onOpenAchievements: () => 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"> <span className="text-lg font-extrabold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
PinPoint Golf PinPoint Golf
</span> </span>
<span className="hidden lg:inline-block ml-2 text-xs font-medium text-stone-400"> <span className="hidden lg:inline-flex items-center ml-2 text-xs font-medium text-stone-400">
{currentRound.courseName} {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> </span>
</div> </div>
</button> </button>
@@ -125,9 +132,11 @@ export const Navbar: React.FC<NavbarProps> = ({
<button <button
onClick={onOpenCommunity} 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" 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" /> <Users className="w-3.5 h-3.5 text-amber-400" />
<span>Community</span> <span>Community</span>
{!user && <Lock className="w-2.5 h-2.5 text-amber-400/80" />}
</button> </button>
</nav> </nav>
@@ -156,19 +165,26 @@ export const Navbar: React.FC<NavbarProps> = ({
<button <button
onClick={onOpenAchievements} 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" 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" /> <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> </button>
{/* Mobile Community Button */} {/* Mobile Community Button */}
<button <button
onClick={onOpenCommunity} onClick={onOpenCommunity}
className="md:hidden p-1.5 text-amber-300 hover:bg-amber-500/10 rounded-lg transition" className="md:hidden p-1.5 text-amber-300 hover:bg-amber-500/10 rounded-lg transition relative"
title="Community Leaderboard" title={user ? 'Community Leaderboard' : 'Community (Sign-in required)'}
> >
<Users className="w-4 h-4 text-amber-400" /> <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> </button>
{/* Saved Rounds */} {/* Saved Rounds */}
+154 -95
View File
@@ -1,8 +1,7 @@
import React from 'react'; import React, { useState } from 'react';
import { X, Calendar, MapPin, Trash2, CheckCircle2, RotateCcw } from 'lucide-react'; import { X, Calendar, MapPin, Trash2, CheckCircle2, RotateCcw, Lock, AlertTriangle } from 'lucide-react';
import { Round } from '../types/golf'; import { Round } from '../types/golf';
import { formatToPar } from '../utils/golfStats'; import { formatToPar } from '../utils/golfStats';
import { SAMPLE_ROUND } from '../data/sampleRounds';
interface RoundsHistoryModalProps { interface RoundsHistoryModalProps {
isOpen: boolean; isOpen: boolean;
@@ -11,7 +10,7 @@ interface RoundsHistoryModalProps {
currentRoundId: string; currentRoundId: string;
onSelectRound: (roundId: string) => void; onSelectRound: (roundId: string) => void;
onDeleteRound: (roundId: string) => void; onDeleteRound: (roundId: string) => void;
onLoadSampleRound: () => void; onLoadSampleRound?: () => void;
} }
export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({ export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
@@ -23,8 +22,17 @@ export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
onDeleteRound, onDeleteRound,
onLoadSampleRound, onLoadSampleRound,
}) => { }) => {
const [deletingId, setDeletingId] = useState<string | null>(null);
if (!isOpen) return null; if (!isOpen) return null;
const isProduction = import.meta.env.PROD;
const handleConfirmDelete = (roundId: string) => {
onDeleteRound(roundId);
setDeletingId(null);
};
return ( 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="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"> <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> <div>
<h3 className="text-xl font-extrabold text-white">Rounds & History</h3> <h3 className="text-xl font-extrabold text-white">Rounds & History</h3>
<p className="text-xs text-stone-400 mt-0.5"> <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> </p>
</div> </div>
<button <button
@@ -44,117 +52,168 @@ export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
</button> </button>
</div> </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 */} {/* Rounds List */}
<div className="space-y-3 my-4 overflow-y-auto flex-1 pr-1"> <div className="space-y-3 my-4 overflow-y-auto flex-1 pr-1">
{rounds.map((r) => { {rounds.length === 0 ? (
const isCurrent = r.id === currentRoundId; <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 // Calculate quick score
let grossTotal = 0; let grossTotal = 0;
let holesScored = 0; let holesScored = 0;
Object.values(r.scores).forEach((s) => { Object.values(r.scores).forEach((s) => {
if (s.grossScore && s.grossScore > 0) { if (s.grossScore && s.grossScore > 0) {
grossTotal += s.grossScore; grossTotal += s.grossScore;
holesScored++; holesScored++;
} }
}); });
const diff = grossTotal - r.coursePar; const diff = grossTotal - r.coursePar;
return ( 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'
}`}
>
<div <div
onClick={() => { key={r.id}
onSelectRound(r.id); className={`p-4 rounded-2xl border transition-all flex items-center justify-between gap-4 ${
onClose(); isCurrent
}} ? 'bg-emerald-950/30 border-emerald-500/80'
className="cursor-pointer flex-1" : 'bg-stone-850 border-stone-800 hover:bg-stone-800/80'
}`}
> >
<div className="flex items-center gap-2"> <div
<span className="font-bold text-white text-sm">{r.courseName}</span> onClick={() => {
{isCurrent && ( onSelectRound(r.id);
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400"> onClose();
Active }}
</span> className="cursor-pointer flex-1"
)} >
</div> <div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-white text-sm">{r.courseName}</span>
<div className="flex flex-wrap items-center gap-3 text-xs text-stone-400 mt-1"> {isCurrent && (
<div className="flex items-center gap-1"> <span className="text-[10px] font-bold px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
<Calendar className="w-3.5 h-3.5 text-stone-500" /> Active
<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> </span>
</> )}
) : 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">
<span>·</span> <Lock className="w-2.5 h-2.5" />
<span className="text-[11px] text-stone-400"> Finalized
{r.weather}
</span> </span>
</> ) : (
) : null} <span className="text-[10px] font-bold px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30">
</div> In Progress
</div> </span>
)}
{/* 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 : '—'}
</div> </div>
<div
className={`text-xs font-mono font-semibold ${ <div className="flex flex-wrap items-center gap-3 text-xs text-stone-400 mt-1">
diff < 0 ? 'text-emerald-400' : diff > 0 ? 'text-rose-400' : 'text-stone-400' <div className="flex items-center gap-1">
}`} <Calendar className="w-3.5 h-3.5 text-stone-500" />
> <span>{r.date}</span>
{holesScored > 0 ? formatToPar(diff) : 'In progress'} </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>
</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 <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" 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" /> <Trash2 className="w-4 h-4" />
</button> </button>
)} </div>
</div> </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"> <div className="pt-4 border-t border-stone-800 flex items-center justify-between shrink-0">
<button {!isProduction && onLoadSampleRound ? (
onClick={() => { <button
onLoadSampleRound(); onClick={() => {
onClose(); 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" }}
> 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> <RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
</button> <span>Load Dev Sample Round</span>
</button>
) : (
<div className="text-xs text-stone-500">
{rounds.length} {rounds.length === 1 ? 'round' : 'rounds'} stored
</div>
)}
<button <button
onClick={onClose} onClick={onClose}
+217
View File
@@ -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>
);
};
+59 -13
View File
@@ -4,6 +4,9 @@ import {
Share2, Share2,
Check, Check,
Edit3, Edit3,
Lock,
CheckCircle2,
AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
import { Course, Round, TrackingSettings } from '../types/golf'; import { Course, Round, TrackingSettings } from '../types/golf';
import { calculateRoundStats, formatToPar } from '../utils/golfStats'; import { calculateRoundStats, formatToPar } from '../utils/golfStats';
@@ -13,6 +16,7 @@ interface ScorecardProps {
course: Course; course: Course;
settings: TrackingSettings; settings: TrackingSettings;
onEditHole: (holeNumber: number) => void; onEditHole: (holeNumber: number) => void;
onOpenSaveModal?: () => void;
} }
export const Scorecard: React.FC<ScorecardProps> = ({ export const Scorecard: React.FC<ScorecardProps> = ({
@@ -20,6 +24,7 @@ export const Scorecard: React.FC<ScorecardProps> = ({
course, course,
settings, settings,
onEditHole, onEditHole,
onOpenSaveModal,
}) => { }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const stats = calculateRoundStats(round, course); const stats = calculateRoundStats(round, course);
@@ -185,8 +190,13 @@ export const Scorecard: React.FC<ScorecardProps> = ({
{holes.map((h) => ( {holes.map((h) => (
<th <th
key={h.holeNumber} key={h.holeNumber}
onClick={() => onEditHole(h.holeNumber)} onClick={() => !round.isCompleted && 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" 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} {h.holeNumber}
</th> </th>
@@ -234,16 +244,24 @@ export const Scorecard: React.FC<ScorecardProps> = ({
<tr className="bg-stone-850 font-bold border-y border-stone-700"> <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"> <td className="py-3 px-3 text-left font-sans text-white flex items-center gap-1.5">
<span>Score</span> <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> </td>
{holes.map((h) => { {holes.map((h) => {
const s = round.scores[h.holeNumber]; const s = round.scores[h.holeNumber];
return ( return (
<td <td
key={h.holeNumber} key={h.holeNumber}
onClick={() => onEditHole(h.holeNumber)} onClick={() => !round.isCompleted && onEditHole(h.holeNumber)}
className="py-2 px-1 cursor-pointer hover:bg-stone-750 transition-colors" className={`py-2 px-1 transition-colors ${
title={`Edit Hole ${h.holeNumber}`} !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)} {renderScoreCell(s?.grossScore, h.par)}
</td> </td>
@@ -455,12 +473,22 @@ export const Scorecard: React.FC<ScorecardProps> = ({
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider"> <div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-wider">
<span>Official Scorecard</span> {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 className="text-stone-600">·</span>
<span>{round.date}</span> <span className="text-stone-300">{round.date}</span>
<span className="text-stone-600">·</span> <span className="text-stone-600">·</span>
<span>Handicap: {round.playerHandicap}</span> <span className="text-stone-400">HCP: {round.playerHandicap}</span>
</div> </div>
<h2 className="text-2xl sm:text-3xl font-extrabold text-white mt-1"> <h2 className="text-2xl sm:text-3xl font-extrabold text-white mt-1">
{course.name} {course.name}
@@ -495,7 +523,7 @@ export const Scorecard: React.FC<ScorecardProps> = ({
</div> </div>
{/* Big Score Summary & Actions */} {/* 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-right">
<div className="text-xs uppercase tracking-wider text-stone-400 font-semibold"> <div className="text-xs uppercase tracking-wider text-stone-400 font-semibold">
Total Score Total Score
@@ -518,7 +546,18 @@ export const Scorecard: React.FC<ScorecardProps> = ({
</div> </div>
</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 <button
onClick={handleShareSummary} 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" 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> <span>Double Bogey+</span>
</div> </div>
<div className="flex items-center gap-1.5 text-stone-500"> <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> </div>
</div> </div>
+131 -1
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Compass, Compass,
Crosshair, Crosshair,
@@ -6,9 +6,15 @@ import {
RotateCcw, RotateCcw,
Sliders, Sliders,
CheckCircle2, CheckCircle2,
Database,
Server,
RefreshCw,
ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { PresetProfile, TrackingSettings } from '../types/golf'; import { PresetProfile, TrackingSettings } from '../types/golf';
import { SETTING_PRESETS, DEFAULT_TRACKING_SETTINGS } from '../data/courses'; import { SETTING_PRESETS, DEFAULT_TRACKING_SETTINGS } from '../data/courses';
import { roundsApi } from '../services/api';
interface SettingsProps { interface SettingsProps {
settings: TrackingSettings; settings: TrackingSettings;
@@ -19,7 +25,32 @@ export const Settings: React.FC<SettingsProps> = ({
settings, settings,
onUpdateSettings, 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) => { const toggleSetting = (key: keyof TrackingSettings) => {
onUpdateSettings({ onUpdateSettings({
...settings, ...settings,
[key]: !settings[key], [key]: !settings[key],
@@ -558,6 +589,105 @@ export const Settings: React.FC<SettingsProps> = ({
</div> </div>
</div> </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> </div>
); );
}; };
+5 -4
View File
@@ -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; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication token missing or invalid' }); 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]; const token = authHeader.split(' ')[1];
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; 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) { if (!user) {
return res.status(401).json({ error: 'User no longer exists' }); 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 // 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; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(); return next();
@@ -51,7 +51,7 @@ export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response,
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; 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) { if (user) {
req.user = user; req.user = user;
} }
@@ -60,3 +60,4 @@ export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response,
} }
next(); next();
} }
+127 -19
View File
@@ -4,12 +4,14 @@ import bcrypt from 'bcryptjs';
import { User, PublicGolferStats } from '../types/auth'; import { User, PublicGolferStats } from '../types/auth';
import { Round, TrackingSettings, Course } from '../types/golf'; import { Round, TrackingSettings, Course } from '../types/golf';
import { DEFAULT_TRACKING_SETTINGS } from '../data/courses'; import { DEFAULT_TRACKING_SETTINGS } from '../data/courses';
import { mariadb } from './mariadb';
interface DatabaseSchema { interface DatabaseSchema {
users: Array<User & { passwordHash?: string }>; users: Array<User & { passwordHash?: string }>;
settings: Record<string, TrackingSettings>; // userId -> settings settings: Record<string, TrackingSettings>; // userId -> settings
rounds: Record<string, Round[]>; // userId -> rounds rounds: Record<string, Round[]>; // userId -> rounds
customCourses: Record<string, Course[]>; // userId -> courses customCourses: Record<string, Course[]>; // userId -> courses
achievements: Record<string, Array<{ id: string; unlockedAt?: string; unlockedRoundName?: string }>>;
publicSeedGolfers: PublicGolferStats[]; publicSeedGolfers: PublicGolferStats[];
} }
@@ -21,7 +23,7 @@ if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true }); 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[] = [ const SEED_PUBLIC_GOLFERS: PublicGolferStats[] = [
{ {
id: 'user_seed_viktor', id: 'user_seed_viktor',
@@ -123,21 +125,42 @@ class Database {
this.seedDefaultUserIfEmpty(); 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 { private load(): DatabaseSchema {
try { try {
if (fs.existsSync(DB_FILE)) { if (fs.existsSync(DB_FILE)) {
const raw = fs.readFileSync(DB_FILE, 'utf-8'); 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) { } catch (e) {
console.error('Failed to load database file, creating fresh store:', 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 { return {
users: [], users: [],
settings: {}, settings: {},
rounds: {}, rounds: {},
customCourses: {}, customCourses: {},
publicSeedGolfers: SEED_PUBLIC_GOLFERS, achievements: {},
publicSeedGolfers: !isProduction && enableMock ? SEED_PUBLIC_GOLFERS : [],
}; };
} }
@@ -152,6 +175,25 @@ class Database {
} }
private seedDefaultUserIfEmpty() { 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) { if (!this.data.users || this.data.users.length === 0) {
const salt = bcrypt.genSaltSync(10); const salt = bcrypt.genSaltSync(10);
const passwordHash = bcrypt.hashSync('golf1234', salt); const passwordHash = bcrypt.hashSync('golf1234', salt);
@@ -181,25 +223,37 @@ class Database {
} }
// --- User Operations --- // --- 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; 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); const user = this.data.users.find((u) => u.id === id);
if (!user) return undefined; if (!user) return undefined;
const { passwordHash: _, ...safeUser } = user; const { passwordHash: _, ...safeUser } = user;
return safeUser; 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(); const lower = identifier.toLowerCase().trim();
return this.data.users.find( return this.data.users.find(
(u) => u.email.toLowerCase() === lower || u.username.toLowerCase() === lower (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); this.data.users.push(userData);
if (!this.data.settings[userData.id]) { if (!this.data.settings[userData.id]) {
this.data.settings[userData.id] = { ...DEFAULT_TRACKING_SETTINGS }; this.data.settings[userData.id] = { ...DEFAULT_TRACKING_SETTINGS };
@@ -207,12 +261,18 @@ class Database {
if (!this.data.rounds[userData.id]) { if (!this.data.rounds[userData.id]) {
this.data.rounds[userData.id] = []; this.data.rounds[userData.id] = [];
} }
if (!this.data.achievements[userData.id]) {
this.data.achievements[userData.id] = [];
}
this.save(); this.save();
const { passwordHash: _, ...safeUser } = userData; const { passwordHash: _, ...safeUser } = userData;
return safeUser; 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); const index = this.data.users.findIndex((u) => u.id === id);
if (index === -1) return undefined; if (index === -1) return undefined;
@@ -228,22 +288,34 @@ class Database {
} }
// --- Settings Operations --- // --- 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 }; 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.data.settings[userId] = settings;
this.save(); this.save();
return settings; return settings;
} }
// --- Rounds Operations --- // --- 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] || []; 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]) { if (!this.data.rounds[userId]) {
this.data.rounds[userId] = []; this.data.rounds[userId] = [];
} }
@@ -257,7 +329,13 @@ class Database {
return round; 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]) { if (!this.data.rounds[userId]) {
this.data.rounds[userId] = []; this.data.rounds[userId] = [];
} }
@@ -273,7 +351,10 @@ class Database {
return this.data.rounds[userId]; 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; if (!this.data.rounds[userId]) return false;
const initialLen = this.data.rounds[userId].length; const initialLen = this.data.rounds[userId].length;
this.data.rounds[userId] = this.data.rounds[userId].filter((r) => r.id !== roundId); this.data.rounds[userId] = this.data.rounds[userId].filter((r) => r.id !== roundId);
@@ -282,12 +363,38 @@ class Database {
return changed; 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 --- // --- 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] || []; 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]) { if (!this.data.customCourses[userId]) {
this.data.customCourses[userId] = []; this.data.customCourses[userId] = [];
} }
@@ -302,13 +409,14 @@ class Database {
} }
// --- Community & Public Stats Operations --- // --- Community & Public Stats Operations ---
public getPublicGolfers(): PublicGolferStats[] { public async getPublicGolfers(): Promise<PublicGolferStats[]> {
const allUsers = await this.getUsers();
const list: PublicGolferStats[] = [...(this.data.publicSeedGolfers || [])]; const list: PublicGolferStats[] = [...(this.data.publicSeedGolfers || [])];
// Compute public stats for any real registered users with isPublic == true // 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; if (!user.isPublic) continue;
const userRounds = this.data.rounds[user.id] || []; const userRounds = await this.getRounds(user.id);
if (userRounds.length === 0) continue; if (userRounds.length === 0) continue;
let totalGross = 0; let totalGross = 0;
+438
View File
@@ -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();
+25 -20
View File
@@ -7,7 +7,7 @@ import { User, AuthProvider } from '../../types/auth';
const router = Router(); const router = Router();
// --- 1. Local Registration --- // --- 1. Local Registration ---
router.post('/register', (req, res: Response) => { router.post('/register', async (req, res: Response) => {
try { try {
const { email, username, password, name, handicapIndex, homeClub, isPublic } = req.body; 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' }); 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) { if (existingUser) {
return res.status(409).json({ error: 'An account with that email or username already exists' }); 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, passwordHash,
}; };
const savedUser = db.createUser(newUser); const savedUser = await db.createUser(newUser);
const token = generateToken(savedUser); const token = generateToken(savedUser);
const settings = await db.getSettings(savedUser.id);
return res.status(201).json({ return res.status(201).json({
token, token,
user: savedUser, user: savedUser,
settings: db.getSettings(savedUser.id), settings,
}); });
} catch (error) { } catch (error) {
console.error('Registration error:', error); console.error('Registration error:', error);
@@ -56,7 +57,7 @@ router.post('/register', (req, res: Response) => {
}); });
// --- 2. Local Login --- // --- 2. Local Login ---
router.post('/login', (req, res: Response) => { router.post('/login', async (req, res: Response) => {
try { try {
const { identifier, password } = req.body; 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' }); 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) { if (!userWithHash || !userWithHash.passwordHash) {
return res.status(401).json({ error: 'Invalid credentials. Please verify your email and password.' }); 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 { passwordHash: _, ...safeUser } = userWithHash;
const token = generateToken(safeUser); const token = generateToken(safeUser);
const settings = await db.getSettings(safeUser.id);
return res.json({ return res.json({
token, token,
user: safeUser, user: safeUser,
settings: db.getSettings(safeUser.id), settings,
}); });
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
@@ -89,9 +91,9 @@ router.post('/login', (req, res: Response) => {
}); });
// --- 3. Get Current User & Profile --- // --- 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' }); 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({ return res.json({
user: req.user, user: req.user,
settings, settings,
@@ -99,7 +101,7 @@ router.get('/me', authMiddleware, (req: AuthenticatedRequest, res: Response) =>
}); });
// --- 4. Update Profile & Privacy (Public / Private toggle) --- // --- 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' }); if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
const { name, handicapIndex, homeClub, isPublic, avatarUrl } = req.body; 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 isPublic === 'boolean') updates.isPublic = isPublic;
if (typeof avatarUrl === 'string') updates.avatarUrl = avatarUrl; 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 }); return res.json({ user: updatedUser });
}); });
// --- 5. User Settings Sync --- // --- 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' }); 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' }); 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); return res.json(updated);
}); });
@@ -158,22 +161,23 @@ router.get('/oauth/providers', (_req, res: Response) => {
}); });
// --- 7. OAuth / SSO Login (Google, Authentik, GitHub, Demo SSO) --- // --- 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 { try {
const { provider, email, name, username, avatarUrl, authentikServerUrl } = req.body; const { provider, email, name, username, avatarUrl, authentikServerUrl } = req.body;
if (!provider) { 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; const providerType = provider as AuthProvider;
// Generate reliable email & username if from SSO // Generate reliable email & username if from SSO
const ssoEmail = email?.toLowerCase().trim() || `${provider}_golfer_${Date.now().toString(36)}@example.com`; 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 ssoUsername = username?.toLowerCase().trim() || `${provider}_${Math.random().toString(36).substring(2, 7)}`;
const ssoName = name?.trim() || `${provider.charAt(0).toUpperCase() + provider.slice(1)} Golfer`; const ssoName = name?.trim() || `${provider.charAt(0).toUpperCase() + provider.slice(1)} Golfer`;
// Check if user already exists // Check if user already exists
let user = db.findUserByEmailOrUsername(ssoEmail); let user = await db.findUserByEmailOrUsername(ssoEmail);
if (!user) { if (!user) {
// Create new user connected via SSO // Create new user connected via SSO
@@ -192,16 +196,17 @@ router.post('/oauth/sso-login', (req, res: Response) => {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
user = db.createUser(newUser); user = await db.createUser(newUser);
} }
const { passwordHash: _, ...safeUser } = user; const { passwordHash: _, ...safeUser } = user;
const token = generateToken(safeUser); const token = generateToken(safeUser);
const settings = await db.getSettings(safeUser.id);
return res.json({ return res.json({
token, token,
user: safeUser, user: safeUser,
settings: db.getSettings(safeUser.id), settings,
}); });
} catch (error) { } catch (error) {
console.error('SSO Login error:', error); console.error('SSO Login error:', error);
+4 -4
View File
@@ -6,9 +6,9 @@ import { PublicGolferStats, ComparisonMetric, CommunityComparisonData } from '..
const router = Router(); const router = Router();
// --- 1. Get Public Golfers Leaderboard --- // --- 1. Get Public Golfers Leaderboard ---
router.get('/leaderboard', (_req, res: Response) => { router.get('/leaderboard', async (_req, res: Response) => {
try { try {
const golfers = db.getPublicGolfers(); const golfers = await db.getPublicGolfers();
// Sort by scoring average ascending (lowest score first) // Sort by scoring average ascending (lowest score first)
golfers.sort((a, b) => a.scoringAverage - b.scoringAverage); 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 --- // --- 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 { try {
const publicGolfers = db.getPublicGolfers(); const publicGolfers = await db.getPublicGolfers();
if (publicGolfers.length === 0) { if (publicGolfers.length === 0) {
return res.status(404).json({ error: 'No public statistics available' }); return res.status(404).json({ error: 'No public statistics available' });
+122 -22
View File
@@ -2,18 +2,26 @@ import { Router, Response } from 'express';
import { db } from '../db'; import { db } from '../db';
import { authMiddleware, AuthenticatedRequest } from '../authMiddleware'; import { authMiddleware, AuthenticatedRequest } from '../authMiddleware';
import { Round, Course } from '../../types/golf'; import { Round, Course } from '../../types/golf';
import { DEFAULT_COURSES } from '../../data/courses';
import { evaluateAchievements } from '../../utils/achievements';
import { mariadb } from '../mariadb';
const router = Router(); const router = Router();
// --- Database Status Endpoint ---
router.get('/db-status', (_req, res: Response) => {
res.json(mariadb.getStatus());
});
// --- Get All Rounds for User --- // --- 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' }); 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); return res.json(rounds);
}); });
// --- Save / Update Single Round --- // --- Save / Finalize Round ---
router.post('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => { router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
const round: Round = req.body; 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' }); return res.status(400).json({ error: 'Invalid round payload' });
} }
const saved = db.saveRound(req.user.id, round); // Ensure isCompleted is explicitly set when finalizing
return res.json(saved); 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) --- // --- 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' }); if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
const { rounds } = req.body; 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' }); return res.status(400).json({ error: 'Expected an array of rounds' });
} }
const updatedRounds = db.syncRoundsBatch(req.user.id, rounds); const updatedRounds = await db.syncRoundsBatch(req.user.id, rounds);
return res.json({ count: updatedRounds.length, rounds: updatedRounds });
});
// --- Delete Round --- // Recalculate achievements
router.delete('/:id', authMiddleware, (req: AuthenticatedRequest, res: Response) => { const customCourses = await db.getCustomCourses(req.user.id);
if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); const allCourses = [...DEFAULT_COURSES, ...customCourses];
const success = db.deleteRound(req.user.id, req.params.id); const evaluated = evaluateAchievements(updatedRounds, allCourses, []);
if (!success) { const validUnlocked = evaluated.unlockedList
return res.status(404).json({ error: 'Round not found' }); .filter((a) => a.isUnlocked)
} .map((a) => ({
return res.json({ success: true }); 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 --- // --- 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' }); 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' }); if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
const course: Course = req.body; const course: Course = req.body;
if (!course || !course.id || !course.name) { if (!course || !course.id || !course.name) {
return res.status(400).json({ error: 'Invalid course payload' }); 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); return res.json(saved);
}); });
+33 -6
View File
@@ -121,30 +121,56 @@ export const authApi = {
}; };
export const roundsApi = { 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[]> { async getRounds(): Promise<Round[]> {
return request<Round[]>('/api/rounds'); return request<Round[]>('/api/rounds');
}, },
async saveRound(round: Round): Promise<Round> { async saveRound(round: Round): Promise<{ round: Round; achievements?: any[]; newlyUnlocked?: any[] }> {
return request<Round>('/api/rounds', { return request<{ round: Round; achievements?: any[]; newlyUnlocked?: any[] }>('/api/rounds', {
method: 'POST', method: 'POST',
body: JSON.stringify(round), body: JSON.stringify(round),
}); });
}, },
async syncBatch(rounds: Round[]): Promise<{ count: number; rounds: Round[] }> { async syncBatch(rounds: Round[]): Promise<{ count: number; rounds: Round[]; achievements?: any[] }> {
return request<{ count: number; rounds: Round[] }>('/api/rounds/sync-batch', { return request<{ count: number; rounds: Round[]; achievements?: any[] }>('/api/rounds/sync-batch', {
method: 'POST', method: 'POST',
body: JSON.stringify({ rounds }), body: JSON.stringify({ rounds }),
}); });
}, },
async deleteRound(id: string): Promise<{ success: boolean }> { async deleteRound(id: string): Promise<{
return request<{ success: boolean }>(`/api/rounds/${id}`, { success: boolean;
remainingRoundsCount?: number;
achievements?: any[];
recalculatedCount?: number;
}> {
return request<{
success: boolean;
remainingRoundsCount?: number;
achievements?: any[];
recalculatedCount?: number;
}>(`/api/rounds/${id}`, {
method: 'DELETE', 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[]> { async getCustomCourses(): Promise<Course[]> {
return request<Course[]>('/api/rounds/courses/custom'); return request<Course[]>('/api/rounds/courses/custom');
}, },
@@ -166,3 +192,4 @@ export const communityApi = {
return request<CommunityComparisonData>('/api/community/compare'); return request<CommunityComparisonData>('/api/community/compare');
}, },
}; };
+2
View File
@@ -135,11 +135,13 @@ export interface Round {
courseId: string; courseId: string;
courseName: string; courseName: string;
teeName: string; teeName: string;
teeId?: string;
coursePar: number; coursePar: number;
holesPlayed: 18 | 9; holesPlayed: 18 | 9;
nineType?: 'all' | 'front' | 'back'; nineType?: 'all' | 'front' | 'back';
scores: Record<number, HoleScore>; scores: Record<number, HoleScore>;
isCompleted: boolean; isCompleted: boolean;
completedAt?: string;
settingsSnapshot: TrackingSettings; settingsSnapshot: TrackingSettings;
playerHandicap: number; playerHandicap: number;
weather?: string; weather?: string;
+58 -1
View File
@@ -1,4 +1,4 @@
import { Achievement, Course, Round } from '../types/golf'; import { Achievement, Course, Round, HoleScore } from '../types/golf';
import { calculateRoundStats } from './golfStats'; import { calculateRoundStats } from './golfStats';
export const ALL_ACHIEVEMENTS: Achievement[] = [ export const ALL_ACHIEVEMENTS: Achievement[] = [
@@ -212,6 +212,9 @@ export function evaluateAchievements(
const unlockedMap = new Map<string, { roundName: string; date: string }>(); const unlockedMap = new Map<string, { roundName: string; date: string }>();
rounds.forEach((round) => { 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]; const course = courses.find((c) => c.id === round.courseId) || courses[0];
if (!course) return; if (!course) return;
@@ -438,3 +441,57 @@ export function evaluateAchievements(
totalUnlockedCount: unlockedMap.size, 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);
}