diff --git a/.aistudio/artifacts/brain/112d9074-1908-4fcf-a1fe-b34e5135ef1a/implementation_plan.md b/.aistudio/artifacts/brain/112d9074-1908-4fcf-a1fe-b34e5135ef1a/implementation_plan.md new file mode 100644 index 0000000..aff3bf1 --- /dev/null +++ b/.aistudio/artifacts/brain/112d9074-1908-4fcf-a1fe-b34e5135ef1a/implementation_plan.md @@ -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. diff --git a/.aistudio/artifacts/brain/112d9074-1908-4fcf-a1fe-b34e5135ef1a/implementation_plan.md.metadata.json b/.aistudio/artifacts/brain/112d9074-1908-4fcf-a1fe-b34e5135ef1a/implementation_plan.md.metadata.json new file mode 100644 index 0000000..7ea1f6b --- /dev/null +++ b/.aistudio/artifacts/brain/112d9074-1908-4fcf-a1fe-b34e5135ef1a/implementation_plan.md.metadata.json @@ -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 +} \ No newline at end of file diff --git a/.env.example b/.env.example index 64d9df3..0608c37 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,19 @@ AUTHENTIK_CLIENT_SECRET="" # GitHub OAuth Credentials GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" + +# ======================================================== +# MariaDB Galera Cluster Configuration (Production Database) +# ======================================================== +# If configured, Pinpoint Golf connects directly to your MariaDB Galera cluster +# for multi-master high-availability storage of users, rounds, and achievements. +# If left empty, the server automatically uses local persistent storage. +MARIADB_HOST="" +MARIADB_PORT="3306" +MARIADB_USER="" +MARIADB_PASSWORD="" +MARIADB_DATABASE="pinpoint_golf" +MARIADB_SSL="false" +# Optional connection pool size for Galera multi-master concurrency +MARIADB_CONNECTION_LIMIT="10" + diff --git a/.gitignore b/.gitignore index ee12b2b..bbb0836 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ coverage/ *.log .env* !.env.example -.aider* +.aider.chat.history.md +.aider.input.history +.aider.tags.cache.v4/ diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 4aeb4fe..e74c0b9 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -219,3 +219,30 @@ npm run build # Clean temporary files npm run clean ``` + +--- + +## 9. Clean Deployment & Data Integrity Rules + +To guarantee enterprise-grade data hygiene and tour-level integrity, PinPoint Golf enforces strict separation between development testing and production deployment: + +### 1. Mock Data Elimination in Production +- **Server Store**: In production (`NODE_ENV=production` or `ENABLE_MOCK_DATA!=true`), the database manager `src/server/db.ts` suppresses demo accounts (`usr_demo_golfer`) and seeded community golfers (`user_seed_*`). Only real registered users and scorecards exist. +- **Client App**: The client initializes rounds state to an empty list `[]` instead of pre-seeding `SAMPLE_ROUND`. The "Load Dev Sample Round" button is stripped in production builds (`import.meta.env.PROD`). + +### 2. Authentication-Gated Features +The following features are exclusively unlocked for registered golfers: +- **Community Benchmarks & Leaderboards**: Public comparison of handicap, FIR%, GIR%, putts, and scrambles against peers. +- **Career Achievements & Trophies**: 22 tier-ranked achievements across scoring, accuracy, and short game. +- **Satellite GPS Rangefinder**: Real-time Google Maps satellite flyover, Front/Center/Back green distance calculation, pin targeting, and shot distance measurement. +- *Guests & Unauthenticated Users*: Can start rounds, view course scorecards, and check local play, but receive clear in-app prompts to sign in or create a free account to unlock GPS, community leaderboards, and trophies. + +### 3. Achievement Lifecycle Rules +- **Empty Initial State**: New registered users start with zero achievements unlocked (`unlockedIds: []`). +- **Finalization Requirement**: Achievements are never awarded during live play; they are evaluated and awarded **strictly upon finishing and saving a completed round** (`isCompleted: true`). + +### 4. Round Immutability & Deletion Workflow +- **Confirmation Modal**: Clicking "Finalize & Save Round" triggers `SaveRoundModal`, showing a complete scoring summary and an explicit warning that the round will be permanently locked. +- **Post-Save Immutability**: Once saved (`isCompleted: true`), hole scores and statistical inputs are locked against modification. +- **Deletion Only**: The only permitted post-save action on a finalized round is deletion from the golfer's history with confirmation. + diff --git a/Dockerfile b/Dockerfile index 6674d15..f615333 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,8 @@ FROM node:22-alpine AS builder WORKDIR /app # Install dependencies first (layer caching) -COPY package.json bun.lock ./ -RUN npm install -g bun -RUN bun install +COPY package.json ./ +RUN npm install # Copy application source code COPY . . @@ -18,7 +17,7 @@ ARG VITE_GOOGLE_MAPS_API_KEY="" ENV VITE_GOOGLE_MAPS_API_KEY=$VITE_GOOGLE_MAPS_API_KEY # Build production bundle (Vite client + server.cjs) -RUN bun run build +RUN npm run build # ======================================================== # Stage 2: Serve full-stack application (API + SPA) with Node.js @@ -30,9 +29,8 @@ ENV NODE_ENV=production ENV PORT=3000 # Install production dependencies only -COPY package.json bun.lock ./ -RUN npm install -g bun -RUN bun install --production +COPY package.json ./ +RUN npm install --omit=dev && npm cache clean --force # Copy built bundle from builder stage COPY --from=builder /app/dist ./dist diff --git a/bun.lock b/bun.lock index 344861b..1141c59 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "jsonwebtoken": "^9.0.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "mysql2": "^3.24.6", "react": "^19.0.1", "react-dom": "^19.0.1", "vite": "^8.3.0", @@ -271,6 +272,8 @@ "autoprefixer": ["autoprefixer@10.6.1", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="], @@ -371,6 +374,8 @@ "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], @@ -391,12 +396,14 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], @@ -447,6 +454,8 @@ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru.min": ["lru.min@1.1.5", "", {}, "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA=="], + "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -473,6 +482,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mysql2": ["mysql2@3.24.6", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -543,6 +556,8 @@ "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + "sql-escaper": ["sql-escaper@1.5.2", "", {}, "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -605,12 +620,16 @@ "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "protobufjs/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], diff --git a/data/pinpoint_db.json b/data/pinpoint_db.json index e55082b..137a0d6 100644 --- a/data/pinpoint_db.json +++ b/data/pinpoint_db.json @@ -1,169 +1,7 @@ { - "users": [ - { - "id": "usr_demo_golfer", - "email": "demo@pinpointgolf.com", - "username": "demogolfer", - "name": "Demo Golfer", - "avatarUrl": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80", - "handicapIndex": 12.4, - "homeClub": "Pebble Cove Links", - "isPublic": true, - "provider": "local", - "createdAt": "2026-10-10T01:33:30.863Z", - "updatedAt": "2026-10-10T01:33:30.863Z", - "passwordHash": "$2b$10$dazakuysQGoPaG27E2OWweafH.l/GJrb3XGsCiBbRVgf2WdNfnwnO" - }, - { - "id": "usr_1791596044846_0e8u8", - "email": "test@golf.com", - "username": "testplayer", - "name": "Test Player", - "handicapIndex": 9.5, - "homeClub": "Local Links Club", - "isPublic": true, - "provider": "local", - "createdAt": "2026-10-10T01:34:04.846Z", - "updatedAt": "2026-10-10T01:34:04.846Z", - "passwordHash": "$2b$10$5UujhxPRuprn7xtAkOAxKeqfxLaIOIcTwEXtnFQnmVYy6wYVeIvCK" - } - ], - "settings": { - "usr_demo_golfer": { - "trackFIR": true, - "trackFIRDirection": true, - "trackGIR": true, - "trackGIRDirection": true, - "trackPutts": true, - "trackForegreen": true, - "trackFirstPuttDistance": false, - "trackPenalties": true, - "trackPenaltyTypes": true, - "trackBunkers": true, - "trackBunkerTypes": true, - "trackSandSaves": true, - "trackUpAndDown": true, - "trackDriveClub": true, - "trackDriveDistance": false, - "trackHoleNotes": true, - "distanceUnit": "meters", - "puttDistanceUnit": "meters" - }, - "usr_1791596044846_0e8u8": { - "trackFIR": true, - "trackFIRDirection": true, - "trackGIR": true, - "trackGIRDirection": true, - "trackPutts": true, - "trackForegreen": true, - "trackFirstPuttDistance": false, - "trackPenalties": true, - "trackPenaltyTypes": true, - "trackBunkers": true, - "trackBunkerTypes": true, - "trackSandSaves": true, - "trackUpAndDown": true, - "trackDriveClub": true, - "trackDriveDistance": false, - "trackHoleNotes": true, - "distanceUnit": "meters", - "puttDistanceUnit": "meters" - } - }, - "rounds": { - "usr_1791596044846_0e8u8": [] - }, + "users": [], + "settings": {}, + "rounds": {}, "customCourses": {}, - "publicSeedGolfers": [ - { - "id": "user_seed_viktor", - "username": "viktor_h", - "name": "Viktor H.", - "avatarUrl": "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=120&h=120&q=80", - "handicapIndex": 0.4, - "homeClub": "Oslo Golfklubb", - "roundsCount": 24, - "scoringAverage": 71.2, - "bestGross": 67, - "firPercentage": 74.5, - "girPercentage": 76.2, - "puttsPerRound": 28.4, - "scramblePercentage": 68, - "sandSavePercentage": 62.5, - "penaltiesPerRound": 0.3, - "isPublic": true - }, - { - "id": "user_seed_marcus", - "username": "marcus_t", - "name": "Marcus Thorne", - "avatarUrl": "https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?auto=format&fit=crop&w=120&h=120&q=80", - "handicapIndex": 5.8, - "homeClub": "Pine Valley GC", - "roundsCount": 18, - "scoringAverage": 76.8, - "bestGross": 73, - "firPercentage": 66, - "girPercentage": 62.5, - "puttsPerRound": 30.1, - "scramblePercentage": 54, - "sandSavePercentage": 47, - "penaltiesPerRound": 0.8, - "isPublic": true - }, - { - "id": "user_seed_sarah", - "username": "sarah_m", - "name": "Sarah Miller", - "avatarUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80", - "handicapIndex": 11.2, - "homeClub": "St Andrews Links Club", - "roundsCount": 15, - "scoringAverage": 82.4, - "bestGross": 78, - "firPercentage": 58.2, - "girPercentage": 51, - "puttsPerRound": 31.6, - "scramblePercentage": 42, - "sandSavePercentage": 36, - "penaltiesPerRound": 1.4, - "isPublic": true - }, - { - "id": "user_seed_dave", - "username": "dave_k", - "name": "Dave Kowalski", - "avatarUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80", - "handicapIndex": 18.6, - "homeClub": "Bandon Dunes Golfers", - "roundsCount": 12, - "scoringAverage": 91.5, - "bestGross": 86, - "firPercentage": 44, - "girPercentage": 32.5, - "puttsPerRound": 34.2, - "scramblePercentage": 28, - "sandSavePercentage": 21, - "penaltiesPerRound": 2.6, - "isPublic": true - }, - { - "id": "user_seed_elena", - "username": "elena_rostova", - "name": "Elena Rostova", - "avatarUrl": "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=120&h=120&q=80", - "handicapIndex": 8.9, - "homeClub": "Torrey Pines South", - "roundsCount": 20, - "scoringAverage": 79.8, - "bestGross": 75, - "firPercentage": 62.4, - "girPercentage": 57.8, - "puttsPerRound": 31, - "scramblePercentage": 48, - "sandSavePercentage": 40, - "penaltiesPerRound": 1.1, - "isPublic": true - } - ] + "publicSeedGolfers": [] } \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index caa9eca..8a0bbcd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,3 +1,5 @@ +version: '3.8' + services: pinpoint-golf: build: @@ -10,7 +12,7 @@ services: restart: unless-stopped ports: # Map host port 3000 to app port 3000 - - "127.0.0.1:3000:3000" + - "3000:3000" environment: - NODE_ENV=production - PORT=3000 diff --git a/package.json b/package.json index 5b61d00..50978a5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx server.ts", "start": "tsx server.ts", - "build": "vite build && esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist/server.cjs --external:express --external:bcryptjs --external:jsonwebtoken --external:vite", + "build": "vite build && esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist/server.cjs --external:express --external:bcryptjs --external:jsonwebtoken --external:vite --external:mysql2", "preview": "vite preview", "clean": "rm -rf dist server.js data/pinpoint_db.json.tmp", "lint": "tsc --noEmit" @@ -22,6 +22,7 @@ "jsonwebtoken": "^9.0.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "mysql2": "^3.24.6", "react": "^19.0.1", "react-dom": "^19.0.1", "vite": "^8.3.0" diff --git a/server.ts b/server.ts index d57ae83..991d77f 100644 --- a/server.ts +++ b/server.ts @@ -4,6 +4,7 @@ import dotenv from 'dotenv'; import authRoutes from './src/server/routes/authRoutes'; import roundsRoutes from './src/server/routes/roundsRoutes'; import communityRoutes from './src/server/routes/communityRoutes'; +import { db } from './src/server/db'; dotenv.config(); @@ -26,6 +27,8 @@ app.use('/api/rounds', roundsRoutes); app.use('/api/community', communityRoutes); async function startServer() { + // Initialize persistence backend (MariaDB Galera cluster if configured, or local store) + await db.init(); if (!isProduction) { // Development mode: Vite middleware const { createServer: createViteServer } = await import('vite'); diff --git a/src/App.tsx b/src/App.tsx index b001a3b..f1329d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,14 +10,24 @@ import { Settings } from './components/Settings'; import { NewRoundModal } from './components/NewRoundModal'; import { RoundsHistoryModal } from './components/RoundsHistoryModal'; import { AchievementsModal } from './components/AchievementsModal'; +import { SaveRoundModal } from './components/SaveRoundModal'; import { CourseEditorModal } from './components/CourseEditorModal'; import { AuthModal } from './components/AuthModal'; import { CommunityComparisonModal } from './components/CommunityComparisonModal'; import { calculateRoundStats } from './utils/golfStats'; -import { evaluateAchievements } from './utils/achievements'; +import { ALL_ACHIEVEMENTS, evaluateAchievements } from './utils/achievements'; import { AuthProviderComponent, useAuth } from './context/AuthContext'; import { roundsApi, authApi } from './services/api'; -import { Trophy, X, Sparkles, CloudCheck, Cloud } from 'lucide-react'; +import { + Trophy, + X, + Sparkles, + Flag, + Plus, + Lock, + Navigation, + Award, +} from 'lucide-react'; const STORAGE_KEY_ROUNDS = 'pinpoint_golf_rounds_v1'; const STORAGE_KEY_SETTINGS = 'pinpoint_golf_settings_v1'; @@ -60,28 +70,28 @@ function AppContent() { return DEFAULT_COURSES; }); - // 3. Rounds state (or seed with SAMPLE_ROUND) + // 3. Rounds state (Clean by default - no mock rounds when deployed) const [rounds, setRounds] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY_ROUNDS); if (saved) { const parsed = JSON.parse(saved); - if (Array.isArray(parsed) && parsed.length > 0) return parsed; + if (Array.isArray(parsed)) return parsed; } } catch { // fallback } - return [SAMPLE_ROUND]; + return []; }); const [currentRoundId, setCurrentRoundId] = useState(() => { - return rounds[0]?.id || SAMPLE_ROUND.id; + return rounds[0]?.id || ''; }); const [currentHoleNumber, setCurrentHoleNumber] = useState(1); const [activeTab, setActiveTab] = useState('hole'); - // 4. Achievements state + // 4. Achievements state (Starts empty for new users, only awarded upon saving completed rounds) const [unlockedIds, setUnlockedIds] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS); @@ -92,22 +102,29 @@ function AppContent() { return []; }); - const [achievements, setAchievements] = useState([]); + const [achievements, setAchievements] = useState(() => { + return ALL_ACHIEVEMENTS.map((a) => ({ ...a, isUnlocked: false })); + }); const [newlyUnlockedToast, setNewlyUnlockedToast] = useState(null); - const isInitialMount = useRef(true); // Modals state const [isNewRoundModalOpen, setIsNewRoundModalOpen] = useState(false); const [isRoundsHistoryModalOpen, setIsRoundsHistoryModalOpen] = useState(false); const [isAchievementsModalOpen, setIsAchievementsModalOpen] = useState(false); + const [isSaveRoundModalOpen, setIsSaveRoundModalOpen] = useState(false); const [isCourseEditorModalOpen, setIsCourseEditorModalOpen] = useState(false); const [courseEditorBaseCourse, setCourseEditorBaseCourse] = useState(null); const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); const [isCommunityModalOpen, setIsCommunityModalOpen] = useState(false); - // Sync state with cloud when user logs in + // Sync state with cloud when user logs in or out useEffect(() => { - if (!user) return; + if (!user) { + // Guest users: achievements are locked/empty + setAchievements(ALL_ACHIEVEMENTS.map((a) => ({ ...a, isUnlocked: false }))); + setUnlockedIds([]); + return; + } const loadCloudData = async () => { try { @@ -121,17 +138,20 @@ function AppContent() { setSettings(cloudSettings); } + let combinedRounds = [...rounds]; if (cloudRounds && cloudRounds.length > 0) { // Merge cloud rounds with local rounds without duplicates - setRounds((prev) => { - const merged = [...cloudRounds]; - for (const r of prev) { - if (!merged.some((m) => m.id === r.id)) { - merged.push(r); - } + const merged = [...cloudRounds]; + for (const r of rounds) { + if (!merged.some((m) => m.id === r.id)) { + merged.push(r); } - return merged; - }); + } + combinedRounds = merged; + setRounds(merged); + if (!currentRoundId || !merged.some((r) => r.id === currentRoundId)) { + setCurrentRoundId(merged[0]?.id || ''); + } } if (cloudCourses && cloudCourses.length > 0) { @@ -145,6 +165,13 @@ function AppContent() { return combined; }); } + + // Only evaluate achievements for COMPLETED, saved rounds + const completedRounds = combinedRounds.filter((r) => r.isCompleted); + const evalResult = evaluateAchievements(completedRounds, courses, unlockedIds); + setAchievements(evalResult.unlockedList); + const userUnlocked = evalResult.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id); + setUnlockedIds(userUnlocked); } catch (err) { console.error('Error syncing cloud data:', err); } @@ -179,38 +206,21 @@ function AppContent() { } }, [courses]); - // Evaluate achievements whenever rounds or courses change - useEffect(() => { - const evaluation = evaluateAchievements(rounds, courses, unlockedIds); - setAchievements(evaluation.unlockedList); + // Current active round object (can be null if user has no rounds started) + const currentRound: Round | null = + rounds.find((r) => r.id === currentRoundId) || rounds[0] || null; - const currentUnlockedIds = evaluation.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id); - if (JSON.stringify(currentUnlockedIds) !== JSON.stringify(unlockedIds)) { - setUnlockedIds(currentUnlockedIds); - try { - localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(currentUnlockedIds)); - } catch { - // fallback - } - } + const currentCourse: Course = currentRound + ? courses.find((c) => c.id === currentRound.courseId) || + DEFAULT_COURSES.find((c) => c.id === currentRound.courseId) || + courses[0] || + DEFAULT_COURSES[0] + : courses[0] || DEFAULT_COURSES[0]; - if (!isInitialMount.current && evaluation.newlyUnlocked.length > 0) { - setNewlyUnlockedToast(evaluation.newlyUnlocked[0]); - } - - isInitialMount.current = false; - }, [rounds, courses]); - - // Current active round object - const currentRound = rounds.find((r) => r.id === currentRoundId) || rounds[0] || SAMPLE_ROUND; - const currentCourse: Course = - courses.find((c) => c.id === currentRound.courseId) || - DEFAULT_COURSES.find((c) => c.id === currentRound.courseId) || - courses[0] || - DEFAULT_COURSES[0]; - - // Update a specific hole's score + // Update a specific hole's score (only permitted while round is not completed) const handleUpdateHoleScore = (holeNumber: number, updated: HoleScore) => { + if (!currentRound || currentRound.isCompleted) return; + setRounds((prevRounds) => { const updatedRounds = prevRounds.map((r) => { if (r.id !== currentRound.id) return r; @@ -222,7 +232,7 @@ function AppContent() { }, }; - // If user is authenticated, save directly to cloud database + // If user is authenticated, sync live update to cloud if (user) { roundsApi.saveRound(modified).catch((e) => console.warn('Cloud save error:', e)); } @@ -244,20 +254,98 @@ function AppContent() { } }; - const handleDeleteRound = (roundId: string) => { - if (rounds.length <= 1) return; + // Finalize and permanently lock a round with confirmation + const handleOpenSaveRoundModal = () => { + setIsSaveRoundModalOpen(true); + }; + + const handleConfirmSaveRound = async () => { + if (!currentRound) return; + + const finalizedRound: Round = { + ...currentRound, + isCompleted: true, + completedAt: new Date().toISOString(), + }; + + const nextRounds = rounds.map((r) => (r.id === finalizedRound.id ? finalizedRound : r)); + setRounds(nextRounds); + + if (user) { + try { + const res = await roundsApi.saveRound(finalizedRound); + if (res.achievements) { + setAchievements(res.achievements); + const nextUnlocked = res.achievements.filter((a: any) => a.isUnlocked).map((a: any) => a.id); + setUnlockedIds(nextUnlocked); + try { + localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(nextUnlocked)); + } catch { + // fallback + } + } + if (res.newlyUnlocked && res.newlyUnlocked.length > 0) { + setNewlyUnlockedToast(res.newlyUnlocked[0]); + } + } catch (e) { + console.warn('Cloud save error:', e); + // Fallback local evaluation + const completedRounds = nextRounds.filter((r) => r.isCompleted); + const evalResult = evaluateAchievements(completedRounds, courses, unlockedIds); + setAchievements(evalResult.unlockedList); + const nextUnlockedIds = evalResult.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id); + setUnlockedIds(nextUnlockedIds); + if (evalResult.newlyUnlocked.length > 0) { + setNewlyUnlockedToast(evalResult.newlyUnlocked[0]); + } + } + } + + setActiveTab('scorecard'); + }; + + // Delete round: recalculates achievements dynamically from remaining saved rounds + const handleDeleteRound = async (roundId: string) => { const remaining = rounds.filter((r) => r.id !== roundId); setRounds(remaining); if (currentRoundId === roundId) { - setCurrentRoundId(remaining[0].id); + setCurrentRoundId(remaining[0]?.id || ''); } if (user) { - roundsApi.deleteRound(roundId).catch((e) => console.warn('Cloud delete error:', e)); + try { + const res = await roundsApi.deleteRound(roundId); + if (res.achievements) { + setAchievements(res.achievements); + const remainingUnlocked = res.achievements.filter((a: any) => a.isUnlocked).map((a: any) => a.id); + setUnlockedIds(remainingUnlocked); + try { + localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(remainingUnlocked)); + } catch { + // fallback + } + } + } catch (e) { + console.warn('Cloud delete error:', e); + } + } else { + // Re-evaluate achievements locally for remaining completed rounds + const remainingCompleted = remaining.filter((r) => r.isCompleted); + const evalResult = evaluateAchievements(remainingCompleted, courses, []); + setAchievements(evalResult.unlockedList); + const remainingUnlocked = evalResult.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id); + setUnlockedIds(remainingUnlocked); + try { + localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(remainingUnlocked)); + } catch { + // fallback + } } }; const handleLoadSampleRound = () => { + // Only available in development mode + if (import.meta.env.PROD) return; const exists = rounds.some((r) => r.id === SAMPLE_ROUND.id); if (!exists) { setRounds((prev) => [SAMPLE_ROUND, ...prev]); @@ -268,6 +356,7 @@ function AppContent() { }; const handleEditHoleFromScorecard = (holeNumber: number) => { + if (currentRound?.isCompleted) return; // Locked: no edits permitted on saved rounds setCurrentHoleNumber(holeNumber); setActiveTab('hole'); }; @@ -323,8 +412,10 @@ function AppContent() { }; // Quick summary stats for the top bar - const summary = calculateRoundStats(currentRound, currentCourse); - const unlockedAchievementsCount = achievements.filter((a) => a.isUnlocked).length; + const summary = currentRound + ? calculateRoundStats(currentRound, currentCourse) + : { totalGross: 0, toPar: 0, holesPlayedCount: 0 }; + const unlockedAchievementsCount = user ? achievements.filter((a) => a.isUnlocked).length : 0; return (
@@ -386,40 +477,115 @@ function AppContent() { {/* Main App Content Viewport */}
- {activeTab === 'hole' && ( - setActiveTab('scorecard')} - /> - )} + {!currentRound ? ( +
+
+ +
- {activeTab === 'scorecard' && ( - - )} +
+

+ Ready to Tee Off? +

+

+ 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! +

+
- {activeTab === 'analytics' && ( - - )} +
+ - {activeTab === 'settings' && ( - + {!user && ( + + )} +
+ + {/* Feature Highlights Grid */} +
+
+
+ + GPS Flyover +
+

Satellite maps & green distances for signed-up golfers

+
+
+
+ + Live Weather +
+

Real-time wind, temperature & rain forecast via Open-Meteo

+
+
+
+ + Locked Cards +
+

Permanent saved records. Saved cards cannot be edited

+
+
+
+ + 22 Trophies +
+

Earned exclusively upon finalizing and saving completed rounds

+
+
+
+ ) : ( + <> + {activeTab === 'hole' && ( + setActiveTab('scorecard')} + isCompleted={currentRound.isCompleted} + onOpenSaveModal={handleOpenSaveRoundModal} + onOpenAuth={() => setIsAuthModalOpen(true)} + /> + )} + + {activeTab === 'scorecard' && ( + + )} + + {activeTab === 'analytics' && ( + + )} + + {activeTab === 'settings' && ( + + )} + )}
@@ -441,16 +607,38 @@ function AppContent() { onClose={() => setIsRoundsHistoryModalOpen(false)} rounds={rounds} currentRoundId={currentRoundId} - onSelectRound={(id) => setCurrentRoundId(id)} + onSelectRound={(id) => { + setCurrentRoundId(id); + const sel = rounds.find((r) => r.id === id); + if (sel?.isCompleted) { + setActiveTab('scorecard'); + } else { + setActiveTab('hole'); + } + }} onDeleteRound={handleDeleteRound} onLoadSampleRound={handleLoadSampleRound} /> + {/* Finalize & Save Round Confirmation Modal */} + {currentRound && ( + setIsSaveRoundModalOpen(false)} + onConfirmSave={handleConfirmSaveRound} + round={currentRound} + course={currentCourse} + isAuthenticated={!!user} + onOpenAuth={() => setIsAuthModalOpen(true)} + /> + )} + {/* Achievements / Trophies Showcase Modal */} setIsAchievementsModalOpen(false)} achievements={achievements} + onOpenAuth={() => setIsAuthModalOpen(true)} /> {/* Custom Course Builder & Modifier Modal */} diff --git a/src/components/AchievementsModal.tsx b/src/components/AchievementsModal.tsx index 6bf9507..cd68ad8 100644 --- a/src/components/AchievementsModal.tsx +++ b/src/components/AchievementsModal.tsx @@ -22,18 +22,22 @@ import { Flag, } from 'lucide-react'; import { Achievement, AchievementTier } from '../types/golf'; +import { useAuth } from '../context/AuthContext'; interface AchievementsModalProps { isOpen: boolean; onClose: () => void; achievements: Achievement[]; + onOpenAuth?: () => void; } export const AchievementsModal: React.FC = ({ isOpen, onClose, achievements, + onOpenAuth, }) => { + const { user } = useAuth(); const [tierFilter, setTierFilter] = useState<'all' | AchievementTier>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all'); @@ -150,53 +154,90 @@ export const AchievementsModal: React.FC = ({
- {/* Global Progress Strip */} -
-
- Career Trophies Unlocked: -
- - {unlockedCount} / {totalCount} - - ({percentage}%) + {/* Authenticated Check */} + {!user ? ( +
+
+
+
+

Registered Golfer Feature

+

+ 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! +

+
+ {onOpenAuth && ( + + )}
+ ) : ( + <> + {/* Global Progress Strip */} +
+
+ Career Trophies Unlocked: +
+ + {unlockedCount} / {totalCount} + + ({percentage}%) +
+
-
-
-
+
+
+
- {/* Tier Counts Pill Bar */} -
-
- Bronze - - {tierCounts.bronze.unlocked} / {tierCounts.bronze.total} - + {unlockedCount === 0 && ( +
+ + + New Golfer: 0 trophies unlocked. Finish and save a round to earn your first achievements! + +
+ )} + + {/* Tier Counts Pill Bar */} +
+
+ Bronze + + {tierCounts.bronze.unlocked} / {tierCounts.bronze.total} + +
+
+ Silver + + {tierCounts.silver.unlocked} / {tierCounts.silver.total} + +
+
+ Gold + + {tierCounts.gold.unlocked} / {tierCounts.gold.total} + +
+
+ Platinum + + {tierCounts.platinum.unlocked} / {tierCounts.platinum.total} + +
+
-
- Silver - - {tierCounts.silver.unlocked} / {tierCounts.silver.total} - -
-
- Gold - - {tierCounts.gold.unlocked} / {tierCounts.gold.total} - -
-
- Platinum - - {tierCounts.platinum.unlocked} / {tierCounts.platinum.total} - -
-
-
{/* Filters */}
@@ -312,6 +353,8 @@ export const AchievementsModal: React.FC = ({ ); })}
+ + )} {/* Footer */}
diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 436bf9c..989bbae 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -282,16 +282,18 @@ export const AuthModal: React.FC = ({ {submitting ? 'Authenticating...' : 'Sign In to Account'} -
- -
+ {!import.meta.env.PROD && ( +
+ +
+ )} )} diff --git a/src/components/CommunityComparisonModal.tsx b/src/components/CommunityComparisonModal.tsx index dad6f2e..2440881 100644 --- a/src/components/CommunityComparisonModal.tsx +++ b/src/components/CommunityComparisonModal.tsx @@ -116,9 +116,36 @@ export const CommunityComparisonModal: React.FC =
- {/* View Switcher & Privacy Bar */} -
-
+ {!user ? ( +
+
+ +
+
+

Registered Golfer Feature

+

+ 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! +

+
+ {onOpenAuthModal && ( + + )} +
+ ) : ( + <> + {/* View Switcher & Privacy Bar */} +
+
) : null}
+ + )} {/* Footer */}
diff --git a/src/components/HoleEntry.tsx b/src/components/HoleEntry.tsx index 3f6429b..488a8d7 100644 --- a/src/components/HoleEntry.tsx +++ b/src/components/HoleEntry.tsx @@ -12,6 +12,10 @@ import { Waves, ShieldAlert, HelpCircle, + Lock, + Navigation, + Trophy, + X, } from 'lucide-react'; import { Course, @@ -24,6 +28,9 @@ import { } from '../types/golf'; import { getScoreName, formatToPar } from '../utils/golfStats'; import { GolfGpsRangefinder } from './GolfGpsRangefinder'; +import { useAuth } from '../context/AuthContext'; +import { evaluateSingleHoleAchievements, getAchievementById } from '../utils/achievements'; + interface HoleEntryProps { currentHoleNumber: number; @@ -33,6 +40,9 @@ interface HoleEntryProps { onUpdateHoleScore: (holeNumber: number, updated: HoleScore) => void; settings: TrackingSettings; onViewScorecard: () => void; + isCompleted?: boolean; + onOpenSaveModal?: () => void; + onOpenAuth?: () => void; } export const HoleEntry: React.FC = ({ @@ -43,7 +53,11 @@ export const HoleEntry: React.FC = ({ onUpdateHoleScore, settings, onViewScorecard, + isCompleted = false, + onOpenSaveModal, + onOpenAuth, }) => { + const { user } = useAuth(); const currentHole: Hole = course.holes.find((h) => h.holeNumber === currentHoleNumber) || course.holes[0]; const holeScore: HoleScore = scores[currentHoleNumber] || { holeNumber: currentHoleNumber, @@ -61,12 +75,53 @@ export const HoleEntry: React.FC = ({ }; 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 = (field: K, value: HoleScore[K]) => { + if (isCompleted) return; const updated = { ...holeScore, [field]: value }; + + // Detect provisional achievement transitions for this hole + const prevFeats = evaluateSingleHoleAchievements(currentHole, holeScore); + const nextFeats = evaluateSingleHoleAchievements(currentHole, updated); + + const newlyUnlocked = nextFeats.filter((id) => !prevFeats.includes(id)); + const retracted = prevFeats.filter((id) => !nextFeats.includes(id)); + + if (newlyUnlocked.length > 0) { + const ach = getAchievementById(newlyUnlocked[0]); + if (ach) { + setProvisionalToast({ + type: 'unlocked', + title: `πŸŽ‰ ${ach.title}!`, + description: `Provisional in-round achievement for Hole ${currentHoleNumber}! This will be permanently recorded into your career profile once this round is finalized and saved.`, + }); + } + } else if (retracted.length > 0) { + const ach = getAchievementById(retracted[0]); + if (ach) { + setProvisionalToast({ + type: 'retracted', + title: `ℹ️ Provisional ${ach.title} Retracted`, + description: `Score modified on Hole ${currentHoleNumber}. The achievement has been retracted until qualified again.`, + }); + } + } + onUpdateHoleScore(currentHoleNumber, updated); }; + const currentGross = holeScore.grossScore || currentHole.par; const scoreDiff = currentGross - currentHole.par; const scoreTitle = getScoreName(currentGross, currentHole.par); @@ -182,6 +237,31 @@ export const HoleEntry: React.FC = ({ return (
+ {/* Locked Notice Banner */} + {isCompleted && ( +
+
+
+ +
+
+
+ Official Finalized Round (Locked) +
+

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

+
+
+ +
+ )} + {/* 1. Quick Hole Navigation Strip */}
@@ -269,15 +349,113 @@ export const HoleEntry: React.FC = ({
- {/* 2.5 Active Google Maps GPS Rangefinder (Front / Middle / Back) */} + {/* 2.5 Active Google Maps GPS Rangefinder (Front / Middle / Back) - Registered Users Only */} {showGps && ( - updateField('driveDistanceYds', dist)} - /> + user ? ( + updateField('driveDistanceYds', dist)} + /> + ) : ( +
+
+
+
+ +
+
+
+ Satellite GPS Rangefinder + + Sign-In Required + +
+

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

+
+
+ + {onOpenAuth && ( + + )} +
+
+ ) + )} + + {/* Provisional Achievement Live Toast Banner */} + {provisionalToast && ( +
+
+
+ +
+
+
+ + {provisionalToast.title} + + + {provisionalToast.type === 'unlocked' ? 'Provisional In-Round' : 'Notice'} + +
+

+ {provisionalToast.description} +

+
+
+ +
+ )} + + {/* Active Provisional Achievements Indicator */} + {currentProvisionalAchievements.length > 0 && ( +
+
+ +
+ + Provisional Feat: {currentProvisionalAchievements.map((a) => a?.title).join(', ')} + + + Qualified on Hole {currentHole.holeNumber}. Finalized upon saving round; revoked if score is changed. + +
+
+ + Pending Save + +
)} {/* 3. Main Gross Score Stepper & Dial */} @@ -899,28 +1077,43 @@ export const HoleEntry: React.FC = ({ )} {/* 11. Bottom Action Bar */} -
+
+ {!isCompleted && onOpenSaveModal && ( + + )} + {currentHoleNumber < course.holes.length ? ( ) : ( )} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index e8ee444..67a1c8b 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -14,6 +14,7 @@ import { Eye, EyeOff, ChevronDown, + Lock, } from 'lucide-react'; import { Round } from '../types/golf'; import { formatToPar } from '../utils/golfStats'; @@ -24,7 +25,7 @@ export type ActiveTab = 'hole' | 'scorecard' | 'analytics' | 'settings'; interface NavbarProps { activeTab: ActiveTab; setActiveTab: (tab: ActiveTab) => void; - currentRound: Round; + currentRound?: Round | null; onNewRound: () => void; onOpenRounds: () => void; onOpenAchievements: () => void; @@ -71,8 +72,14 @@ export const Navbar: React.FC = ({ PinPoint Golf - - {currentRound.courseName} + + {currentRound ? currentRound.courseName : 'No Active Round'} + {currentRound?.isCompleted && ( + + + Saved + + )}
@@ -125,9 +132,11 @@ export const Navbar: React.FC = ({ @@ -156,19 +165,26 @@ export const Navbar: React.FC = ({ {/* Mobile Community Button */} {/* Saved Rounds */} diff --git a/src/components/RoundsHistoryModal.tsx b/src/components/RoundsHistoryModal.tsx index 679cbc4..e44f84f 100644 --- a/src/components/RoundsHistoryModal.tsx +++ b/src/components/RoundsHistoryModal.tsx @@ -1,8 +1,7 @@ -import React from 'react'; -import { X, Calendar, MapPin, Trash2, CheckCircle2, RotateCcw } from 'lucide-react'; +import React, { useState } from 'react'; +import { X, Calendar, MapPin, Trash2, CheckCircle2, RotateCcw, Lock, AlertTriangle } from 'lucide-react'; import { Round } from '../types/golf'; import { formatToPar } from '../utils/golfStats'; -import { SAMPLE_ROUND } from '../data/sampleRounds'; interface RoundsHistoryModalProps { isOpen: boolean; @@ -11,7 +10,7 @@ interface RoundsHistoryModalProps { currentRoundId: string; onSelectRound: (roundId: string) => void; onDeleteRound: (roundId: string) => void; - onLoadSampleRound: () => void; + onLoadSampleRound?: () => void; } export const RoundsHistoryModal: React.FC = ({ @@ -23,8 +22,17 @@ export const RoundsHistoryModal: React.FC = ({ onDeleteRound, onLoadSampleRound, }) => { + const [deletingId, setDeletingId] = useState(null); + if (!isOpen) return null; + const isProduction = import.meta.env.PROD; + + const handleConfirmDelete = (roundId: string) => { + onDeleteRound(roundId); + setDeletingId(null); + }; + return (
@@ -33,7 +41,7 @@ export const RoundsHistoryModal: React.FC = ({

Rounds & History

- Review saved rounds or reload the sample tour analytics round + Review your saved scorecards. Saved rounds can only be viewed or deleted.

+ {/* Delete Confirmation Inline Banner */} + {deletingId && ( +
+
+ + Are you sure you want to permanently delete this saved round? +
+
+ + +
+
+ )} + {/* Rounds List */}
- {rounds.map((r) => { - const isCurrent = r.id === currentRoundId; + {rounds.length === 0 ? ( +
+ +

No Saved Rounds Yet

+

+ Start a new round from the top navigation, play holes, and finalize it to build your career history. +

+
+ ) : ( + rounds.map((r) => { + const isCurrent = r.id === currentRoundId; - // Calculate quick score - let grossTotal = 0; - let holesScored = 0; - Object.values(r.scores).forEach((s) => { - if (s.grossScore && s.grossScore > 0) { - grossTotal += s.grossScore; - holesScored++; - } - }); - const diff = grossTotal - r.coursePar; + // Calculate quick score + let grossTotal = 0; + let holesScored = 0; + Object.values(r.scores).forEach((s) => { + if (s.grossScore && s.grossScore > 0) { + grossTotal += s.grossScore; + holesScored++; + } + }); + const diff = grossTotal - r.coursePar; - return ( -
+ return (
{ - onSelectRound(r.id); - onClose(); - }} - className="cursor-pointer flex-1" + key={r.id} + className={`p-4 rounded-2xl border transition-all flex items-center justify-between gap-4 ${ + isCurrent + ? 'bg-emerald-950/30 border-emerald-500/80' + : 'bg-stone-850 border-stone-800 hover:bg-stone-800/80' + }`} > -
- {r.courseName} - {isCurrent && ( - - Active - - )} -
- -
-
- - {r.date} -
- Β· -
- - {r.teeName} -
- {r.weatherData ? ( - <> - Β· - - 🌑️ {r.weatherData.temperature}Β°C Β· πŸ’¨ {r.weatherData.windSpeed} km/h Β· 🌧️ {r.weatherData.precipitationProbability}% +
{ + onSelectRound(r.id); + onClose(); + }} + className="cursor-pointer flex-1" + > +
+ {r.courseName} + {isCurrent && ( + + Active - - ) : r.weather ? ( - <> - Β· - - {r.weather} + )} + {r.isCompleted ? ( + + + Finalized - - ) : null} -
-
- - {/* Score & Actions */} -
-
-
- {grossTotal > 0 ? grossTotal : 'β€”'} + ) : ( + + In Progress + + )}
-
0 ? 'text-rose-400' : 'text-stone-400' - }`} - > - {holesScored > 0 ? formatToPar(diff) : 'In progress'} + +
+
+ + {r.date} +
+ Β· +
+ + {r.teeName} +
+ {r.weatherData ? ( + <> + Β· + + 🌑️ {r.weatherData.temperature}Β°C Β· πŸ’¨ {r.weatherData.windSpeed} km/h Β· 🌧️ {r.weatherData.precipitationProbability}% + + + ) : r.weather ? ( + <> + Β· + + {r.weather} + + + ) : null}
- {rounds.length > 1 && ( + {/* Score & Delete Action */} +
+
+
+ {grossTotal > 0 ? grossTotal : 'β€”'} +
+
0 ? 'text-rose-400' : 'text-stone-400' + }`} + > + {holesScored > 0 ? formatToPar(diff) : 'In progress'} +
+
+ - )} +
-
- ); - })} + ); + }) + )}
- {/* Footer with Sample Round restore */} + {/* Footer */}
- + {!isProduction && onLoadSampleRound ? ( + + ) : ( +
+ {rounds.length} {rounds.length === 1 ? 'round' : 'rounds'} stored +
+ )} +
+ + {/* Content Body */} +
+ {/* Irreversible Lock Warning Banner */} +
+ +
+ + Notice: Editing Will Not Be Possible + + Once saved, this round will be permanently finalized. You will{' '} + not be able to edit individual + hole scores, putts, or stats later. Saved rounds can only be deleted. +
+
+ + {/* Unplayed Holes Notice (if applicable) */} + {unplayedCount > 0 && ( +
+ ℹ️ Note: + + {unplayedCount} of {totalHoles} holes have no score entered yet. Unplayed holes + will be omitted from final scoring averages. + +
+ )} + + {/* Round Summary Card */} +
+
+
+

{round.courseName}

+
+
+ + {round.date} +
+ Β· +
+ + {round.teeName} +
+
+
+ + {/* Total Score Pill */} +
+
+ {stats.totalGross > 0 ? stats.totalGross : 'β€”'} +
+
0 + ? 'text-rose-400' + : 'text-stone-400' + }`} + > + {scoredCount > 0 ? formatToPar(stats.toPar) : 'Unscored'} +
+
+
+ + {/* Quick Metrics Grid */} +
+
+
Holes
+
+ {scoredCount}/{totalHoles} +
+
+
+
FIR %
+
+ {stats.firPercentage}% +
+
+
+
GIR %
+
+ {stats.girPercentage}% +
+
+
+
Putts
+
+ {stats.totalPutts} +
+
+
+
+ + {/* Achievement Evaluation Preview Banner */} + {isAuthenticated ? ( +
+
+ +
+
+ Trophy Evaluation Ready + Career achievements will be evaluated and awarded immediately upon saving this round! +
+
+ ) : ( +
+
+ + + Playing as guest. Sign in to sync rounds and earn trophies! + +
+ {onOpenAuth && ( + + )} +
+ )} +
+ + {/* Action Buttons */} +
+ + + +
+
+
+ ); +}; diff --git a/src/components/Scorecard.tsx b/src/components/Scorecard.tsx index 36cf7c8..b460565 100644 --- a/src/components/Scorecard.tsx +++ b/src/components/Scorecard.tsx @@ -4,6 +4,9 @@ import { Share2, Check, Edit3, + Lock, + CheckCircle2, + AlertCircle, } from 'lucide-react'; import { Course, Round, TrackingSettings } from '../types/golf'; import { calculateRoundStats, formatToPar } from '../utils/golfStats'; @@ -13,6 +16,7 @@ interface ScorecardProps { course: Course; settings: TrackingSettings; onEditHole: (holeNumber: number) => void; + onOpenSaveModal?: () => void; } export const Scorecard: React.FC = ({ @@ -20,6 +24,7 @@ export const Scorecard: React.FC = ({ course, settings, onEditHole, + onOpenSaveModal, }) => { const [copied, setCopied] = useState(false); const stats = calculateRoundStats(round, course); @@ -185,8 +190,13 @@ export const Scorecard: React.FC = ({ {holes.map((h) => ( onEditHole(h.holeNumber)} - className="py-2.5 px-1 sm:px-2 min-w-[36px] sm:min-w-[42px] cursor-pointer hover:bg-stone-800 hover:text-white transition-colors" + onClick={() => !round.isCompleted && onEditHole(h.holeNumber)} + className={`py-2.5 px-1 sm:px-2 min-w-[36px] sm:min-w-[42px] transition-colors ${ + !round.isCompleted + ? 'cursor-pointer hover:bg-stone-800 hover:text-white' + : 'cursor-default text-stone-300' + }`} + title={round.isCompleted ? 'Saved round (locked)' : `Edit Hole ${h.holeNumber}`} > {h.holeNumber} @@ -234,16 +244,24 @@ export const Scorecard: React.FC = ({ Score - + {round.isCompleted ? ( + + ) : ( + + )} {holes.map((h) => { const s = round.scores[h.holeNumber]; return ( onEditHole(h.holeNumber)} - className="py-2 px-1 cursor-pointer hover:bg-stone-750 transition-colors" - title={`Edit Hole ${h.holeNumber}`} + onClick={() => !round.isCompleted && onEditHole(h.holeNumber)} + className={`py-2 px-1 transition-colors ${ + !round.isCompleted + ? 'cursor-pointer hover:bg-stone-750' + : 'cursor-default' + }`} + title={round.isCompleted ? 'Saved round (locked)' : `Edit Hole ${h.holeNumber}`} > {renderScoreCell(s?.grossScore, h.par)} @@ -455,12 +473,22 @@ export const Scorecard: React.FC = ({
-
- Official Scorecard +
+ {round.isCompleted ? ( + + + Finalized & Locked + + ) : ( + + + Live Scoring (In Progress) + + )} Β· - {round.date} + {round.date} Β· - Handicap: {round.playerHandicap} + HCP: {round.playerHandicap}

{course.name} @@ -495,7 +523,7 @@ export const Scorecard: React.FC = ({

{/* Big Score Summary & Actions */} -
+
Total Score @@ -518,7 +546,18 @@ export const Scorecard: React.FC = ({
-
+
+ {onOpenSaveModal && !round.isCompleted && ( + + )} +
- Click any cell to edit that hole + {round.isCompleted ? ( + + + Round finalized & locked β€” scores cannot be edited (saved rounds can only be deleted). + + ) : ( + Click any cell to edit that hole + )}
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index d69101b..4fe60b0 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Compass, Crosshair, @@ -6,9 +6,15 @@ import { RotateCcw, Sliders, CheckCircle2, + Database, + Server, + RefreshCw, + ShieldCheck, } from 'lucide-react'; import { PresetProfile, TrackingSettings } from '../types/golf'; import { SETTING_PRESETS, DEFAULT_TRACKING_SETTINGS } from '../data/courses'; +import { roundsApi } from '../services/api'; + interface SettingsProps { settings: TrackingSettings; @@ -19,7 +25,32 @@ export const Settings: React.FC = ({ settings, onUpdateSettings, }) => { + const [dbStatus, setDbStatus] = useState<{ + configured: boolean; + connected: boolean; + host: string | null; + database: string | null; + port: number; + lastError: string | null; + engine: string; + } | null>(null); + const [loadingDbStatus, setLoadingDbStatus] = useState(false); + + const fetchDbStatus = () => { + setLoadingDbStatus(true); + roundsApi + .getDbStatus() + .then((status) => setDbStatus(status)) + .catch((err) => console.warn('Could not fetch db status:', err)) + .finally(() => setLoadingDbStatus(false)); + }; + + useEffect(() => { + fetchDbStatus(); + }, []); + const toggleSetting = (key: keyof TrackingSettings) => { + onUpdateSettings({ ...settings, [key]: !settings[key], @@ -558,6 +589,105 @@ export const Settings: React.FC = ({
+ + {/* Database & Storage Architecture Card */} +
+
+
+
+ +
+
+

Database & Persistence Engine

+

+ MariaDB Galera cluster integration & dynamic achievement lifecycle +

+
+
+ + +
+ +
+ {/* Active Backend Engine Status */} +
+
+ + Active Storage Engine + + + {dbStatus?.connected ? 'Cluster Online' : 'Local Standby'} + +
+ +
+ {dbStatus?.engine || 'Local Persistent Storage'} +
+ +
+
+ Host:{' '} + + {dbStatus?.host || 'Built-in Local JSON DB'} + +
+
+ Database:{' '} + + {dbStatus?.database || 'pinpoint_db.json'} + +
+ {dbStatus?.port && ( +
+ Port:{' '} + {dbStatus.port} +
+ )} +
+
+ + {/* Galera Cluster Setup Instructions */} +
+
+ + MariaDB Galera Cluster Connection +
+

+ To connect your MariaDB Galera cluster, configure these environment variables: +

+
+
MARIADB_HOST="192.168.1.100"
+
MARIADB_PORT="3306"
+
MARIADB_USER="pinpoint_user"
+
MARIADB_PASSWORD="your_password"
+
MARIADB_DATABASE="pinpoint_golf"
+
+
+
+ + {/* Achievement Recalculation Rules Recap */} +
+ +
+ Integrity Guarantees: + 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. +
+
+
); }; diff --git a/src/server/authMiddleware.ts b/src/server/authMiddleware.ts index 08a64e9..17ccd24 100644 --- a/src/server/authMiddleware.ts +++ b/src/server/authMiddleware.ts @@ -21,7 +21,7 @@ export function generateToken(user: User): string { ); } -export function authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) { +export async function authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Authentication token missing or invalid' }); @@ -30,7 +30,7 @@ export function authMiddleware(req: AuthenticatedRequest, res: Response, next: N const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; - const user = db.findUserById(decoded.id); + const user = await db.findUserById(decoded.id); if (!user) { return res.status(401).json({ error: 'User no longer exists' }); } @@ -42,7 +42,7 @@ export function authMiddleware(req: AuthenticatedRequest, res: Response, next: N } // Optional auth: attaches user if token is valid, but doesn't block if not -export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) { +export async function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return next(); @@ -51,7 +51,7 @@ export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response, const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; - const user = db.findUserById(decoded.id); + const user = await db.findUserById(decoded.id); if (user) { req.user = user; } @@ -60,3 +60,4 @@ export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response, } next(); } + diff --git a/src/server/db.ts b/src/server/db.ts index 6153a3d..af88184 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -4,12 +4,14 @@ import bcrypt from 'bcryptjs'; import { User, PublicGolferStats } from '../types/auth'; import { Round, TrackingSettings, Course } from '../types/golf'; import { DEFAULT_TRACKING_SETTINGS } from '../data/courses'; +import { mariadb } from './mariadb'; interface DatabaseSchema { users: Array; settings: Record; // userId -> settings rounds: Record; // userId -> rounds customCourses: Record; // userId -> courses + achievements: Record>; publicSeedGolfers: PublicGolferStats[]; } @@ -21,7 +23,7 @@ if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } -// Initial seed public golfers for rich community stats & benchmarking +// Initial seed public golfers (used only when mock data is explicitly enabled in dev) const SEED_PUBLIC_GOLFERS: PublicGolferStats[] = [ { id: 'user_seed_viktor', @@ -123,21 +125,42 @@ class Database { this.seedDefaultUserIfEmpty(); } + public async init(): Promise { + const connected = await mariadb.init(); + if (connected) { + console.log('πŸš€ MariaDB Galera cluster online and selected as primary storage.'); + } else { + console.log('πŸ“¦ Using local persistent file storage for PinPoint Golf.'); + } + } + private load(): DatabaseSchema { try { if (fs.existsSync(DB_FILE)) { const raw = fs.readFileSync(DB_FILE, 'utf-8'); - return JSON.parse(raw); + const parsed = JSON.parse(raw); + return { + users: parsed.users || [], + settings: parsed.settings || {}, + rounds: parsed.rounds || {}, + customCourses: parsed.customCourses || {}, + achievements: parsed.achievements || {}, + publicSeedGolfers: parsed.publicSeedGolfers || [], + }; } } catch (e) { console.error('Failed to load database file, creating fresh store:', e); } + const isProduction = process.env.NODE_ENV === 'production'; + const enableMock = process.env.ENABLE_MOCK_DATA === 'true'; + return { users: [], settings: {}, rounds: {}, customCourses: {}, - publicSeedGolfers: SEED_PUBLIC_GOLFERS, + achievements: {}, + publicSeedGolfers: !isProduction && enableMock ? SEED_PUBLIC_GOLFERS : [], }; } @@ -152,6 +175,25 @@ class Database { } private seedDefaultUserIfEmpty() { + const isProduction = process.env.NODE_ENV === 'production'; + const enableMock = process.env.ENABLE_MOCK_DATA === 'true'; + + // Mock data must not be present when deployed or in production + if (isProduction || !enableMock) { + if (this.data.publicSeedGolfers && this.data.publicSeedGolfers.some(g => g.id.startsWith('user_seed_'))) { + this.data.publicSeedGolfers = this.data.publicSeedGolfers.filter(g => !g.id.startsWith('user_seed_')); + this.save(); + } + if (this.data.users && this.data.users.some(u => u.id === 'usr_demo_golfer')) { + this.data.users = this.data.users.filter(u => u.id !== 'usr_demo_golfer'); + delete this.data.settings['usr_demo_golfer']; + delete this.data.rounds['usr_demo_golfer']; + delete this.data.achievements['usr_demo_golfer']; + this.save(); + } + return; + } + if (!this.data.users || this.data.users.length === 0) { const salt = bcrypt.genSaltSync(10); const passwordHash = bcrypt.hashSync('golf1234', salt); @@ -181,25 +223,37 @@ class Database { } // --- User Operations --- - public getUsers(): Array { + public async getUsers(): Promise> { + if (mariadb.isAvailable()) { + return await mariadb.getUsers(); + } return this.data.users; } - public findUserById(id: string): User | undefined { + public async findUserById(id: string): Promise { + if (mariadb.isAvailable()) { + return await mariadb.findUserById(id); + } const user = this.data.users.find((u) => u.id === id); if (!user) return undefined; const { passwordHash: _, ...safeUser } = user; return safeUser; } - public findUserByEmailOrUsername(identifier: string): (User & { passwordHash?: string }) | undefined { + public async findUserByEmailOrUsername(identifier: string): Promise<(User & { passwordHash?: string }) | undefined> { + if (mariadb.isAvailable()) { + return await mariadb.findUserByEmailOrUsername(identifier); + } const lower = identifier.toLowerCase().trim(); return this.data.users.find( (u) => u.email.toLowerCase() === lower || u.username.toLowerCase() === lower ); } - public createUser(userData: User & { passwordHash?: string }): User { + public async createUser(userData: User & { passwordHash?: string }): Promise { + if (mariadb.isAvailable()) { + return await mariadb.createUser(userData); + } this.data.users.push(userData); if (!this.data.settings[userData.id]) { this.data.settings[userData.id] = { ...DEFAULT_TRACKING_SETTINGS }; @@ -207,12 +261,18 @@ class Database { if (!this.data.rounds[userData.id]) { this.data.rounds[userData.id] = []; } + if (!this.data.achievements[userData.id]) { + this.data.achievements[userData.id] = []; + } this.save(); const { passwordHash: _, ...safeUser } = userData; return safeUser; } - public updateUser(id: string, updates: Partial): User | undefined { + public async updateUser(id: string, updates: Partial): Promise { + if (mariadb.isAvailable()) { + return await mariadb.updateUser(id, updates); + } const index = this.data.users.findIndex((u) => u.id === id); if (index === -1) return undefined; @@ -228,22 +288,34 @@ class Database { } // --- Settings Operations --- - public getSettings(userId: string): TrackingSettings { + public async getSettings(userId: string): Promise { + if (mariadb.isAvailable()) { + return await mariadb.getSettings(userId); + } return this.data.settings[userId] || { ...DEFAULT_TRACKING_SETTINGS }; } - public updateSettings(userId: string, settings: TrackingSettings): TrackingSettings { + public async updateSettings(userId: string, settings: TrackingSettings): Promise { + if (mariadb.isAvailable()) { + return await mariadb.updateSettings(userId, settings); + } this.data.settings[userId] = settings; this.save(); return settings; } // --- Rounds Operations --- - public getRounds(userId: string): Round[] { + public async getRounds(userId: string): Promise { + if (mariadb.isAvailable()) { + return await mariadb.getRounds(userId); + } return this.data.rounds[userId] || []; } - public saveRound(userId: string, round: Round): Round { + public async saveRound(userId: string, round: Round): Promise { + if (mariadb.isAvailable()) { + return await mariadb.saveRound(userId, round); + } if (!this.data.rounds[userId]) { this.data.rounds[userId] = []; } @@ -257,7 +329,13 @@ class Database { return round; } - public syncRoundsBatch(userId: string, rounds: Round[]): Round[] { + public async syncRoundsBatch(userId: string, rounds: Round[]): Promise { + if (mariadb.isAvailable()) { + for (const r of rounds) { + await mariadb.saveRound(userId, r); + } + return await mariadb.getRounds(userId); + } if (!this.data.rounds[userId]) { this.data.rounds[userId] = []; } @@ -273,7 +351,10 @@ class Database { return this.data.rounds[userId]; } - public deleteRound(userId: string, roundId: string): boolean { + public async deleteRound(userId: string, roundId: string): Promise { + if (mariadb.isAvailable()) { + return await mariadb.deleteRound(userId, roundId); + } if (!this.data.rounds[userId]) return false; const initialLen = this.data.rounds[userId].length; this.data.rounds[userId] = this.data.rounds[userId].filter((r) => r.id !== roundId); @@ -282,12 +363,38 @@ class Database { return changed; } + // --- Achievements Operations --- + public async getUserAchievements(userId: string): Promise> { + 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 { + if (mariadb.isAvailable()) { + await mariadb.syncUserAchievements(userId, achievements); + return; + } + this.data.achievements[userId] = achievements; + this.save(); + } + // --- Custom Courses Operations --- - public getCustomCourses(userId: string): Course[] { + public async getCustomCourses(userId: string): Promise { + if (mariadb.isAvailable()) { + return await mariadb.getCustomCourses(userId); + } return this.data.customCourses[userId] || []; } - public saveCustomCourse(userId: string, course: Course): Course { + public async saveCustomCourse(userId: string, course: Course): Promise { + if (mariadb.isAvailable()) { + return await mariadb.saveCustomCourse(userId, course); + } if (!this.data.customCourses[userId]) { this.data.customCourses[userId] = []; } @@ -302,13 +409,14 @@ class Database { } // --- Community & Public Stats Operations --- - public getPublicGolfers(): PublicGolferStats[] { + public async getPublicGolfers(): Promise { + const allUsers = await this.getUsers(); const list: PublicGolferStats[] = [...(this.data.publicSeedGolfers || [])]; // Compute public stats for any real registered users with isPublic == true - for (const user of this.data.users) { + for (const user of allUsers) { if (!user.isPublic) continue; - const userRounds = this.data.rounds[user.id] || []; + const userRounds = await this.getRounds(user.id); if (userRounds.length === 0) continue; let totalGross = 0; diff --git a/src/server/mariadb.ts b/src/server/mariadb.ts new file mode 100644 index 0000000..80df0f7 --- /dev/null +++ b/src/server/mariadb.ts @@ -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 { + 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 { + 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> { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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(); diff --git a/src/server/routes/authRoutes.ts b/src/server/routes/authRoutes.ts index 270b228..3056fd7 100644 --- a/src/server/routes/authRoutes.ts +++ b/src/server/routes/authRoutes.ts @@ -7,7 +7,7 @@ import { User, AuthProvider } from '../../types/auth'; const router = Router(); // --- 1. Local Registration --- -router.post('/register', (req, res: Response) => { +router.post('/register', async (req, res: Response) => { try { const { email, username, password, name, handicapIndex, homeClub, isPublic } = req.body; @@ -19,7 +19,7 @@ router.post('/register', (req, res: Response) => { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } - const existingUser = db.findUserByEmailOrUsername(email) || db.findUserByEmailOrUsername(username); + const existingUser = (await db.findUserByEmailOrUsername(email)) || (await db.findUserByEmailOrUsername(username)); if (existingUser) { return res.status(409).json({ error: 'An account with that email or username already exists' }); } @@ -41,13 +41,14 @@ router.post('/register', (req, res: Response) => { passwordHash, }; - const savedUser = db.createUser(newUser); + const savedUser = await db.createUser(newUser); const token = generateToken(savedUser); + const settings = await db.getSettings(savedUser.id); return res.status(201).json({ token, user: savedUser, - settings: db.getSettings(savedUser.id), + settings, }); } catch (error) { console.error('Registration error:', error); @@ -56,7 +57,7 @@ router.post('/register', (req, res: Response) => { }); // --- 2. Local Login --- -router.post('/login', (req, res: Response) => { +router.post('/login', async (req, res: Response) => { try { const { identifier, password } = req.body; @@ -64,7 +65,7 @@ router.post('/login', (req, res: Response) => { return res.status(400).json({ error: 'Email/Username and password are required' }); } - const userWithHash = db.findUserByEmailOrUsername(identifier); + const userWithHash = await db.findUserByEmailOrUsername(identifier); if (!userWithHash || !userWithHash.passwordHash) { return res.status(401).json({ error: 'Invalid credentials. Please verify your email and password.' }); } @@ -76,11 +77,12 @@ router.post('/login', (req, res: Response) => { const { passwordHash: _, ...safeUser } = userWithHash; const token = generateToken(safeUser); + const settings = await db.getSettings(safeUser.id); return res.json({ token, user: safeUser, - settings: db.getSettings(safeUser.id), + settings, }); } catch (error) { console.error('Login error:', error); @@ -89,9 +91,9 @@ router.post('/login', (req, res: Response) => { }); // --- 3. Get Current User & Profile --- -router.get('/me', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.get('/me', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); - const settings = db.getSettings(req.user.id); + const settings = await db.getSettings(req.user.id); return res.json({ user: req.user, settings, @@ -99,7 +101,7 @@ router.get('/me', authMiddleware, (req: AuthenticatedRequest, res: Response) => }); // --- 4. Update Profile & Privacy (Public / Private toggle) --- -router.put('/profile', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.put('/profile', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); const { name, handicapIndex, homeClub, isPublic, avatarUrl } = req.body; @@ -111,19 +113,20 @@ router.put('/profile', authMiddleware, (req: AuthenticatedRequest, res: Response if (typeof isPublic === 'boolean') updates.isPublic = isPublic; if (typeof avatarUrl === 'string') updates.avatarUrl = avatarUrl; - const updatedUser = db.updateUser(req.user.id, updates); + const updatedUser = await db.updateUser(req.user.id, updates); return res.json({ user: updatedUser }); }); // --- 5. User Settings Sync --- -router.get('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.get('/settings', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); - return res.json(db.getSettings(req.user.id)); + const settings = await db.getSettings(req.user.id); + return res.json(settings); }); -router.put('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.put('/settings', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); - const updated = db.updateSettings(req.user.id, req.body); + const updated = await db.updateSettings(req.user.id, req.body); return res.json(updated); }); @@ -158,22 +161,23 @@ router.get('/oauth/providers', (_req, res: Response) => { }); // --- 7. OAuth / SSO Login (Google, Authentik, GitHub, Demo SSO) --- -router.post('/oauth/sso-login', (req, res: Response) => { +router.post('/oauth/sso-login', async (req, res: Response) => { try { const { provider, email, name, username, avatarUrl, authentikServerUrl } = req.body; if (!provider) { - return res.status(400).json({ error: 'Provider is required' }); + return res.status(400).json({ error: 'OAuth provider is required' }); } const providerType = provider as AuthProvider; + // Generate reliable email & username if from SSO const ssoEmail = email?.toLowerCase().trim() || `${provider}_golfer_${Date.now().toString(36)}@example.com`; const ssoUsername = username?.toLowerCase().trim() || `${provider}_${Math.random().toString(36).substring(2, 7)}`; const ssoName = name?.trim() || `${provider.charAt(0).toUpperCase() + provider.slice(1)} Golfer`; // Check if user already exists - let user = db.findUserByEmailOrUsername(ssoEmail); + let user = await db.findUserByEmailOrUsername(ssoEmail); if (!user) { // Create new user connected via SSO @@ -192,16 +196,17 @@ router.post('/oauth/sso-login', (req, res: Response) => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; - user = db.createUser(newUser); + user = await db.createUser(newUser); } const { passwordHash: _, ...safeUser } = user; const token = generateToken(safeUser); + const settings = await db.getSettings(safeUser.id); return res.json({ token, user: safeUser, - settings: db.getSettings(safeUser.id), + settings, }); } catch (error) { console.error('SSO Login error:', error); diff --git a/src/server/routes/communityRoutes.ts b/src/server/routes/communityRoutes.ts index 4094bdb..477cca8 100644 --- a/src/server/routes/communityRoutes.ts +++ b/src/server/routes/communityRoutes.ts @@ -6,9 +6,9 @@ import { PublicGolferStats, ComparisonMetric, CommunityComparisonData } from '.. const router = Router(); // --- 1. Get Public Golfers Leaderboard --- -router.get('/leaderboard', (_req, res: Response) => { +router.get('/leaderboard', async (_req, res: Response) => { try { - const golfers = db.getPublicGolfers(); + const golfers = await db.getPublicGolfers(); // Sort by scoring average ascending (lowest score first) golfers.sort((a, b) => a.scoringAverage - b.scoringAverage); @@ -24,9 +24,9 @@ router.get('/leaderboard', (_req, res: Response) => { }); // --- 2. Head-to-Head Community Benchmark Comparison --- -router.get('/compare', optionalAuthMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.get('/compare', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => { try { - const publicGolfers = db.getPublicGolfers(); + const publicGolfers = await db.getPublicGolfers(); if (publicGolfers.length === 0) { return res.status(404).json({ error: 'No public statistics available' }); diff --git a/src/server/routes/roundsRoutes.ts b/src/server/routes/roundsRoutes.ts index ca8bfba..6afdd7c 100644 --- a/src/server/routes/roundsRoutes.ts +++ b/src/server/routes/roundsRoutes.ts @@ -2,18 +2,26 @@ import { Router, Response } from 'express'; import { db } from '../db'; import { authMiddleware, AuthenticatedRequest } from '../authMiddleware'; import { Round, Course } from '../../types/golf'; +import { DEFAULT_COURSES } from '../../data/courses'; +import { evaluateAchievements } from '../../utils/achievements'; +import { mariadb } from '../mariadb'; const router = Router(); +// --- Database Status Endpoint --- +router.get('/db-status', (_req, res: Response) => { + res.json(mariadb.getStatus()); +}); + // --- Get All Rounds for User --- -router.get('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); - const rounds = db.getRounds(req.user.id); + const rounds = await db.getRounds(req.user.id); return res.json(rounds); }); -// --- Save / Update Single Round --- -router.post('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +// --- Save / Finalize Round --- +router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); const round: Round = req.body; @@ -21,12 +29,99 @@ router.post('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => { return res.status(400).json({ error: 'Invalid round payload' }); } - const saved = db.saveRound(req.user.id, round); - return res.json(saved); + // Ensure isCompleted is explicitly set when finalizing + const roundToSave: Round = { + ...round, + isCompleted: true, + completedAt: round.completedAt || new Date().toISOString(), + }; + + const saved = await db.saveRound(req.user.id, roundToSave); + + // Re-evaluate and persist achievements for this user across all completed rounds + const customCourses = await db.getCustomCourses(req.user.id); + const allCourses = [...DEFAULT_COURSES, ...customCourses]; + const allUserRounds = await db.getRounds(req.user.id); + + const prevAchievements = await db.getUserAchievements(req.user.id); + const prevIds = prevAchievements.map((a) => a.id); + + const evaluated = evaluateAchievements(allUserRounds, allCourses, prevIds); + const unlockedRecords = evaluated.unlockedList + .filter((a) => a.isUnlocked) + .map((a) => ({ + id: a.id, + unlockedAt: a.unlockedAt || new Date().toISOString(), + unlockedRoundName: a.unlockedRoundName || roundToSave.courseName, + })); + + await db.syncUserAchievements(req.user.id, unlockedRecords); + + return res.json({ + round: saved, + achievements: evaluated.unlockedList, + newlyUnlocked: evaluated.newlyUnlocked, + }); +}); + +// --- Delete Round & Dynamic Achievement Recalculation --- +router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { + if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); + const roundId = req.params.id; + + const success = await db.deleteRound(req.user.id, roundId); + if (!success) { + return res.status(404).json({ error: 'Round not found or already deleted' }); + } + + // User confirmed requirement: + // "Recalculate achievements from remaining saved rounds" + const customCourses = await db.getCustomCourses(req.user.id); + const allCourses = [...DEFAULT_COURSES, ...customCourses]; + const remainingRounds = await db.getRounds(req.user.id); + + // Recalculate completely from remaining rounds (with empty previous set to detect true qualification) + const evaluated = evaluateAchievements(remainingRounds, allCourses, []); + + // Sync remaining valid achievements + const validUnlocked = evaluated.unlockedList + .filter((a) => a.isUnlocked) + .map((a) => ({ + id: a.id, + unlockedAt: a.unlockedAt || new Date().toISOString(), + unlockedRoundName: a.unlockedRoundName || 'Saved Round', + })); + + await db.syncUserAchievements(req.user.id, validUnlocked); + + return res.json({ + success: true, + remainingRoundsCount: remainingRounds.length, + achievements: evaluated.unlockedList, + recalculatedCount: validUnlocked.length, + }); +}); + +// --- Get User's Saved Achievements --- +router.get('/achievements', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { + if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); + const records = await db.getUserAchievements(req.user.id); + return res.json(records); +}); + +// --- Sync Achievements (Client-to-Server) --- +router.post('/achievements/sync', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { + if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); + const { achievements } = req.body; + if (!Array.isArray(achievements)) { + return res.status(400).json({ error: 'Expected achievements array' }); + } + await db.syncUserAchievements(req.user.id, achievements); + return res.json({ success: true, count: achievements.length }); }); // --- Batch Sync Rounds (e.g. migrating local offline rounds to cloud account) --- -router.post('/sync-batch', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.post('/sync-batch', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); const { rounds } = req.body; @@ -34,33 +129,38 @@ router.post('/sync-batch', authMiddleware, (req: AuthenticatedRequest, res: Resp return res.status(400).json({ error: 'Expected an array of rounds' }); } - const updatedRounds = db.syncRoundsBatch(req.user.id, rounds); - return res.json({ count: updatedRounds.length, rounds: updatedRounds }); -}); + const updatedRounds = await db.syncRoundsBatch(req.user.id, rounds); -// --- Delete Round --- -router.delete('/:id', authMiddleware, (req: AuthenticatedRequest, res: Response) => { - if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); - const success = db.deleteRound(req.user.id, req.params.id); - if (!success) { - return res.status(404).json({ error: 'Round not found' }); - } - return res.json({ success: true }); + // Recalculate achievements + const customCourses = await db.getCustomCourses(req.user.id); + const allCourses = [...DEFAULT_COURSES, ...customCourses]; + const evaluated = evaluateAchievements(updatedRounds, allCourses, []); + const validUnlocked = evaluated.unlockedList + .filter((a) => a.isUnlocked) + .map((a) => ({ + id: a.id, + unlockedAt: a.unlockedAt || new Date().toISOString(), + unlockedRoundName: a.unlockedRoundName || 'Saved Round', + })); + await db.syncUserAchievements(req.user.id, validUnlocked); + + return res.json({ count: updatedRounds.length, rounds: updatedRounds, achievements: evaluated.unlockedList }); }); // --- Custom Courses --- -router.get('/courses/custom', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.get('/courses/custom', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); - return res.json(db.getCustomCourses(req.user.id)); + const courses = await db.getCustomCourses(req.user.id); + return res.json(courses); }); -router.post('/courses/custom', authMiddleware, (req: AuthenticatedRequest, res: Response) => { +router.post('/courses/custom', authMiddleware, async (req: AuthenticatedRequest, res: Response) => { if (!req.user) return res.status(401).json({ error: 'Not authenticated' }); const course: Course = req.body; if (!course || !course.id || !course.name) { return res.status(400).json({ error: 'Invalid course payload' }); } - const saved = db.saveCustomCourse(req.user.id, course); + const saved = await db.saveCustomCourse(req.user.id, course); return res.json(saved); }); diff --git a/src/services/api.ts b/src/services/api.ts index d1ad3fc..e2cd0ac 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -121,30 +121,56 @@ export const authApi = { }; export const roundsApi = { + async getDbStatus(): Promise<{ + configured: boolean; + connected: boolean; + host: string | null; + database: string | null; + port: number; + lastError: string | null; + engine: string; + }> { + return request('/api/rounds/db-status'); + }, + async getRounds(): Promise { return request('/api/rounds'); }, - async saveRound(round: Round): Promise { - return request('/api/rounds', { + async saveRound(round: Round): Promise<{ round: Round; achievements?: any[]; newlyUnlocked?: any[] }> { + return request<{ round: Round; achievements?: any[]; newlyUnlocked?: any[] }>('/api/rounds', { method: 'POST', body: JSON.stringify(round), }); }, - async syncBatch(rounds: Round[]): Promise<{ count: number; rounds: Round[] }> { - return request<{ count: number; rounds: Round[] }>('/api/rounds/sync-batch', { + async syncBatch(rounds: Round[]): Promise<{ count: number; rounds: Round[]; achievements?: any[] }> { + return request<{ count: number; rounds: Round[]; achievements?: any[] }>('/api/rounds/sync-batch', { method: 'POST', body: JSON.stringify({ rounds }), }); }, - async deleteRound(id: string): Promise<{ success: boolean }> { - return request<{ success: boolean }>(`/api/rounds/${id}`, { + async deleteRound(id: string): Promise<{ + success: boolean; + remainingRoundsCount?: number; + achievements?: any[]; + recalculatedCount?: number; + }> { + return request<{ + success: boolean; + remainingRoundsCount?: number; + achievements?: any[]; + recalculatedCount?: number; + }>(`/api/rounds/${id}`, { method: 'DELETE', }); }, + async getAchievements(): Promise> { + return request>('/api/rounds/achievements'); + }, + async getCustomCourses(): Promise { return request('/api/rounds/courses/custom'); }, @@ -166,3 +192,4 @@ export const communityApi = { return request('/api/community/compare'); }, }; + diff --git a/src/types/golf.ts b/src/types/golf.ts index 925e171..1cfdda0 100644 --- a/src/types/golf.ts +++ b/src/types/golf.ts @@ -135,11 +135,13 @@ export interface Round { courseId: string; courseName: string; teeName: string; + teeId?: string; coursePar: number; holesPlayed: 18 | 9; nineType?: 'all' | 'front' | 'back'; scores: Record; isCompleted: boolean; + completedAt?: string; settingsSnapshot: TrackingSettings; playerHandicap: number; weather?: string; diff --git a/src/utils/achievements.ts b/src/utils/achievements.ts index 7c56476..7eb3254 100644 --- a/src/utils/achievements.ts +++ b/src/utils/achievements.ts @@ -1,4 +1,4 @@ -import { Achievement, Course, Round } from '../types/golf'; +import { Achievement, Course, Round, HoleScore } from '../types/golf'; import { calculateRoundStats } from './golfStats'; export const ALL_ACHIEVEMENTS: Achievement[] = [ @@ -212,6 +212,9 @@ export function evaluateAchievements( const unlockedMap = new Map(); rounds.forEach((round) => { + // Achievements are awarded only after finishing and saving the round + if (!round.isCompleted) return; + const course = courses.find((c) => c.id === round.courseId) || courses[0]; if (!course) return; @@ -438,3 +441,57 @@ export function evaluateAchievements( totalUnlockedCount: unlockedMap.size, }; } + +/** + * Evaluates live single-hole feats for provisional in-round awards (e.g. Hole in One, Eagle, Albatross). + */ +export function evaluateSingleHoleAchievements(hole: { par: number; holeNumber: number }, s: HoleScore): string[] { + const ids: string[] = []; + if (!s || !s.grossScore) return ids; + + const gross = s.grossScore; + const par = hole.par; + const diff = gross - par; + + // 1. Hole in One! + if (gross === 1) { + ids.push('ach_hole_in_one'); + } + + // 2. Albatross (3 under par) + if (diff <= -3 && gross > 0) { + ids.push('ach_albatross'); + } + + // 3. Eagle (2 under par) + if (diff === -2 && gross > 0) { + ids.push('ach_eagle_eye'); + } + + // 4. First Birdie (1 under par) + if (diff === -1) { + ids.push('ach_first_birdie'); + } + + // 5. Drove the Green (Par 4 drive reaching green) + if (par === 4 && s.gir === 'hit' && (gross - (s.putts || 0) === 1 || (s.putts && gross === s.putts + 1))) { + ids.push('ach_drove_the_green'); + } + + // 6. Fringe Whisperer + if (s.foregreenPutts && s.foregreenPutts > 0 && gross <= par) { + ids.push('ach_fringe_whisperer'); + } + + // 7. Clutch Sand Save + if (s.bunker?.hit && s.bunker?.sandSave === true && gross <= par) { + ids.push('ach_clutch_sand_save'); + } + + return ids; +} + +export function getAchievementById(id: string): Achievement | undefined { + return ALL_ACHIEVEMENTS.find((a) => a.id === id); +} +