feat: add golf prototype with hole navigation and stats
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
This commit is contained in:
1 parent
6be02b9a23
commit
e164b1958c
3 files changed
+448
No files matched your search
+255
@@ -0,0 +1,255 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
|
||||
import os
|
||||
|
||||
DATABASE_URL = "sqlite:///./backend/golf.db"
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
handicap = Column(Integer, default=0)
|
||||
|
||||
class Course(Base):
|
||||
__tablename__ = "courses"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
|
||||
class Hole(Base):
|
||||
__tablename__ = "holes"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
course_id = Column(Integer, ForeignKey("courses.id"))
|
||||
number = Column(Integer, nullable=False)
|
||||
par = Column(Integer, nullable=False)
|
||||
yardage = Column(Integer, nullable=False)
|
||||
|
||||
class Round(Base):
|
||||
__tablename__ = "rounds"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
course_id = Column(Integer, ForeignKey("courses.id"))
|
||||
status = Column(String, default="in_progress")
|
||||
|
||||
class HoleScore(Base):
|
||||
__tablename__ = "hole_scores"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
round_id = Column(Integer, ForeignKey("rounds.id"))
|
||||
hole_number = Column(Integer, nullable=False)
|
||||
strokes = Column(Integer, default=0)
|
||||
skipped = Column(Boolean, default=False)
|
||||
completed = Column(Boolean, default=False)
|
||||
fairway_hit = Column(Boolean, default=False)
|
||||
gir = Column(Boolean, default=False)
|
||||
|
||||
class Putt(Base):
|
||||
__tablename__ = "putts"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
hole_score_id = Column(Integer, ForeignKey("hole_scores.id"))
|
||||
location = Column(String, nullable=False)
|
||||
|
||||
class Penalty(Base):
|
||||
__tablename__ = "penalties"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
hole_score_id = Column(Integer, ForeignKey("hole_scores.id"))
|
||||
type = Column(String, nullable=False)
|
||||
|
||||
class Bunker(Base):
|
||||
__tablename__ = "bunkers"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
hole_score_id = Column(Integer, ForeignKey("hole_scores.id"))
|
||||
location = Column(String, nullable=False)
|
||||
|
||||
User.rounds = relationship("Round", back_populates="user")
|
||||
Round.user = relationship("User", back_populates="rounds")
|
||||
Round.hole_scores = relationship("HoleScore", back_populates="round")
|
||||
HoleScore.round = relationship("Round", back_populates="hole_scores")
|
||||
HoleScore.putts = relationship("Putt", back_populates="hole_score")
|
||||
Putt.hole_score = relationship("HoleScore", back_populates="putts")
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
def init_mock():
|
||||
db = SessionLocal()
|
||||
if db.query(Course).first():
|
||||
db.close()
|
||||
return
|
||||
course = Course(name="Mock Valley")
|
||||
db.add(course)
|
||||
db.commit()
|
||||
db.refresh(course)
|
||||
holes = [
|
||||
Hole(course_id=course.id, number=1, par=4, yardage=350),
|
||||
Hole(course_id=course.id, number=2, par=3, yardage=180),
|
||||
Hole(course_id=course.id, number=3, par=5, yardage=520),
|
||||
Hole(course_id=course.id, number=4, par=4, yardage=390),
|
||||
Hole(course_id=course.id, number=5, par=3, yardage=160),
|
||||
Hole(course_id=course.id, number=6, par=4, yardage=410),
|
||||
Hole(course_id=course.id, number=7, par=5, yardage=540),
|
||||
Hole(course_id=course.id, number=8, par=4, yardage=370),
|
||||
Hole(course_id=course.id, number=9, par=3, yardage=190),
|
||||
]
|
||||
db.add_all(holes)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
init_mock()
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
name: str
|
||||
handicap: int = 0
|
||||
|
||||
@app.post("/signup")
|
||||
def signup(req: SignupRequest):
|
||||
db = SessionLocal()
|
||||
user = User(name=req.name, handicap=req.handicap)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
db.close()
|
||||
return {"id": user.id, "name": user.name, "handicap": user.handicap}
|
||||
|
||||
class StartRoundRequest(BaseModel):
|
||||
user_id: int
|
||||
course_id: int
|
||||
|
||||
@app.post("/round")
|
||||
def start_round(req: StartRoundRequest):
|
||||
db = SessionLocal()
|
||||
round_obj = Round(user_id=req.user_id, course_id=req.course_id, status="in_progress")
|
||||
db.add(round_obj)
|
||||
db.commit()
|
||||
db.refresh(round_obj)
|
||||
holes = db.query(Hole).filter(Hole.course_id == req.course_id).all()
|
||||
for h in holes:
|
||||
hs = HoleScore(round_id=round_obj.id, hole_number=h.number, strokes=0, skipped=False, completed=False, fairway_hit=False, gir=False)
|
||||
db.add(hs)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"id": round_obj.id, "status": round_obj.status}
|
||||
|
||||
@app.get("/course")
|
||||
def get_course():
|
||||
db = SessionLocal()
|
||||
course = db.query(Course).first()
|
||||
holes = db.query(Hole).filter(Hole.course_id == course.id).all()
|
||||
db.close()
|
||||
return {
|
||||
"id": course.id,
|
||||
"name": course.name,
|
||||
"holes": [{"number": h.number, "par": h.par, "yardage": h.yardage} for h in holes]
|
||||
}
|
||||
|
||||
@app.get("/round/{round_id}")
|
||||
def get_round(round_id: int):
|
||||
db = SessionLocal()
|
||||
round_obj = db.query(Round).filter(Round.id == round_id).first()
|
||||
if not round_obj:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Round not found")
|
||||
hole_scores = db.query(HoleScore).filter(HoleScore.round_id == round_id).all()
|
||||
result = {
|
||||
"id": round_obj.id,
|
||||
"user_id": round_obj.user_id,
|
||||
"course_id": round_obj.course_id,
|
||||
"status": round_obj.status,
|
||||
"holes": []
|
||||
}
|
||||
for hs in hole_scores:
|
||||
putts = db.query(Putt).filter(Putt.hole_score_id == hs.id).all()
|
||||
penalties = db.query(Penalty).filter(Penalty.hole_score_id == hs.id).all()
|
||||
bunkers = db.query(Bunker).filter(Bunker.hole_score_id == hs.id).all()
|
||||
result["holes"].append({
|
||||
"hole_score_id": hs.id,
|
||||
"hole_number": hs.hole_number,
|
||||
"strokes": hs.strokes,
|
||||
"skipped": hs.skipped,
|
||||
"completed": hs.completed,
|
||||
"fairway_hit": hs.fairway_hit,
|
||||
"gir": hs.gir,
|
||||
"putts": [{"location": p.location} for p in putts],
|
||||
"penalties": [{"type": p.type} for p in penalties],
|
||||
"bunkers": [{"location": b.location} for b in bunkers]
|
||||
})
|
||||
db.close()
|
||||
return result
|
||||
|
||||
class UpdateHoleRequest(BaseModel):
|
||||
strokes: int = 0
|
||||
skipped: bool = False
|
||||
completed: bool = False
|
||||
fairway_hit: bool = False
|
||||
putts: list = []
|
||||
penalties: list = []
|
||||
bunkers: list = []
|
||||
|
||||
@app.post("/hole/{hole_score_id}")
|
||||
def update_hole(hole_score_id: int, req: UpdateHoleRequest):
|
||||
db = SessionLocal()
|
||||
hs = db.query(HoleScore).filter(HoleScore.id == hole_score_id).first()
|
||||
if not hs:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Hole score not found")
|
||||
hs.strokes = req.strokes
|
||||
hs.skipped = req.skipped
|
||||
hs.completed = req.completed
|
||||
hs.fairway_hit = req.fairway_hit
|
||||
|
||||
db.query(Putt).filter(Putt.hole_score_id == hole_score_id).delete()
|
||||
db.query(Penalty).filter(Penalty.hole_score_id == hole_score_id).delete()
|
||||
db.query(Bunker).filter(Bunker.hole_score_id == hole_score_id).delete()
|
||||
|
||||
for p in req.putts:
|
||||
db.add(Putt(hole_score_id=hole_score_id, location=p.get("location", "green")))
|
||||
for pen in req.penalties:
|
||||
db.add(Penalty(hole_score_id=hole_score_id, type=pen.get("type", "water")))
|
||||
for b in req.bunkers:
|
||||
db.add(Bunker(hole_score_id=hole_score_id, location=b.get("location", "greenside")))
|
||||
|
||||
putts = db.query(Putt).filter(Putt.hole_score_id == hole_score_id).all()
|
||||
putts_count = len(putts)
|
||||
round_obj = db.query(Round).filter(Round.id == hs.round_id).first()
|
||||
hole = db.query(Hole).filter(Hole.course_id == round_obj.course_id, Hole.number == hs.hole_number).first()
|
||||
if hole:
|
||||
all_green = all(p.location == "green" for p in putts) if putts else True
|
||||
hs.gir = (hs.strokes - putts_count) <= (hole.par - 2) and all_green
|
||||
|
||||
db.commit()
|
||||
db.refresh(hs)
|
||||
db.close()
|
||||
return {"hole_score_id": hs.id, "gir": hs.gir, "strokes": hs.strokes, "putts": putts_count}
|
||||
|
||||
@app.get("/stats/{round_id}")
|
||||
def get_stats(round_id: int):
|
||||
db = SessionLocal()
|
||||
hs_list = db.query(HoleScore).filter(HoleScore.round_id == round_id).all()
|
||||
total_strokes = sum(hs.strokes for hs in hs_list if not hs.skipped)
|
||||
total_putts = 0
|
||||
fairways = 0
|
||||
girs = 0
|
||||
for hs in hs_list:
|
||||
if hs.skipped:
|
||||
continue
|
||||
putts = db.query(Putt).filter(Putt.hole_score_id == hs.id).all()
|
||||
total_putts += len(putts)
|
||||
if hs.fairway_hit:
|
||||
fairways += 1
|
||||
if hs.gir:
|
||||
girs += 1
|
||||
db.close()
|
||||
return {
|
||||
"total_strokes": total_strokes,
|
||||
"total_putts": total_putts,
|
||||
"fairways_hit": fairways,
|
||||
"gir": girs,
|
||||
"holes_played": len([h for h in hs_list if not h.skipped])
|
||||
}
|
||||
|
||||
app.mount("/", StaticFiles(directory="frontend", html=True), name="static")
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
let currentUser = null;
|
||||
let currentRound = null;
|
||||
let currentData = null;
|
||||
let currentIndex = 0;
|
||||
|
||||
async function signup() {
|
||||
const name = document.getElementById('name').value;
|
||||
const hcp = parseInt(document.getElementById('hcp').value) || 0;
|
||||
const res = await fetch('/signup', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name, handicap: hcp})
|
||||
});
|
||||
currentUser = await res.json();
|
||||
document.getElementById('signup-section').style.display = 'none';
|
||||
document.getElementById('start-section').style.display = 'block';
|
||||
loadCourse();
|
||||
}
|
||||
|
||||
async function loadCourse() {
|
||||
const res = await fetch('/course');
|
||||
const course = await res.json();
|
||||
document.getElementById('course-info').textContent = `${course.name} — ${course.holes.length} holes`;
|
||||
}
|
||||
|
||||
async function startRound() {
|
||||
const res = await fetch('/course');
|
||||
const course = await res.json();
|
||||
const res2 = await fetch('/round', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({user_id: currentUser.id, course_id: course.id})
|
||||
});
|
||||
currentRound = await res2.json();
|
||||
document.getElementById('start-section').style.display = 'none';
|
||||
document.getElementById('round-section').style.display = 'block';
|
||||
document.getElementById('stats-section').style.display = 'block';
|
||||
loadRound();
|
||||
}
|
||||
|
||||
async function loadRound() {
|
||||
const res = await fetch(`/round/${currentRound.id}`);
|
||||
currentData = await res.json();
|
||||
const holes = currentData.holes.sort((a,b) => a.hole_number - b.hole_number);
|
||||
if (holes.length === 0) return;
|
||||
if (currentIndex >= holes.length) currentIndex = 0;
|
||||
renderHole(holes[currentIndex]);
|
||||
renderNav(holes);
|
||||
loadStats();
|
||||
}
|
||||
|
||||
function renderHole(hole) {
|
||||
document.getElementById('hole-score-id').value = hole.hole_score_id;
|
||||
document.getElementById('hole-label').textContent = `Hole ${hole.hole_number}`;
|
||||
document.getElementById('strokes').value = hole.strokes || 0;
|
||||
document.getElementById('fairway').checked = hole.fairway_hit;
|
||||
document.getElementById('skipped').checked = hole.skipped;
|
||||
document.getElementById('completed').checked = hole.completed;
|
||||
document.getElementById('gir-display').textContent = hole.gir ? 'Yes' : 'No';
|
||||
|
||||
const putts = hole.putts || [];
|
||||
document.getElementById('putts-input').value = putts.map(p => p.location).join(',');
|
||||
|
||||
const pens = (hole.penalties || []).map(p => p.type).join(',');
|
||||
document.getElementById('penalties-input').value = pens;
|
||||
|
||||
const buns = (hole.bunkers || []).map(b => b.location).join(',');
|
||||
document.getElementById('bunkers-input').value = buns;
|
||||
}
|
||||
|
||||
function renderNav(holes) {
|
||||
document.getElementById('prev-btn').disabled = currentIndex <= 0;
|
||||
document.getElementById('next-btn').disabled = currentIndex >= holes.length - 1;
|
||||
}
|
||||
|
||||
function prevHole() {
|
||||
if (currentIndex > 0) {
|
||||
currentIndex--;
|
||||
const holes = currentData.holes.sort((a,b) => a.hole_number - b.hole_number);
|
||||
renderHole(holes[currentIndex]);
|
||||
renderNav(holes);
|
||||
}
|
||||
}
|
||||
|
||||
function nextHole() {
|
||||
const holes = currentData.holes.sort((a,b) => a.hole_number - b.hole_number);
|
||||
if (currentIndex < holes.length - 1) {
|
||||
currentIndex++;
|
||||
renderHole(holes[currentIndex]);
|
||||
renderNav(holes);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHole() {
|
||||
const id = document.getElementById('hole-score-id').value;
|
||||
const strokes = parseInt(document.getElementById('strokes').value) || 0;
|
||||
const fairway = document.getElementById('fairway').checked;
|
||||
const skipped = document.getElementById('skipped').checked;
|
||||
const completed = document.getElementById('completed').checked;
|
||||
|
||||
const puttsStr = document.getElementById('putts-input').value;
|
||||
const putts = puttsStr.split(',').filter(s => s.trim()).map(s => ({location: s.trim()}));
|
||||
|
||||
const pensStr = document.getElementById('penalties-input').value;
|
||||
const penalties = pensStr.split(',').filter(s => s.trim()).map(s => ({type: s.trim()}));
|
||||
|
||||
const bunsStr = document.getElementById('bunkers-input').value;
|
||||
const bunkers = bunsStr.split(',').filter(s => s.trim()).map(s => ({location: s.trim()}));
|
||||
|
||||
const res = await fetch(`/hole/${id}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
strokes, skipped, completed, fairway_hit: fairway,
|
||||
putts, penalties, bunkers
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
document.getElementById('gir-display').textContent = result.gir ? 'Yes' : 'No';
|
||||
loadRound();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const res = await fetch(`/stats/${currentRound.id}`);
|
||||
const stats = await res.json();
|
||||
document.getElementById('stats-display').innerHTML = `
|
||||
<p><strong>Total Strokes:</strong> ${stats.total_strokes}</p>
|
||||
<p><strong>Total Putts:</strong> ${stats.total_putts}</p>
|
||||
<p><strong>Fairways Hit:</strong> ${stats.fairways_hit}</p>
|
||||
<p><strong>GIR:</strong> ${stats.gir}</p>
|
||||
<p><strong>Holes Played:</strong> ${stats.holes_played}</p>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Golf Prototype</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #222; }
|
||||
section { margin-bottom: 1.5rem; padding: 1rem; border: 1px solid #ddd; border-radius: 8px; background: #fafafa; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
label { display: inline-block; margin: 0.25rem 0; }
|
||||
input, select { padding: 0.25rem; }
|
||||
button { padding: 0.4rem 0.8rem; margin-right: 0.5rem; cursor: pointer; }
|
||||
#stats-display p { margin: 0.2rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Golf Score Card — Prototype</h1>
|
||||
|
||||
<section id="signup-section">
|
||||
<h2>Sign Up</h2>
|
||||
<label>Name: <input id="name" type="text" placeholder="Your name" /></label><br>
|
||||
<label>Handicap: <input id="hcp" type="number" value="0" /></label><br>
|
||||
<button onclick="signup()">Sign Up</button>
|
||||
</section>
|
||||
|
||||
<section id="start-section" style="display:none;">
|
||||
<h2>Course</h2>
|
||||
<p id="course-info"></p>
|
||||
<button onclick="startRound()">Start Round</button>
|
||||
</section>
|
||||
|
||||
<section id="round-section" style="display:none;">
|
||||
<h2>Round</h2>
|
||||
<div>
|
||||
<button id="prev-btn" onclick="prevHole()">Prev</button>
|
||||
<strong id="hole-label">Hole 1</strong>
|
||||
<button id="next-btn" onclick="nextHole()">Next</button>
|
||||
</div>
|
||||
<form id="hole-form" onsubmit="event.preventDefault(); saveHole();">
|
||||
<input type="hidden" id="hole-score-id" />
|
||||
<label>Strokes: <input id="strokes" type="number" value="0" min="0" /></label><br>
|
||||
<label>Fairway Hit: <input id="fairway" type="checkbox" /></label><br>
|
||||
<label>Skipped: <input id="skipped" type="checkbox" /></label><br>
|
||||
<label>Completed: <input id="completed" type="checkbox" /></label><br>
|
||||
<label>Putts (comma list): <input id="putts-input" type="text" placeholder="green,green" /></label><br>
|
||||
<label>Penalties (comma list): <input id="penalties-input" type="text" placeholder="water" /></label><br>
|
||||
<label>Bunkers (comma list): <input id="bunkers-input" type="text" placeholder="greenside" /></label><br>
|
||||
<button type="submit">Save Hole</button>
|
||||
</form>
|
||||
<p>GIR: <strong id="gir-display">No</strong></p>
|
||||
</section>
|
||||
|
||||
<section id="stats-section" style="display:none;">
|
||||
<h2>Basic Stats</h2>
|
||||
<div id="stats-display"></div>
|
||||
</section>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user