From e164b1958c3a12ec3ce1b3e340d81cadc087e6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Stach?= Date: Fri, 9 Oct 2026 21:57:56 +0200 Subject: [PATCH] feat: add golf prototype with hole navigation and stats Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) --- backend/app.py | 255 ++++++++++++++++++++++++++++++++++++++++++++ frontend/app.js | 133 +++++++++++++++++++++++ frontend/index.html | 60 +++++++++++ 3 files changed, 448 insertions(+) create mode 100644 backend/app.py create mode 100644 frontend/app.js create mode 100644 frontend/index.html diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..7e52b62 --- /dev/null +++ b/backend/app.py @@ -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") diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..027fba6 --- /dev/null +++ b/frontend/app.js @@ -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 = ` +

Total Strokes: ${stats.total_strokes}

+

Total Putts: ${stats.total_putts}

+

Fairways Hit: ${stats.fairways_hit}

+

GIR: ${stats.gir}

+

Holes Played: ${stats.holes_played}

+ `; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b3f12b8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,60 @@ + + + + +Golf Prototype + + + +

Golf Score Card — Prototype

+ +
+

Sign Up

+
+
+ +
+ + + + + + + + + +