Compare commits

...
8 Commits
Author SHA1 Message Date
Jurina b274363c15 compose 2026-10-13 22:11:07 +02:00
Jurina 3ec61915f7 docker-compose edit 2026-10-13 22:02:06 +02:00
Jiří Stach 87be71e23f Added env variables for db to docker-compse.yaml 2026-10-13 21:56:15 +02:00
Jiří Stach d6742fcf21 Removed version from docker-compose file. To get rid of the warning. 2026-10-13 21:27:48 +02:00
Jiří Stach 7ec8f68440 Edited docker so it will build. 2026-10-13 21:24:04 +02:00
Jiří Stach 449ef46c84 Added all new changes from main github repo. 2026-10-13 21:16:42 +02:00
Jiří Stach 564975d0e3 Remove version from docker-compoe.yaml file 2026-10-12 22:06:24 +02:00
Jiří Stachandaider 0bd901e890 fix: switch Dockerfile to bun and copy bun.lock
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-12 21:59:40 +02:00
30 changed files with 2298 additions and 524 deletions

No files matched your search

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