Refactor codebase
This commit is contained in:
@@ -1,10 +1,7 @@
|
|||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from app.config import Config
|
from app.config import Config, SYDNEY_TZ
|
||||||
from app.db import init_db, close_db
|
from app.db import init_db, close_db
|
||||||
|
|
||||||
SYDNEY_TZ = timezone(timedelta(hours=11))
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -17,12 +14,17 @@ def create_app():
|
|||||||
# Jinja2 filter: convert UTC to Sydney time
|
# Jinja2 filter: convert UTC to Sydney time
|
||||||
@app.template_filter('sydney')
|
@app.template_filter('sydney')
|
||||||
def sydney_time_filter(dt, fmt='%d %b %Y, %H:%M'):
|
def sydney_time_filter(dt, fmt='%d %b %Y, %H:%M'):
|
||||||
|
from datetime import timezone
|
||||||
if dt is None:
|
if dt is None:
|
||||||
return ''
|
return ''
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
return dt.astimezone(SYDNEY_TZ).strftime(fmt)
|
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
|
# Register blueprints
|
||||||
from app.routes.auth import bp as auth_bp
|
from app.routes.auth import bp as auth_bp
|
||||||
from app.routes.dashboard import bp as dashboard_bp
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
|
|||||||
18
app/auth.py
18
app/auth.py
@@ -1,5 +1,5 @@
|
|||||||
from functools import wraps
|
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
|
from app.db import query_one
|
||||||
|
|
||||||
|
|
||||||
@@ -19,3 +19,19 @@ def get_current_user():
|
|||||||
if user_id is None:
|
if user_id is None:
|
||||||
return None
|
return None
|
||||||
return query_one("SELECT * FROM users WHERE id = %s", (user_id,))
|
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
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import timezone, timedelta
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Application-wide timezone for display formatting
|
||||||
|
SYDNEY_TZ = timezone(timedelta(hours=11))
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
DATABASE_URL = os.environ.get("DATABASE_URL")
|
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from flask import Blueprint, jsonify, request, session
|
from collections import OrderedDict
|
||||||
from app import SYDNEY_TZ
|
|
||||||
from app.auth import login_required
|
|
||||||
from app.db import query, query_one
|
|
||||||
from datetime import datetime, timezone
|
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")
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
# Distinct hues for up to 12 users; cycles if more
|
# Distinct hues for up to 12 users; cycles if more
|
||||||
@@ -58,7 +61,6 @@ def progress_over_time():
|
|||||||
""", params)
|
""", params)
|
||||||
|
|
||||||
# Group rows by user
|
# Group rows by user
|
||||||
from collections import OrderedDict
|
|
||||||
users_map = OrderedDict()
|
users_map = OrderedDict()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
uid = r["user_id"]
|
uid = r["user_id"]
|
||||||
@@ -90,7 +92,6 @@ def progress_over_time():
|
|||||||
points = entry["data"]
|
points = entry["data"]
|
||||||
best_fit = {"slope": 0, "intercept": 0}
|
best_fit = {"slope": 0, "intercept": 0}
|
||||||
if len(points) >= 2:
|
if len(points) >= 2:
|
||||||
# Convert dates to day offsets from first point
|
|
||||||
base = datetime.strptime(points[0]["date"], "%Y-%m-%d")
|
base = datetime.strptime(points[0]["date"], "%Y-%m-%d")
|
||||||
xs = [(datetime.strptime(p["date"], "%Y-%m-%d") - base).days for p in points]
|
xs = [(datetime.strptime(p["date"], "%Y-%m-%d") - base).days for p in points]
|
||||||
ys = [p["weight"] for p in points]
|
ys = [p["weight"] for p in points]
|
||||||
@@ -118,14 +119,9 @@ def progress_over_time():
|
|||||||
|
|
||||||
@bp.route("/chart-data/<int:user_id>")
|
@bp.route("/chart-data/<int:user_id>")
|
||||||
@login_required
|
@login_required
|
||||||
|
@privacy_guard
|
||||||
def chart_data(user_id):
|
def chart_data(user_id):
|
||||||
"""Return weight & BMI over time for Chart.js."""
|
"""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(
|
checkins = query(
|
||||||
"""SELECT weight_kg, bmi, checked_in_at
|
"""SELECT weight_kg, bmi, checked_in_at
|
||||||
FROM checkins WHERE user_id = %s
|
FROM checkins WHERE user_id = %s
|
||||||
@@ -169,15 +165,10 @@ def comparison():
|
|||||||
for u in users:
|
for u in users:
|
||||||
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
||||||
current_w = float(u["current_weight"] or start_w)
|
current_w = float(u["current_weight"] or start_w)
|
||||||
if start_w > 0:
|
lost, pct = calculate_weight_change(start_w, current_w)
|
||||||
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"])
|
names.append(u["display_name"] or u["username"])
|
||||||
pct_lost.append(pct)
|
pct_lost.append(pct)
|
||||||
kg_lost.append(round(lost, 1))
|
kg_lost.append(lost)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"names": names,
|
"names": names,
|
||||||
@@ -188,14 +179,9 @@ def comparison():
|
|||||||
|
|
||||||
@bp.route("/weekly-change/<int:user_id>")
|
@bp.route("/weekly-change/<int:user_id>")
|
||||||
@login_required
|
@login_required
|
||||||
|
@privacy_guard
|
||||||
def weekly_change(user_id):
|
def weekly_change(user_id):
|
||||||
"""Return weekly weight changes for bar chart."""
|
"""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(
|
checkins = query(
|
||||||
"""SELECT weight_kg, checked_in_at
|
"""SELECT weight_kg, checked_in_at
|
||||||
FROM checkins WHERE user_id = %s
|
FROM checkins WHERE user_id = %s
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
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__)
|
bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
@@ -10,13 +11,7 @@ def signup():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
display_name = request.form.get("display_name", "").strip()
|
fields = parse_profile_fields(request.form)
|
||||||
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"
|
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
@@ -38,7 +33,11 @@ def signup():
|
|||||||
user = execute_returning(
|
user = execute_returning(
|
||||||
"""INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg, is_private)
|
"""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""",
|
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"]
|
session["user_id"] = user["id"]
|
||||||
|
|||||||
@@ -1,57 +1,11 @@
|
|||||||
import math
|
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from app.auth import login_required, get_current_user
|
from app.auth import login_required, get_current_user
|
||||||
from app.db import query, query_one, execute, execute_returning
|
from app.db import query, query_one, execute, execute_returning
|
||||||
|
from app.utils import calculate_bmi, check_milestones
|
||||||
|
|
||||||
bp = Blueprint("checkin", __name__)
|
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"])
|
@bp.route("/checkin", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
@@ -88,7 +42,6 @@ def create():
|
|||||||
(user["id"], weight_kg, bmi, notes or None),
|
(user["id"], weight_kg, bmi, notes or None),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check milestones
|
|
||||||
check_milestones(user["id"], user)
|
check_milestones(user["id"], user)
|
||||||
|
|
||||||
# If HTMX request, return just the new row
|
# If HTMX request, return just the new row
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
from app.auth import login_required, get_current_user
|
from app.auth import login_required, get_current_user
|
||||||
from app.db import query, query_one
|
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__)
|
bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
@@ -32,11 +32,11 @@ def index():
|
|||||||
weight_change = None
|
weight_change = None
|
||||||
weight_change_pct = None
|
weight_change_pct = None
|
||||||
if latest and first_checkin:
|
if latest and first_checkin:
|
||||||
start_w = float(first_checkin["weight_kg"])
|
kg_lost, pct_lost = calculate_weight_change(
|
||||||
current_w = float(latest["weight_kg"])
|
first_checkin["weight_kg"], latest["weight_kg"]
|
||||||
weight_change = round(current_w - start_w, 1)
|
)
|
||||||
if start_w > 0:
|
weight_change = round(-kg_lost, 1) # negative = gained, positive = lost
|
||||||
weight_change_pct = round((weight_change / start_w) * 100, 1)
|
weight_change_pct = round(-pct_lost, 1)
|
||||||
|
|
||||||
# Recent check-ins (last 5)
|
# Recent check-ins (last 5)
|
||||||
recent_checkins = query(
|
recent_checkins = query(
|
||||||
@@ -75,4 +75,3 @@ def index():
|
|||||||
milestones=milestones,
|
milestones=milestones,
|
||||||
streak=streak,
|
streak=streak,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
from app.auth import login_required
|
from app.auth import login_required
|
||||||
from app.db import query, query_one
|
from app.db import query, query_one
|
||||||
from app import SYDNEY_TZ
|
from app.config import SYDNEY_TZ
|
||||||
from app.utils import calculate_streak
|
from app.utils import calculate_streak, calculate_weight_change
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
bp = Blueprint("leaderboard", __name__)
|
bp = Blueprint("leaderboard", __name__)
|
||||||
@@ -33,13 +33,7 @@ def index():
|
|||||||
for u in users:
|
for u in users:
|
||||||
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
||||||
current_w = float(u["current_weight"] or start_w)
|
current_w = float(u["current_weight"] or start_w)
|
||||||
|
weight_lost, pct_lost = calculate_weight_change(start_w, current_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 = float(u["goal_weight_kg"]) if u["goal_weight_kg"] else None
|
||||||
goal_progress = None
|
goal_progress = None
|
||||||
@@ -50,7 +44,7 @@ def index():
|
|||||||
streak = calculate_streak(u["id"])
|
streak = calculate_streak(u["id"])
|
||||||
ranked.append({
|
ranked.append({
|
||||||
**u,
|
**u,
|
||||||
"weight_lost": round(weight_lost, 1),
|
"weight_lost": weight_lost,
|
||||||
"pct_lost": pct_lost,
|
"pct_lost": pct_lost,
|
||||||
"goal_progress": goal_progress,
|
"goal_progress": goal_progress,
|
||||||
"streak": streak["current"],
|
"streak": streak["current"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from app.auth import login_required, get_current_user
|
from app.auth import login_required, get_current_user
|
||||||
from app.db import execute
|
from app.db import execute
|
||||||
|
from app.utils import parse_profile_fields
|
||||||
|
|
||||||
bp = Blueprint("profile", __name__)
|
bp = Blueprint("profile", __name__)
|
||||||
|
|
||||||
@@ -16,21 +17,17 @@ def index():
|
|||||||
@login_required
|
@login_required
|
||||||
def update():
|
def update():
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
display_name = request.form.get("display_name", "").strip()
|
fields = parse_profile_fields(request.form)
|
||||||
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"
|
|
||||||
|
|
||||||
execute(
|
execute(
|
||||||
"""UPDATE users
|
"""UPDATE users
|
||||||
SET display_name = %s, height_cm = %s, age = %s, gender = %s,
|
SET display_name = %s, height_cm = %s, age = %s, gender = %s,
|
||||||
goal_weight_kg = %s, starting_weight_kg = %s, is_private = %s
|
goal_weight_kg = %s, starting_weight_kg = %s, is_private = %s
|
||||||
WHERE id = %s""",
|
WHERE id = %s""",
|
||||||
(display_name or user["username"], height_cm, age, gender,
|
(fields["display_name"] or user["username"],
|
||||||
goal_weight_kg, starting_weight_kg, is_private, user["id"]),
|
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"):
|
if request.headers.get("HX-Request"):
|
||||||
|
|||||||
@@ -103,18 +103,7 @@
|
|||||||
<div class="milestones-grid">
|
<div class="milestones-grid">
|
||||||
{% for m in milestones %}
|
{% for m in milestones %}
|
||||||
<span class="milestone-badge {{ 'gold' if 'lost' in m.milestone_key else '' }}">
|
<span class="milestone-badge {{ 'gold' if 'lost' in m.milestone_key else '' }}">
|
||||||
{% if m.milestone_key == 'first_checkin' %}✅ First Check-in
|
{{ MILESTONE_LABELS.get(m.milestone_key, m.milestone_key) }}
|
||||||
{% 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 %}
|
|
||||||
</span>
|
</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
129
app/utils.py
129
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
|
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):
|
def calculate_streak(user_id):
|
||||||
"""Calculate current and best consecutive-day check-in streaks."""
|
"""Calculate current and best consecutive-day check-in streaks."""
|
||||||
rows = query(
|
rows = query(
|
||||||
@@ -40,3 +103,65 @@ def calculate_streak(user_id):
|
|||||||
|
|
||||||
best = max(best, current)
|
best = max(best, current)
|
||||||
return {"current": current, "best": best}
|
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",
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user