Initial commit

This commit is contained in:
Peter Stockings
2026-02-22 22:53:22 +11:00
commit ccdb3d8dc7
26 changed files with 2238 additions and 0 deletions

0
app/routes/__init__.py Normal file
View File

95
app/routes/api.py Normal file
View File

@@ -0,0 +1,95 @@
from flask import Blueprint, jsonify
from app.auth import login_required
from app.db import query
bp = Blueprint("api", __name__, url_prefix="/api")
@bp.route("/chart-data/<int:user_id>")
@login_required
def chart_data(user_id):
"""Return weight & BMI over time for Chart.js."""
checkins = query(
"""SELECT weight_kg, bmi, checked_in_at
FROM checkins WHERE user_id = %s
ORDER BY checked_in_at ASC""",
(user_id,),
)
labels = [c["checked_in_at"].strftime("%d %b") for c in checkins]
weights = [float(c["weight_kg"]) for c in checkins]
bmis = [float(c["bmi"]) if c["bmi"] else None for c in checkins]
return jsonify({
"labels": labels,
"weights": weights,
"bmis": bmis,
})
@bp.route("/comparison")
@login_required
def comparison():
"""Return all-user comparison data for bar charts."""
users = query("""
SELECT
u.id,
u.display_name,
u.username,
u.starting_weight_kg,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at ASC LIMIT 1) as first_weight,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight
FROM users u
WHERE (SELECT COUNT(*) FROM checkins WHERE user_id = u.id) > 0
ORDER BY u.display_name
""")
names = []
pct_lost = []
kg_lost = []
for u in users:
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
current_w = float(u["current_weight"] or start_w)
if start_w > 0:
lost = start_w - current_w
pct = round((lost / start_w) * 100, 1)
else:
lost = 0
pct = 0
names.append(u["display_name"] or u["username"])
pct_lost.append(pct)
kg_lost.append(round(lost, 1))
return jsonify({
"names": names,
"pct_lost": pct_lost,
"kg_lost": kg_lost,
})
@bp.route("/weekly-change/<int:user_id>")
@login_required
def weekly_change(user_id):
"""Return weekly weight changes for bar chart."""
checkins = query(
"""SELECT weight_kg, checked_in_at
FROM checkins WHERE user_id = %s
ORDER BY checked_in_at ASC""",
(user_id,),
)
if len(checkins) < 2:
return jsonify({"labels": [], "changes": []})
labels = []
changes = []
for i in range(1, len(checkins)):
prev_w = float(checkins[i - 1]["weight_kg"])
curr_w = float(checkins[i]["weight_kg"])
change = round(curr_w - prev_w, 1)
label = checkins[i]["checked_in_at"].strftime("%d %b")
labels.append(label)
changes.append(change)
return jsonify({"labels": labels, "changes": changes})

72
app/routes/auth.py Normal file
View File

@@ -0,0 +1,72 @@
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
from werkzeug.security import generate_password_hash, check_password_hash
from app.db import query_one, execute_returning, execute
bp = Blueprint("auth", __name__)
@bp.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
display_name = request.form.get("display_name", "").strip()
height_cm = request.form.get("height_cm") or None
age = request.form.get("age") or None
gender = request.form.get("gender") or None
goal_weight_kg = request.form.get("goal_weight_kg") or None
starting_weight_kg = request.form.get("starting_weight_kg") or None
# Validation
if not username or not password:
flash("Username and password are required.", "error")
return render_template("signup.html"), 400
if len(password) < 4:
flash("Password must be at least 4 characters.", "error")
return render_template("signup.html"), 400
# Check if username taken
existing = query_one("SELECT id FROM users WHERE username = %s", (username,))
if existing:
flash("Username already taken.", "error")
return render_template("signup.html"), 400
# Create user
password_hash = generate_password_hash(password)
user = execute_returning(
"""INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(username, password_hash, display_name or username, height_cm, age, gender, goal_weight_kg, starting_weight_kg),
)
session["user_id"] = user["id"]
flash("Welcome! You're all signed up.", "success")
return redirect(url_for("dashboard.index"))
return render_template("signup.html")
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = query_one("SELECT * FROM users WHERE username = %s", (username,))
if not user or not check_password_hash(user["password_hash"], password):
flash("Invalid username or password.", "error")
return render_template("login.html"), 401
session["user_id"] = user["id"]
next_url = request.args.get("next", url_for("dashboard.index"))
return redirect(next_url)
return render_template("login.html")
@bp.route("/logout")
def logout():
session.clear()
flash("You've been logged out.", "info")
return redirect(url_for("auth.login"))

115
app/routes/checkin.py Normal file
View File

@@ -0,0 +1,115 @@
import math
from flask import Blueprint, render_template, request, redirect, url_for, flash
from app.auth import login_required, get_current_user
from app.db import query, query_one, execute, execute_returning
bp = Blueprint("checkin", __name__)
def calculate_bmi(weight_kg, height_cm):
"""Calculate BMI from weight (kg) and height (cm)."""
if not weight_kg or not height_cm or float(height_cm) == 0:
return None
h_m = float(height_cm) / 100.0
return round(float(weight_kg) / (h_m * h_m), 1)
def check_milestones(user_id, user):
"""Check and award any new milestones after a check-in."""
checkins = query(
"SELECT weight_kg, checked_in_at FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC",
(user_id,),
)
if not checkins:
return
starting = float(user.get("starting_weight_kg") or checkins[0]["weight_kg"])
current = float(checkins[-1]["weight_kg"])
total_lost = starting - current
count = len(checkins)
milestone_checks = [
("first_checkin", count >= 1),
("5_checkins", count >= 5),
("10_checkins", count >= 10),
("25_checkins", count >= 25),
("lost_1kg", total_lost >= 1),
("lost_2kg", total_lost >= 2),
("lost_5kg", total_lost >= 5),
("lost_10kg", total_lost >= 10),
("lost_15kg", total_lost >= 15),
("lost_20kg", total_lost >= 20),
]
for key, achieved in milestone_checks:
if achieved:
try:
execute(
"INSERT INTO milestones (user_id, milestone_key) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, key),
)
except Exception:
pass
@bp.route("/checkin", methods=["GET"])
@login_required
def index():
user = get_current_user()
checkins = query(
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC",
(user["id"],),
)
return render_template("checkin.html", user=user, checkins=checkins)
@bp.route("/checkin", methods=["POST"])
@login_required
def create():
user = get_current_user()
weight_kg = request.form.get("weight_kg")
notes = request.form.get("notes", "").strip()
if not weight_kg:
flash("Weight is required.", "error")
return redirect(url_for("checkin.index"))
try:
weight_kg = float(weight_kg)
except ValueError:
flash("Invalid weight value.", "error")
return redirect(url_for("checkin.index"))
bmi = calculate_bmi(weight_kg, user.get("height_cm"))
checkin = execute_returning(
"""INSERT INTO checkins (user_id, weight_kg, bmi, notes)
VALUES (%s, %s, %s, %s) RETURNING *""",
(user["id"], weight_kg, bmi, notes or None),
)
# Check milestones
check_milestones(user["id"], user)
# If HTMX request, return just the new row
if request.headers.get("HX-Request"):
return render_template("partials/checkin_row.html", c=checkin, user=user)
flash("Check-in recorded!", "success")
return redirect(url_for("checkin.index"))
@bp.route("/checkin/<int:checkin_id>", methods=["DELETE"])
@login_required
def delete(checkin_id):
user = get_current_user()
execute(
"DELETE FROM checkins WHERE id = %s AND user_id = %s",
(checkin_id, user["id"]),
)
if request.headers.get("HX-Request"):
return ""
flash("Check-in deleted.", "info")
return redirect(url_for("checkin.index"))

71
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,71 @@
from flask import Blueprint, render_template
from app.auth import login_required, get_current_user
from app.db import query, query_one
bp = Blueprint("dashboard", __name__)
@bp.route("/")
@login_required
def index():
user = get_current_user()
# Get latest check-in
latest = query_one(
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 1",
(user["id"],),
)
# Get check-in count
stats = query_one(
"SELECT COUNT(*) as total_checkins FROM checkins WHERE user_id = %s",
(user["id"],),
)
# Calculate weight change
first_checkin = query_one(
"SELECT weight_kg FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC LIMIT 1",
(user["id"],),
)
weight_change = None
weight_change_pct = None
if latest and first_checkin:
start_w = float(first_checkin["weight_kg"])
current_w = float(latest["weight_kg"])
weight_change = round(current_w - start_w, 1)
if start_w > 0:
weight_change_pct = round((weight_change / start_w) * 100, 1)
# Recent check-ins (last 5)
recent_checkins = query(
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 5",
(user["id"],),
)
# Activity feed (recent check-ins from all users)
activity = query("""
SELECT c.*, u.display_name, u.username
FROM checkins c
JOIN users u ON c.user_id = u.id
ORDER BY c.checked_in_at DESC
LIMIT 10
""")
# Milestones
milestones = query(
"SELECT * FROM milestones WHERE user_id = %s ORDER BY achieved_at DESC",
(user["id"],),
)
return render_template(
"dashboard.html",
user=user,
latest=latest,
stats=stats,
weight_change=weight_change,
weight_change_pct=weight_change_pct,
recent_checkins=recent_checkins,
activity=activity,
milestones=milestones,
)

56
app/routes/leaderboard.py Normal file
View File

@@ -0,0 +1,56 @@
from flask import Blueprint, render_template
from app.auth import login_required
from app.db import query
bp = Blueprint("leaderboard", __name__)
@bp.route("/leaderboard")
@login_required
def index():
# Get all users with their weight stats
users = query("""
SELECT
u.id,
u.display_name,
u.username,
u.starting_weight_kg,
u.goal_weight_kg,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at ASC LIMIT 1) as first_weight,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight,
(SELECT COUNT(*) FROM checkins WHERE user_id = u.id) as total_checkins,
(SELECT checked_in_at FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as last_checkin
FROM users u
ORDER BY u.created_at
""")
# Calculate rankings
ranked = []
for u in users:
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
current_w = float(u["current_weight"] or start_w)
if start_w > 0:
weight_lost = start_w - current_w
pct_lost = round((weight_lost / start_w) * 100, 1)
else:
weight_lost = 0
pct_lost = 0
goal = float(u["goal_weight_kg"]) if u["goal_weight_kg"] else None
goal_progress = None
if goal and start_w > goal:
total_to_lose = start_w - goal
goal_progress = min(100, round((weight_lost / total_to_lose) * 100, 1)) if total_to_lose > 0 else 0
ranked.append({
**u,
"weight_lost": round(weight_lost, 1),
"pct_lost": pct_lost,
"goal_progress": goal_progress,
})
# Sort by % lost (descending)
ranked.sort(key=lambda x: x["pct_lost"], reverse=True)
return render_template("leaderboard.html", ranked=ranked)

40
app/routes/profile.py Normal file
View File

@@ -0,0 +1,40 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from app.auth import login_required, get_current_user
from app.db import execute
bp = Blueprint("profile", __name__)
@bp.route("/profile", methods=["GET"])
@login_required
def index():
user = get_current_user()
return render_template("profile.html", user=user)
@bp.route("/profile", methods=["POST"])
@login_required
def update():
user = get_current_user()
display_name = request.form.get("display_name", "").strip()
height_cm = request.form.get("height_cm") or None
age = request.form.get("age") or None
gender = request.form.get("gender") or None
goal_weight_kg = request.form.get("goal_weight_kg") or None
starting_weight_kg = request.form.get("starting_weight_kg") or None
execute(
"""UPDATE users
SET display_name = %s, height_cm = %s, age = %s, gender = %s,
goal_weight_kg = %s, starting_weight_kg = %s
WHERE id = %s""",
(display_name or user["username"], height_cm, age, gender,
goal_weight_kg, starting_weight_kg, user["id"]),
)
if request.headers.get("HX-Request"):
flash("Profile updated!", "success")
return render_template("profile.html", user=get_current_user())
flash("Profile updated!", "success")
return redirect(url_for("profile.index"))