diff --git a/app/__init__.py b/app/__init__.py index 358544e..00c011d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,10 +1,7 @@ -from datetime import datetime, timezone, timedelta from flask import Flask -from app.config import Config +from app.config import Config, SYDNEY_TZ from app.db import init_db, close_db -SYDNEY_TZ = timezone(timedelta(hours=11)) - def create_app(): app = Flask(__name__) @@ -17,12 +14,17 @@ def create_app(): # Jinja2 filter: convert UTC to Sydney time @app.template_filter('sydney') def sydney_time_filter(dt, fmt='%d %b %Y, %H:%M'): + from datetime import timezone if dt is None: return '' if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(SYDNEY_TZ).strftime(fmt) + # Make milestone labels available to all templates + from app.utils import MILESTONE_LABELS + app.jinja_env.globals['MILESTONE_LABELS'] = MILESTONE_LABELS + # Register blueprints from app.routes.auth import bp as auth_bp from app.routes.dashboard import bp as dashboard_bp diff --git a/app/auth.py b/app/auth.py index 4a22259..b3c733e 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,5 +1,5 @@ from functools import wraps -from flask import session, redirect, url_for, request +from flask import session, redirect, url_for, request, jsonify from app.db import query_one @@ -19,3 +19,19 @@ def get_current_user(): if user_id is None: return None return query_one("SELECT * FROM users WHERE id = %s", (user_id,)) + + +def privacy_guard(f): + """Decorator for API endpoints that take a user_id parameter. + + If the requested user is private and is not the current session user, + returns an empty JSON response instead of the actual data. + """ + @wraps(f) + def decorated_function(user_id, *args, **kwargs): + if user_id != session.get("user_id"): + target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,)) + if target and target["is_private"]: + return jsonify({}) + return f(user_id, *args, **kwargs) + return decorated_function diff --git a/app/config.py b/app/config.py index 9dc2dfc..9064e5f 100644 --- a/app/config.py +++ b/app/config.py @@ -1,8 +1,12 @@ import os +from datetime import timezone, timedelta from dotenv import load_dotenv load_dotenv() +# Application-wide timezone for display formatting +SYDNEY_TZ = timezone(timedelta(hours=11)) + class Config: DATABASE_URL = os.environ.get("DATABASE_URL") diff --git a/app/routes/api.py b/app/routes/api.py index cb74eeb..35f173a 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,9 +1,12 @@ -from flask import Blueprint, jsonify, request, session -from app import SYDNEY_TZ -from app.auth import login_required -from app.db import query, query_one +from collections import OrderedDict from datetime import datetime, timezone +from flask import Blueprint, jsonify, request, session +from app.config import SYDNEY_TZ +from app.auth import login_required, privacy_guard +from app.db import query +from app.utils import calculate_weight_change + bp = Blueprint("api", __name__, url_prefix="/api") # Distinct hues for up to 12 users; cycles if more @@ -58,7 +61,6 @@ def progress_over_time(): """, params) # Group rows by user - from collections import OrderedDict users_map = OrderedDict() for r in rows: uid = r["user_id"] @@ -90,7 +92,6 @@ def progress_over_time(): points = entry["data"] best_fit = {"slope": 0, "intercept": 0} if len(points) >= 2: - # Convert dates to day offsets from first point base = datetime.strptime(points[0]["date"], "%Y-%m-%d") xs = [(datetime.strptime(p["date"], "%Y-%m-%d") - base).days for p in points] ys = [p["weight"] for p in points] @@ -118,14 +119,9 @@ def progress_over_time(): @bp.route("/chart-data/") @login_required +@privacy_guard def chart_data(user_id): """Return weight & BMI over time for Chart.js.""" - # Privacy guard: don't expose private user data to others - if user_id != session.get("user_id"): - target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,)) - if target and target["is_private"]: - return jsonify({"labels": [], "weights": [], "bmis": []}) - checkins = query( """SELECT weight_kg, bmi, checked_in_at FROM checkins WHERE user_id = %s @@ -169,15 +165,10 @@ def comparison(): 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 + lost, pct = calculate_weight_change(start_w, current_w) names.append(u["display_name"] or u["username"]) pct_lost.append(pct) - kg_lost.append(round(lost, 1)) + kg_lost.append(lost) return jsonify({ "names": names, @@ -188,14 +179,9 @@ def comparison(): @bp.route("/weekly-change/") @login_required +@privacy_guard def weekly_change(user_id): """Return weekly weight changes for bar chart.""" - # Privacy guard: don't expose private user data to others - if user_id != session.get("user_id"): - target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,)) - if target and target["is_private"]: - return jsonify({"labels": [], "changes": []}) - checkins = query( """SELECT weight_kg, checked_in_at FROM checkins WHERE user_id = %s diff --git a/app/routes/auth.py b/app/routes/auth.py index b22267b..ff448c9 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,6 +1,7 @@ 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 +from app.db import query_one, execute_returning +from app.utils import parse_profile_fields bp = Blueprint("auth", __name__) @@ -10,13 +11,7 @@ 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 - is_private = request.form.get("is_private") == "on" + fields = parse_profile_fields(request.form) # Validation if not username or not password: @@ -38,7 +33,11 @@ def signup(): user = execute_returning( """INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg, is_private) VALUES (%s, %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, is_private), + (username, password_hash, + fields["display_name"] or username, + fields["height_cm"], fields["age"], fields["gender"], + fields["goal_weight_kg"], fields["starting_weight_kg"], + fields["is_private"]), ) session["user_id"] = user["id"] diff --git a/app/routes/checkin.py b/app/routes/checkin.py index 73a6115..a13c6dc 100644 --- a/app/routes/checkin.py +++ b/app/routes/checkin.py @@ -1,57 +1,11 @@ -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 +from app.utils import calculate_bmi, check_milestones 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(): @@ -88,7 +42,6 @@ def create(): (user["id"], weight_kg, bmi, notes or None), ) - # Check milestones check_milestones(user["id"], user) # If HTMX request, return just the new row diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index b09c6a7..0de76f3 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template from app.auth import login_required, get_current_user from app.db import query, query_one -from app.utils import calculate_streak +from app.utils import calculate_streak, calculate_weight_change bp = Blueprint("dashboard", __name__) @@ -32,11 +32,11 @@ def index(): 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) + kg_lost, pct_lost = calculate_weight_change( + first_checkin["weight_kg"], latest["weight_kg"] + ) + weight_change = round(-kg_lost, 1) # negative = gained, positive = lost + weight_change_pct = round(-pct_lost, 1) # Recent check-ins (last 5) recent_checkins = query( @@ -75,4 +75,3 @@ def index(): milestones=milestones, streak=streak, ) - diff --git a/app/routes/leaderboard.py b/app/routes/leaderboard.py index ef6c177..35f52c2 100644 --- a/app/routes/leaderboard.py +++ b/app/routes/leaderboard.py @@ -1,8 +1,8 @@ from flask import Blueprint, render_template from app.auth import login_required from app.db import query, query_one -from app import SYDNEY_TZ -from app.utils import calculate_streak +from app.config import SYDNEY_TZ +from app.utils import calculate_streak, calculate_weight_change from datetime import timezone bp = Blueprint("leaderboard", __name__) @@ -33,13 +33,7 @@ def index(): 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 + weight_lost, pct_lost = calculate_weight_change(start_w, current_w) goal = float(u["goal_weight_kg"]) if u["goal_weight_kg"] else None goal_progress = None @@ -50,7 +44,7 @@ def index(): streak = calculate_streak(u["id"]) ranked.append({ **u, - "weight_lost": round(weight_lost, 1), + "weight_lost": weight_lost, "pct_lost": pct_lost, "goal_progress": goal_progress, "streak": streak["current"], diff --git a/app/routes/profile.py b/app/routes/profile.py index c00a493..faa0fd5 100644 --- a/app/routes/profile.py +++ b/app/routes/profile.py @@ -1,6 +1,7 @@ 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 +from app.utils import parse_profile_fields bp = Blueprint("profile", __name__) @@ -16,21 +17,17 @@ def index(): @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 - is_private = request.form.get("is_private") == "on" + fields = parse_profile_fields(request.form) execute( """UPDATE users SET display_name = %s, height_cm = %s, age = %s, gender = %s, goal_weight_kg = %s, starting_weight_kg = %s, is_private = %s WHERE id = %s""", - (display_name or user["username"], height_cm, age, gender, - goal_weight_kg, starting_weight_kg, is_private, user["id"]), + (fields["display_name"] or user["username"], + fields["height_cm"], fields["age"], fields["gender"], + fields["goal_weight_kg"], fields["starting_weight_kg"], + fields["is_private"], user["id"]), ) if request.headers.get("HX-Request"): diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 73867ba..7a729b3 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -103,18 +103,7 @@
{% for m in milestones %} - {% if m.milestone_key == 'first_checkin' %}✅ First Check-in - {% elif m.milestone_key == '5_checkins' %}🔥 5 Check-ins - {% elif m.milestone_key == '10_checkins' %}💪 10 Check-ins - {% elif m.milestone_key == '25_checkins' %}🎯 25 Check-ins - {% elif m.milestone_key == 'lost_1kg' %}⭐ 1kg Lost - {% elif m.milestone_key == 'lost_2kg' %}⭐ 2kg Lost - {% elif m.milestone_key == 'lost_5kg' %}🌟 5kg Lost - {% elif m.milestone_key == 'lost_10kg' %}💎 10kg Lost - {% elif m.milestone_key == 'lost_15kg' %}👑 15kg Lost - {% elif m.milestone_key == 'lost_20kg' %}🏆 20kg Lost - {% else %}{{ m.milestone_key }} - {% endif %} + {{ MILESTONE_LABELS.get(m.milestone_key, m.milestone_key) }} {% endfor %}
diff --git a/app/utils.py b/app/utils.py index 729a186..585c0c1 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,8 +1,71 @@ -from app.db import query -from app import SYDNEY_TZ +""" +Shared business-logic helpers. + +Keep route handlers thin — calculation logic lives here. +""" + +from app.db import query, execute +from app.config import SYDNEY_TZ from datetime import datetime, timedelta +# --------------------------------------------------------------------------- +# Milestones — single source of truth for keys, thresholds, and labels +# --------------------------------------------------------------------------- + +MILESTONES = [ + # (key, check_fn_or_description, emoji + display label) + ("first_checkin", "count >= 1", "✅ First Check-in"), + ("5_checkins", "count >= 5", "🔥 5 Check-ins"), + ("10_checkins", "count >= 10", "💪 10 Check-ins"), + ("25_checkins", "count >= 25", "🎯 25 Check-ins"), + ("lost_1kg", "lost >= 1", "⭐ 1kg Lost"), + ("lost_2kg", "lost >= 2", "⭐ 2kg Lost"), + ("lost_5kg", "lost >= 5", "🌟 5kg Lost"), + ("lost_10kg", "lost >= 10", "💎 10kg Lost"), + ("lost_15kg", "lost >= 15", "👑 15kg Lost"), + ("lost_20kg", "lost >= 20", "🏆 20kg Lost"), +] + +# Quick lookup: milestone_key → display label (used in templates) +MILESTONE_LABELS = {key: label for key, _, label in MILESTONES} + + +# --------------------------------------------------------------------------- +# Weight calculations +# --------------------------------------------------------------------------- + +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 calculate_weight_change(start_w, current_w): + """Return (kg_lost, pct_lost) from a start and current weight. + + kg_lost is positive when weight decreased. + pct_lost is the percentage of start weight that was lost. + """ + start_w = float(start_w or 0) + current_w = float(current_w or start_w) + + if start_w > 0: + kg_lost = round(start_w - current_w, 1) + pct_lost = round((kg_lost / start_w) * 100, 1) + else: + kg_lost = 0.0 + pct_lost = 0.0 + + return kg_lost, pct_lost + + +# --------------------------------------------------------------------------- +# Streaks +# --------------------------------------------------------------------------- + def calculate_streak(user_id): """Calculate current and best consecutive-day check-in streaks.""" rows = query( @@ -40,3 +103,65 @@ def calculate_streak(user_id): best = max(best, current) return {"current": current, "best": best} + + +# --------------------------------------------------------------------------- +# Milestone checker +# --------------------------------------------------------------------------- + +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 + + +# --------------------------------------------------------------------------- +# Form helpers +# --------------------------------------------------------------------------- + +def parse_profile_fields(form): + """Extract the common profile fields from a request form. + + Returns a dict suitable for both signup and profile-update flows. + """ + return { + "display_name": form.get("display_name", "").strip(), + "height_cm": form.get("height_cm") or None, + "age": form.get("age") or None, + "gender": form.get("gender") or None, + "goal_weight_kg": form.get("goal_weight_kg") or None, + "starting_weight_kg": form.get("starting_weight_kg") or None, + "is_private": form.get("is_private") == "on", + }