Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Stockings
2023e44624 Make auth session last longer so users dont have to relogin each day 2026-02-26 22:37:45 +11:00
Peter Stockings
c76b4cd6fc perf: connection pooling, query consolidation, inline chart data, batch milestones 2026-02-24 21:41:55 +11:00
Peter Stockings
56168a182b Refactor codebase 2026-02-24 21:23:14 +11:00
Peter Stockings
f3abb4781b Show daily streak count on leaderboard as well 2026-02-24 21:12:45 +11:00
Peter Stockings
9323082d37 Show daily streak on dashboard 2026-02-24 20:58:04 +11:00
Peter Stockings
1c935a64be Allow users to edit checkins 2026-02-24 20:57:45 +11:00
Peter Stockings
10256a1283 Prefill date selectors on leadership page with earliest/latest dates 2026-02-24 20:09:18 +11:00
Peter Stockings
93c6822439 Change leadership graph to line and add linear best fit 2026-02-24 14:00:51 +11:00
Peter Stockings
c21a7890f3 Allows users to hide their check-ins from other users 2026-02-24 13:17:09 +11:00
Peter Stockings
d6885a8339 Fix timezone to Sydney 2026-02-23 08:32:18 +11:00
22 changed files with 1307 additions and 228 deletions

View File

@@ -1,5 +1,5 @@
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
@@ -11,6 +11,20 @@ def create_app():
init_db(app) init_db(app)
app.teardown_appcontext(close_db) app.teardown_appcontext(close_db)
# 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 # 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

View File

@@ -1,5 +1,5 @@
from functools import wraps from functools import wraps
from flask import session, redirect, url_for, request from flask import g, session, redirect, url_for, request, jsonify
from app.db import query_one from app.db import query_one
@@ -14,8 +14,27 @@ def login_required(f):
def get_current_user(): def get_current_user():
"""Get the current logged-in user from the database.""" """Get the current logged-in user (cached per-request on g)."""
if "current_user" in g:
return g.current_user
user_id = session.get("user_id") user_id = session.get("user_id")
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,)) g.current_user = query_one("SELECT * FROM users WHERE id = %s", (user_id,))
return g.current_user
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

View File

@@ -1,9 +1,14 @@
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")
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me-in-prod") SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me-in-prod")
PERMANENT_SESSION_LIFETIME = timedelta(days=30)

View File

@@ -1,33 +1,41 @@
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
import psycopg2.pool
from flask import g, current_app from flask import g, current_app
# Module-level connection pool (initialised once per process)
_pool = None
def init_db(app): def init_db(app):
"""Test the database connection on startup.""" """Initialise the connection pool on startup."""
global _pool
try: try:
conn = psycopg2.connect(app.config["DATABASE_URL"]) _pool = psycopg2.pool.SimpleConnectionPool(
conn.close() minconn=2,
print(" * Database connection OK") maxconn=10,
dsn=app.config["DATABASE_URL"],
)
print(" * Database connection pool OK (210 connections)")
except Exception as e: except Exception as e:
print(f" * Database connection FAILED: {e}") print(f" * Database connection pool FAILED: {e}")
def get_db(): def get_db():
"""Get a database connection for the current request.""" """Get a pooled database connection for the current request."""
if "db" not in g: if "db" not in g:
g.db = psycopg2.connect( g.db = _pool.getconn()
current_app.config["DATABASE_URL"], g.db.cursor_factory = psycopg2.extras.RealDictCursor
cursor_factory=psycopg2.extras.RealDictCursor,
)
return g.db return g.db
def close_db(exception=None): def close_db(exception=None):
"""Close database connection at end of request.""" """Return database connection to the pool at end of request."""
db = g.pop("db", None) db = g.pop("db", None)
if db is not None: if db is not None:
db.close() if exception:
db.rollback()
_pool.putconn(db)
def query(sql, params=None): def query(sql, params=None):
@@ -62,3 +70,11 @@ def execute_returning(sql, params=None):
row = cur.fetchone() row = cur.fetchone()
db.commit() db.commit()
return row return row
def execute_many(sql, params_list):
"""Execute a batch INSERT/UPDATE/DELETE and commit."""
db = get_db()
with db.cursor() as cur:
cur.executemany(sql, params_list)
db.commit()

View File

@@ -1,12 +1,131 @@
from flask import Blueprint, jsonify from collections import OrderedDict
from app.auth import login_required 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.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
_CHART_HUES = [210, 160, 270, 30, 340, 190, 50, 120, 300, 80, 240, 10]
@bp.route("/progress-over-time")
@login_required
def progress_over_time():
"""Return per-user % weight lost over time for the leaderboard line chart."""
# --- parse optional filters ---
start = request.args.get("start") # ISO date string
end = request.args.get("end")
user_ids_raw = request.args.get("user_ids", "")
# Build WHERE fragments
where_clauses = ["u.is_private = FALSE"]
params = []
if start:
where_clauses.append("c.checked_in_at >= %s")
params.append(start)
if end:
where_clauses.append("c.checked_in_at < (%s::date + interval '1 day')")
params.append(end)
if user_ids_raw:
try:
uid_list = [int(x) for x in user_ids_raw.split(",") if x.strip()]
except ValueError:
uid_list = []
if uid_list:
placeholders = ",".join(["%s"] * len(uid_list))
where_clauses.append(f"u.id IN ({placeholders})")
params.extend(uid_list)
where_sql = " AND ".join(where_clauses)
# Use CTE for first_weight instead of correlated subquery
rows = query(f"""
WITH first_weights AS (
SELECT DISTINCT ON (user_id) user_id, weight_kg AS first_weight
FROM checkins
ORDER BY user_id, checked_in_at ASC
)
SELECT
u.id AS user_id,
u.display_name,
u.username,
u.starting_weight_kg,
fw.first_weight,
c.weight_kg,
c.checked_in_at
FROM checkins c
JOIN users u ON u.id = c.user_id
LEFT JOIN first_weights fw ON fw.user_id = u.id
WHERE {where_sql}
ORDER BY u.id, c.checked_in_at ASC
""", params)
# Group rows by user
users_map = OrderedDict()
for r in rows:
uid = r["user_id"]
if uid not in users_map:
start_w = float(r["starting_weight_kg"] or r["first_weight"] or 0)
users_map[uid] = {
"id": uid,
"name": r["display_name"] or r["username"],
"start_w": start_w,
"data": [],
}
entry = users_map[uid]
w = float(r["weight_kg"])
dt = r["checked_in_at"]
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
entry["data"].append({
"date": dt.astimezone(SYDNEY_TZ).strftime("%Y-%m-%d"),
"weight": round(w, 1),
})
# Build response with best-fit
result = []
for idx, (uid, entry) in enumerate(users_map.items()):
hue = _CHART_HUES[idx % len(_CHART_HUES)]
color = f"hsl({hue}, 70%, 55%)"
# Simple linear regression (x = day index, y = weight)
points = entry["data"]
best_fit = {"slope": 0, "intercept": 0}
if len(points) >= 2:
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]
n = len(xs)
sum_x = sum(xs)
sum_y = sum(ys)
sum_xy = sum(x * y for x, y in zip(xs, ys))
sum_x2 = sum(x * x for x in xs)
denom = n * sum_x2 - sum_x * sum_x
if denom != 0:
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
best_fit = {"slope": round(slope, 4), "intercept": round(intercept, 4)}
result.append({
"id": uid,
"name": entry["name"],
"color": color,
"data": points,
"best_fit": best_fit,
})
return jsonify({"users": result})
@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."""
checkins = query( checkins = query(
@@ -16,7 +135,7 @@ def chart_data(user_id):
(user_id,), (user_id,),
) )
labels = [c["checked_in_at"].strftime("%d %b") for c in checkins] labels = [c["checked_in_at"].replace(tzinfo=timezone.utc).astimezone(SYDNEY_TZ).strftime("%d %b") for c in checkins]
weights = [float(c["weight_kg"]) 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] bmis = [float(c["bmi"]) if c["bmi"] else None for c in checkins]
@@ -31,16 +150,26 @@ def chart_data(user_id):
@login_required @login_required
def comparison(): def comparison():
"""Return all-user comparison data for bar charts.""" """Return all-user comparison data for bar charts."""
# Use CTE with window functions instead of correlated subqueries
users = query(""" users = query("""
WITH user_weights AS (
SELECT
user_id,
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at ASC) AS first_weight,
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS current_weight,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS rn
FROM checkins
)
SELECT SELECT
u.id, u.id,
u.display_name, u.display_name,
u.username, u.username,
u.starting_weight_kg, 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, uw.first_weight,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight uw.current_weight
FROM users u FROM users u
WHERE (SELECT COUNT(*) FROM checkins WHERE user_id = u.id) > 0 JOIN user_weights uw ON uw.user_id = u.id AND uw.rn = 1
WHERE u.is_private = FALSE
ORDER BY u.display_name ORDER BY u.display_name
""") """)
@@ -51,15 +180,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,
@@ -70,6 +194,7 @@ 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."""
checkins = query( checkins = query(
@@ -88,7 +213,7 @@ def weekly_change(user_id):
prev_w = float(checkins[i - 1]["weight_kg"]) prev_w = float(checkins[i - 1]["weight_kg"])
curr_w = float(checkins[i]["weight_kg"]) curr_w = float(checkins[i]["weight_kg"])
change = round(curr_w - prev_w, 1) change = round(curr_w - prev_w, 1)
label = checkins[i]["checked_in_at"].strftime("%d %b") label = checkins[i]["checked_in_at"].replace(tzinfo=timezone.utc).astimezone(SYDNEY_TZ).strftime("%d %b")
labels.append(label) labels.append(label)
changes.append(change) changes.append(change)

View File

@@ -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,12 +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
# Validation # Validation
if not username or not password: if not username or not password:
@@ -35,11 +31,16 @@ def signup():
# Create user # Create user
password_hash = generate_password_hash(password) password_hash = generate_password_hash(password)
user = execute_returning( user = execute_returning(
"""INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg) """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) 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), (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.permanent = True
session["user_id"] = user["id"] session["user_id"] = user["id"]
flash("Welcome! You're all signed up.", "success") flash("Welcome! You're all signed up.", "success")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
@@ -58,6 +59,7 @@ def login():
flash("Invalid username or password.", "error") flash("Invalid username or password.", "error")
return render_template("login.html"), 401 return render_template("login.html"), 401
session.permanent = True
session["user_id"] = user["id"] session["user_id"] = user["id"]
next_url = request.args.get("next", url_for("dashboard.index")) next_url = request.args.get("next", url_for("dashboard.index"))
return redirect(next_url) return redirect(next_url)

View File

@@ -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
@@ -99,6 +52,72 @@ def create():
return redirect(url_for("checkin.index")) return redirect(url_for("checkin.index"))
@bp.route("/checkin/<int:checkin_id>/edit", methods=["GET"])
@login_required
def edit_form(checkin_id):
user = get_current_user()
checkin = query_one(
"SELECT * FROM checkins WHERE id = %s AND user_id = %s",
(checkin_id, user["id"]),
)
if not checkin:
return "", 404
return render_template("partials/checkin_edit_row.html", c=checkin, user=user)
@bp.route("/checkin/<int:checkin_id>/view", methods=["GET"])
@login_required
def view_row(checkin_id):
user = get_current_user()
checkin = query_one(
"SELECT * FROM checkins WHERE id = %s AND user_id = %s",
(checkin_id, user["id"]),
)
if not checkin:
return "", 404
return render_template("partials/checkin_row.html", c=checkin, user=user)
@bp.route("/checkin/<int:checkin_id>", methods=["PUT"])
@login_required
def update(checkin_id):
user = get_current_user()
weight_kg = request.form.get("weight_kg")
notes = request.form.get("notes", "").strip()
checkin = query_one(
"SELECT * FROM checkins WHERE id = %s AND user_id = %s",
(checkin_id, user["id"]),
)
if not checkin:
return "", 404
if not weight_kg:
flash("Weight is required.", "error")
return render_template("partials/checkin_edit_row.html", c=checkin, user=user)
try:
weight_kg = float(weight_kg)
except ValueError:
flash("Invalid weight value.", "error")
return render_template("partials/checkin_edit_row.html", c=checkin, user=user)
bmi = calculate_bmi(weight_kg, user.get("height_cm"))
execute(
"""UPDATE checkins SET weight_kg = %s, bmi = %s, notes = %s
WHERE id = %s AND user_id = %s""",
(weight_kg, bmi, notes or None, checkin_id, user["id"]),
)
check_milestones(user["id"], user)
updated = query_one(
"SELECT * FROM checkins WHERE id = %s", (checkin_id,)
)
return render_template("partials/checkin_row.html", c=updated, user=user)
@bp.route("/checkin/<int:checkin_id>", methods=["DELETE"]) @bp.route("/checkin/<int:checkin_id>", methods=["DELETE"])
@login_required @login_required
def delete(checkin_id): def delete(checkin_id):

View File

@@ -1,6 +1,9 @@
from datetime import timezone
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.config import SYDNEY_TZ
from app.utils import calculate_streak, calculate_weight_change
bp = Blueprint("dashboard", __name__) bp = Blueprint("dashboard", __name__)
@@ -9,38 +12,53 @@ bp = Blueprint("dashboard", __name__)
@login_required @login_required
def index(): def index():
user = get_current_user() user = get_current_user()
uid = user["id"]
# Get latest check-in # --- Single query: latest, first, count via window functions ----------
latest = query_one( summary = query_one("""
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 1", SELECT
(user["id"],), total,
) first_weight,
latest_weight,
# Get check-in count latest_bmi,
stats = query_one( latest_at
"SELECT COUNT(*) as total_checkins FROM checkins WHERE user_id = %s", FROM (
(user["id"],), SELECT
) COUNT(*) OVER () AS total,
FIRST_VALUE(weight_kg) OVER (ORDER BY checked_in_at ASC) AS first_weight,
# Calculate weight change FIRST_VALUE(weight_kg) OVER (ORDER BY checked_in_at DESC) AS latest_weight,
first_checkin = query_one( FIRST_VALUE(bmi) OVER (ORDER BY checked_in_at DESC) AS latest_bmi,
"SELECT weight_kg FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC LIMIT 1", FIRST_VALUE(checked_in_at) OVER (ORDER BY checked_in_at DESC) AS latest_at,
(user["id"],), ROW_NUMBER() OVER (ORDER BY checked_in_at DESC) AS rn
) FROM checkins
WHERE user_id = %s
) sub
WHERE rn = 1
""", (uid,))
# Build lightweight "latest" dict for the template
latest = None
weight_change = None weight_change = None
weight_change_pct = None weight_change_pct = None
if latest and first_checkin: total_checkins = 0
start_w = float(first_checkin["weight_kg"])
current_w = float(latest["weight_kg"]) if summary:
weight_change = round(current_w - start_w, 1) total_checkins = summary["total"]
if start_w > 0: latest = {
weight_change_pct = round((weight_change / start_w) * 100, 1) "weight_kg": summary["latest_weight"],
"bmi": summary["latest_bmi"],
"checked_in_at": summary["latest_at"],
}
kg_lost, pct_lost = calculate_weight_change(
summary["first_weight"], summary["latest_weight"]
)
weight_change = round(-kg_lost, 1)
weight_change_pct = round(-pct_lost, 1)
# Recent check-ins (last 5) # Recent check-ins (last 5)
recent_checkins = query( recent_checkins = query(
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 5", "SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 5",
(user["id"],), (uid,),
) )
# Activity feed (recent check-ins from all users) # Activity feed (recent check-ins from all users)
@@ -48,24 +66,60 @@ def index():
SELECT c.*, u.display_name, u.username SELECT c.*, u.display_name, u.username
FROM checkins c FROM checkins c
JOIN users u ON c.user_id = u.id JOIN users u ON c.user_id = u.id
WHERE u.is_private = FALSE OR u.id = %s
ORDER BY c.checked_in_at DESC ORDER BY c.checked_in_at DESC
LIMIT 10 LIMIT 10
""") """, (uid,))
# Milestones # Milestones
milestones = query( milestones = query(
"SELECT * FROM milestones WHERE user_id = %s ORDER BY achieved_at DESC", "SELECT * FROM milestones WHERE user_id = %s ORDER BY achieved_at DESC",
(user["id"],), (uid,),
) )
# Streak
streak = calculate_streak(uid)
# --- Pre-compute chart data (eliminates 2 client-side fetches) --------
chart_checkins = query(
"""SELECT weight_kg, bmi, checked_in_at
FROM checkins WHERE user_id = %s
ORDER BY checked_in_at ASC""",
(uid,),
)
chart_labels = []
chart_weights = []
weekly_labels = []
weekly_changes = []
for i, c in enumerate(chart_checkins):
dt = c["checked_in_at"]
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
label = dt.astimezone(SYDNEY_TZ).strftime("%d %b")
chart_labels.append(label)
chart_weights.append(float(c["weight_kg"]))
if i > 0:
prev_w = float(chart_checkins[i - 1]["weight_kg"])
curr_w = float(c["weight_kg"])
weekly_labels.append(label)
weekly_changes.append(round(curr_w - prev_w, 1))
return render_template( return render_template(
"dashboard.html", "dashboard.html",
user=user, user=user,
latest=latest, latest=latest,
stats=stats, stats={"total_checkins": total_checkins},
weight_change=weight_change, weight_change=weight_change,
weight_change_pct=weight_change_pct, weight_change_pct=weight_change_pct,
recent_checkins=recent_checkins, recent_checkins=recent_checkins,
activity=activity, activity=activity,
milestones=milestones, milestones=milestones,
streak=streak,
chart_labels=chart_labels,
chart_weights=chart_weights,
weekly_labels=weekly_labels,
weekly_changes=weekly_changes,
) )

View File

@@ -1,6 +1,9 @@
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 from app.db import query, query_one
from app.config import SYDNEY_TZ
from app.utils import calculate_streaks_bulk, calculate_weight_change
from datetime import timezone
bp = Blueprint("leaderboard", __name__) bp = Blueprint("leaderboard", __name__)
@@ -8,34 +11,44 @@ bp = Blueprint("leaderboard", __name__)
@bp.route("/leaderboard") @bp.route("/leaderboard")
@login_required @login_required
def index(): def index():
# Get all users with their weight stats # Get all users with weight stats using window functions (no correlated subqueries)
users = query(""" users = query("""
WITH user_weights AS (
SELECT
user_id,
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at ASC) AS first_weight,
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS current_weight,
COUNT(*) OVER (PARTITION BY user_id) AS total_checkins,
MAX(checked_in_at) OVER (PARTITION BY user_id) AS last_checkin,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS rn
FROM checkins
)
SELECT SELECT
u.id, u.id,
u.display_name, u.display_name,
u.username, u.username,
u.starting_weight_kg, u.starting_weight_kg,
u.goal_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, uw.first_weight,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight, uw.current_weight,
(SELECT COUNT(*) FROM checkins WHERE user_id = u.id) as total_checkins, uw.total_checkins,
(SELECT checked_in_at FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as last_checkin uw.last_checkin
FROM users u FROM users u
JOIN user_weights uw ON uw.user_id = u.id AND uw.rn = 1
WHERE u.is_private = FALSE
ORDER BY u.created_at ORDER BY u.created_at
""") """)
# Batch-compute streaks for all users in one query
user_ids = [u["id"] for u in users]
all_streaks = calculate_streaks_bulk(user_ids)
# Calculate rankings # Calculate rankings
ranked = [] ranked = []
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
@@ -43,14 +56,37 @@ def index():
total_to_lose = 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 goal_progress = min(100, round((weight_lost / total_to_lose) * 100, 1)) if total_to_lose > 0 else 0
streak = all_streaks.get(u["id"], {"current": 0, "best": 0})
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"],
}) })
# Sort by % lost (descending) # Sort by % lost (descending)
ranked.sort(key=lambda x: x["pct_lost"], reverse=True) ranked.sort(key=lambda x: x["pct_lost"], reverse=True)
return render_template("leaderboard.html", ranked=ranked) # Get earliest and latest check-in dates for date pickers
date_range = query_one("""
SELECT
MIN(c.checked_in_at) AS earliest,
MAX(c.checked_in_at) AS latest
FROM checkins c
JOIN users u ON u.id = c.user_id
WHERE u.is_private = FALSE
""")
earliest = ""
latest = ""
if date_range and date_range["earliest"]:
e = date_range["earliest"]
l = date_range["latest"]
if e.tzinfo is None:
e = e.replace(tzinfo=timezone.utc)
if l.tzinfo is None:
l = l.replace(tzinfo=timezone.utc)
earliest = e.astimezone(SYDNEY_TZ).strftime("%Y-%m-%d")
latest = l.astimezone(SYDNEY_TZ).strftime("%Y-%m-%d")
return render_template("leaderboard.html", ranked=ranked, earliest=earliest, latest=latest)

View File

@@ -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,20 +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
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 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, 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"):

View File

@@ -43,7 +43,9 @@
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15); --shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15);
} }
*, *::before, *::after { *,
*::before,
*::after {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -88,7 +90,9 @@ body {
font-size: 1.15rem; font-size: 1.15rem;
} }
.nav-icon { font-size: 1.4rem; } .nav-icon {
font-size: 1.4rem;
}
.nav-links { .nav-links {
display: flex; display: flex;
@@ -118,7 +122,9 @@ body {
background: var(--accent-glow); background: var(--accent-glow);
} }
.nav-link-icon { font-size: 1.1rem; } .nav-link-icon {
font-size: 1.1rem;
}
.nav-toggle { .nav-toggle {
display: none; display: none;
@@ -139,7 +145,10 @@ body {
transition: all 0.2s; transition: all 0.2s;
} }
.nav-actions { display: flex; align-items: center; } .nav-actions {
display: flex;
align-items: center;
}
/* ========== CONTAINER ========== */ /* ========== CONTAINER ========== */
.container { .container {
@@ -149,7 +158,9 @@ body {
} }
/* ========== FLASH MESSAGES ========== */ /* ========== FLASH MESSAGES ========== */
.flash-messages { margin-bottom: 1rem; } .flash-messages {
margin-bottom: 1rem;
}
.flash { .flash {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
@@ -162,9 +173,23 @@ body {
animation: slideIn 0.3s ease; animation: slideIn 0.3s ease;
} }
.flash-success { background: var(--success-bg); color: var(--success); border: 1px solid rgba(16, 185, 129, 0.2); } .flash-success {
.flash-error { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.2); } background: var(--success-bg);
.flash-info { background: rgba(6, 182, 212, 0.1); color: var(--info); border: 1px solid rgba(6, 182, 212, 0.2); } color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.flash-error {
background: var(--danger-bg);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.flash-info {
background: rgba(6, 182, 212, 0.1);
color: var(--info);
border: 1px solid rgba(6, 182, 212, 0.2);
}
.flash-close { .flash-close {
background: none; background: none;
@@ -175,7 +200,9 @@ body {
opacity: 0.7; opacity: 0.7;
} }
.flash-close:hover { opacity: 1; } .flash-close:hover {
opacity: 1;
}
/* ========== PAGE HEADERS ========== */ /* ========== PAGE HEADERS ========== */
.page-header { .page-header {
@@ -263,7 +290,14 @@ body {
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.stat-card:hover::before { opacity: 1; } .stat-card:hover::before {
opacity: 1;
}
.stat-card-streak::before {
background: linear-gradient(135deg, #f59e0b, #ef4444);
opacity: 1;
}
.stat-label { .stat-label {
font-size: 0.75rem; font-size: 0.75rem;
@@ -287,8 +321,13 @@ body {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.stat-change.positive { color: var(--success); } .stat-change.positive {
.stat-change.negative { color: var(--danger); } color: var(--success);
}
.stat-change.negative {
color: var(--danger);
}
/* ========== FORMS ========== */ /* ========== FORMS ========== */
.form-group { .form-group {
@@ -321,7 +360,9 @@ body {
box-shadow: 0 0 0 3px var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-glow);
} }
.form-input::placeholder { color: var(--text-muted); } .form-input::placeholder {
color: var(--text-muted);
}
select.form-input { select.form-input {
appearance: none; appearance: none;
@@ -343,7 +384,10 @@ select.form-input {
align-items: flex-end; align-items: flex-end;
} }
.form-inline .form-group { flex: 1; margin-bottom: 0; } .form-inline .form-group {
flex: 1;
margin-bottom: 0;
}
/* ========== BUTTONS ========== */ /* ========== BUTTONS ========== */
.btn { .btn {
@@ -396,9 +440,19 @@ select.form-input {
background: var(--bg-card); background: var(--bg-card);
} }
.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.8rem; } .btn-sm {
.btn-lg { padding: 0.85rem 1.75rem; font-size: 1rem; } padding: 0.4rem 0.8rem;
.btn-block { width: 100%; } font-size: 0.8rem;
}
.btn-lg {
padding: 0.85rem 1.75rem;
font-size: 1rem;
}
.btn-block {
width: 100%;
}
/* ========== TABLES ========== */ /* ========== TABLES ========== */
.table-wrap { .table-wrap {
@@ -431,9 +485,13 @@ td {
color: var(--text-secondary); color: var(--text-secondary);
} }
tr:last-child td { border-bottom: none; } tr:last-child td {
border-bottom: none;
}
tr:hover td { background: var(--bg-card-hover); } tr:hover td {
background: var(--bg-card-hover);
}
/* ========== LEADERBOARD ========== */ /* ========== LEADERBOARD ========== */
.rank-badge { .rank-badge {
@@ -447,10 +505,25 @@ tr:hover td { background: var(--bg-card-hover); }
font-size: 0.8rem; font-size: 0.8rem;
} }
.rank-1 { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #1a1a1a; } .rank-1 {
.rank-2 { background: linear-gradient(135deg, #cbd5e1, #94a3b8); color: #1a1a1a; } background: linear-gradient(135deg, #fbbf24, #f59e0b);
.rank-3 { background: linear-gradient(135deg, #d97706, #b45309); color: white; } color: #1a1a1a;
.rank-other { background: var(--bg-secondary); color: var(--text-muted); } }
.rank-2 {
background: linear-gradient(135deg, #cbd5e1, #94a3b8);
color: #1a1a1a;
}
.rank-3 {
background: linear-gradient(135deg, #d97706, #b45309);
color: white;
}
.rank-other {
background: var(--bg-secondary);
color: var(--text-muted);
}
.progress-bar-track { .progress-bar-track {
width: 100%; width: 100%;
@@ -476,7 +549,9 @@ tr:hover td { background: var(--bg-card-hover); }
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.activity-item:last-child { border-bottom: none; } .activity-item:last-child {
border-bottom: none;
}
.activity-avatar { .activity-avatar {
width: 32px; width: 32px;
@@ -492,7 +567,9 @@ tr:hover td { background: var(--bg-card-hover); }
flex-shrink: 0; flex-shrink: 0;
} }
.activity-content { flex: 1; } .activity-content {
flex: 1;
}
.activity-name { .activity-name {
font-weight: 600; font-weight: 600;
@@ -594,7 +671,9 @@ tr:hover td { background: var(--bg-card-hover); }
font-weight: 500; font-weight: 500;
} }
.auth-card .auth-footer a:hover { text-decoration: underline; } .auth-card .auth-footer a:hover {
text-decoration: underline;
}
/* ========== GRID LAYOUTS ========== */ /* ========== GRID LAYOUTS ========== */
.grid-2 { .grid-2 {
@@ -611,17 +690,34 @@ tr:hover td { background: var(--bg-card-hover); }
/* ========== ANIMATIONS ========== */ /* ========== ANIMATIONS ========== */
@keyframes slideIn { @keyframes slideIn {
from { transform: translateY(-8px); opacity: 0; } from {
to { transform: translateY(0); opacity: 1; } transform: translateY(-8px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
} }
.htmx-added { animation: slideIn 0.3s ease; } to {
.htmx-settling { animation: fadeIn 0.2s ease; } opacity: 1;
}
}
.htmx-added {
animation: slideIn 0.3s ease;
}
.htmx-settling {
animation: fadeIn 0.2s ease;
}
/* ========== DELETE BUTTON ========== */ /* ========== DELETE BUTTON ========== */
.btn-icon { .btn-icon {
@@ -646,6 +742,42 @@ tr:hover td { background: var(--bg-card-hover); }
border-color: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.2);
} }
.btn-icon-success:hover {
color: var(--success) !important;
background: var(--success-bg) !important;
border-color: rgba(16, 185, 129, 0.2) !important;
}
/* ========== INLINE EDIT ========== */
.checkin-actions {
display: flex;
gap: 0.25rem;
align-items: center;
}
.edit-input {
width: 100%;
max-width: 120px;
padding: 0.35rem 0.6rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
transition: all 0.2s ease;
outline: none;
}
.edit-input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.editing-row td {
background: var(--bg-card-hover);
}
/* ========== EMPTY STATE ========== */ /* ========== EMPTY STATE ========== */
.empty-state { .empty-state {
text-align: center; text-align: center;
@@ -671,6 +803,146 @@ tr:hover td { background: var(--bg-card-hover); }
margin: 0 auto 1rem; margin: 0 auto 1rem;
} }
/* ========== TOGGLE SWITCH ========== */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 24px;
cursor: pointer;
transition: all 0.3s ease;
}
.toggle-slider::before {
content: '';
position: absolute;
left: 3px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background: var(--text-muted);
border-radius: 50%;
transition: all 0.3s ease;
}
.toggle-switch input:checked+.toggle-slider {
background: var(--accent);
border-color: var(--accent);
}
.toggle-switch input:checked+.toggle-slider::before {
left: 23px;
background: white;
}
.toggle-switch input:focus+.toggle-slider {
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* ========== CHART FILTERS ========== */
.chart-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
padding: 0 0 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.filter-group label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.filter-group input[type="date"] {
width: 160px;
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
outline: none;
transition: all 0.2s ease;
}
.filter-group input[type="date"]:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.filter-group-people {
flex: 1;
min-width: 200px;
}
.person-filter-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
max-height: 72px;
overflow-y: auto;
padding: 0.25rem 0;
}
.person-checkbox {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 100px;
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.person-checkbox:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.person-checkbox input[type="checkbox"] {
accent-color: var(--accent);
width: 14px;
height: 14px;
cursor: pointer;
}
.person-checkbox:has(input:checked) {
background: var(--accent-glow);
border-color: rgba(59, 130, 246, 0.3);
color: var(--accent);
}
/* ========== RESPONSIVE ========== */ /* ========== RESPONSIVE ========== */
@media (max-width: 768px) { @media (max-width: 768px) {
.navbar { .navbar {
@@ -689,24 +961,59 @@ tr:hover td { background: var(--bg-card-hover); }
padding: 0.5rem; padding: 0.5rem;
} }
.nav-links.open { display: flex; } .nav-links.open {
display: flex;
}
.nav-toggle { display: flex; } .nav-toggle {
display: flex;
}
.nav-actions { display: none; } .nav-actions {
display: none;
}
.container { padding: 1rem; } .container {
padding: 0.75rem;
}
.stats-grid { .stats-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
margin-bottom: 1rem;
}
.stat-card {
padding: 0.85rem;
}
.stat-value {
font-size: 1.35rem;
}
.stat-label {
font-size: 0.65rem;
margin-bottom: 0.25rem;
} }
.charts-grid { .charts-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
} }
.grid-2, .grid-3 { .chart-container {
height: 220px;
}
.grid-2,
.grid-3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.75rem;
}
.card {
padding: 1rem;
} }
.form-inline { .form-inline {
@@ -714,23 +1021,75 @@ tr:hover td { background: var(--bg-card-hover); }
align-items: stretch; align-items: stretch;
} }
.form-inline .form-group { margin-bottom: 0.75rem; } .form-inline .form-group {
margin-bottom: 0.75rem;
}
.page-header h1 { font-size: 1.4rem; } .page-header {
margin-bottom: 1rem;
}
.stat-value { font-size: 1.4rem; } .page-header h1 {
font-size: 1.3rem;
}
.auth-card { margin: 1rem; padding: 1.5rem; } .page-header p {
font-size: 0.8rem;
}
.auth-card {
margin: 1rem;
padding: 1.5rem;
}
.table-wrap { .table-wrap {
font-size: 0.8rem; font-size: 0.8rem;
} }
th, td { padding: 0.5rem 0.65rem; } th,
td {
padding: 0.5rem 0.65rem;
}
.chart-filters {
flex-direction: column;
gap: 0.75rem;
}
.chart-filters .filter-group {
width: 100%;
}
.filter-group input[type="date"] {
width: 100%;
padding: 0.65rem 0.9rem;
font-size: 0.9rem;
}
.filter-group-people {
min-width: unset;
}
.card-header h2 {
font-size: 0.9rem;
}
.activity-item {
padding: 0.5rem 0;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.stats-grid { .stats-grid {
grid-template-columns: 1fr; grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.stat-card {
padding: 0.7rem;
}
.stat-value {
font-size: 1.15rem;
} }
} }

View File

@@ -144,3 +144,113 @@ function createComparisonChart(canvasId, names, pctLost) {
}, },
}); });
} }
/**
* Create a multi-series progress-over-time line chart with best-fit lines.
* @param {string} canvasId - canvas element ID
* @param {Array} users - array of { id, name, color, data: [{date, pct_lost}], best_fit: {slope, intercept} }
* @returns {Chart} the Chart.js instance
*/
function createProgressChart(canvasId, users) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const datasets = [];
users.forEach(user => {
// Actual data line
datasets.push({
label: user.name,
data: user.data.map(d => ({ x: d.date, y: d.weight })),
borderColor: user.color,
backgroundColor: user.color.replace(')', ', 0.1)').replace('hsl(', 'hsla('),
fill: false,
tension: 0.3,
pointBackgroundColor: user.color,
pointBorderColor: '#1a2233',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
borderWidth: 2.5,
});
// Best-fit line (dashed) — only if we have ≥ 2 data points
if (user.data.length >= 2) {
const baseDate = new Date(user.data[0].date);
const firstDay = 0;
const lastDay = Math.round(
(new Date(user.data[user.data.length - 1].date) - baseDate) / 86400000
);
const fitStart = user.best_fit.intercept + user.best_fit.slope * firstDay;
const fitEnd = user.best_fit.intercept + user.best_fit.slope * lastDay;
datasets.push({
label: user.name + ' (trend)',
data: [
{ x: user.data[0].date, y: Math.round(fitStart * 100) / 100 },
{ x: user.data[user.data.length - 1].date, y: Math.round(fitEnd * 100) / 100 },
],
borderColor: user.color,
borderDash: [6, 4],
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 0,
fill: false,
tension: 0,
});
}
});
return new Chart(ctx, {
type: 'line',
data: { datasets },
options: {
...chartDefaults,
scales: {
x: {
type: 'time',
time: {
unit: 'day',
tooltipFormat: 'dd MMM yyyy',
displayFormats: { day: 'dd MMM' },
},
grid: { color: 'rgba(42, 53, 72, 0.5)', drawBorder: false },
ticks: { color: '#64748b', font: { size: 11, family: 'Inter' } },
},
y: {
...chartDefaults.scales.y,
title: {
display: true,
text: 'Weight (kg)',
color: '#64748b',
font: { size: 12, family: 'Inter' },
},
},
},
plugins: {
...chartDefaults.plugins,
legend: {
display: true,
labels: {
color: '#94a3b8',
font: { size: 12, family: 'Inter' },
usePointStyle: true,
pointStyle: 'circle',
padding: 16,
// Hide trend lines from legend
filter: item => !item.text.endsWith('(trend)'),
},
},
tooltip: {
...chartDefaults.plugins.tooltip,
callbacks: {
label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y} kg`,
},
},
},
},
});
}

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,11 +8,15 @@
<meta name="description" content="Track your weight loss competition with friends"> <meta name="description" content="Track your weight loss competition with friends">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
</head> </head>
<body> <body>
{% if session.get('user_id') %} {% if session.get('user_id') %}
<nav class="navbar"> <nav class="navbar">
@@ -26,15 +31,18 @@
<span class="nav-link-icon">📊</span> <span class="nav-link-icon">📊</span>
<span>Dashboard</span> <span>Dashboard</span>
</a> </a>
<a href="{{ url_for('checkin.index') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('checkin') }}"> <a href="{{ url_for('checkin.index') }}"
class="{{ 'active' if request.endpoint and request.endpoint.startswith('checkin') }}">
<span class="nav-link-icon">✏️</span> <span class="nav-link-icon">✏️</span>
<span>Check-in</span> <span>Check-in</span>
</a> </a>
<a href="{{ url_for('leaderboard.index') }}" class="{{ 'active' if request.endpoint == 'leaderboard.index' }}"> <a href="{{ url_for('leaderboard.index') }}"
class="{{ 'active' if request.endpoint == 'leaderboard.index' }}">
<span class="nav-link-icon">🏆</span> <span class="nav-link-icon">🏆</span>
<span>Leaderboard</span> <span>Leaderboard</span>
</a> </a>
<a href="{{ url_for('profile.index') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('profile') }}"> <a href="{{ url_for('profile.index') }}"
class="{{ 'active' if request.endpoint and request.endpoint.startswith('profile') }}">
<span class="nav-link-icon">👤</span> <span class="nav-link-icon">👤</span>
<span>Profile</span> <span>Profile</span>
</a> </a>
@@ -67,4 +75,5 @@
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -34,9 +34,11 @@
<div class="stat-label">Current BMI</div> <div class="stat-label">Current BMI</div>
<div class="stat-value">{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else '—' }}</div> <div class="stat-value">{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else '—' }}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card {{ 'stat-card-streak' if streak.current >= 3 }}">
<div class="stat-label">Check-ins</div> <div class="stat-label">Check-ins</div>
<div class="stat-value">{{ stats.total_checkins if stats else 0 }}</div> <div class="stat-value">{{ stats.total_checkins if stats else 0 }}</div>
<div class="stat-change" style="color: var(--warning);">🔥 {{ streak.current }}-day streak{% if streak.best >
streak.current %} · Best: {{ streak.best }}{% endif %}</div>
</div> </div>
</div> </div>
@@ -75,7 +77,7 @@
<div style="font-weight: 600; font-size: 1.1rem;">{{ '%.1f' % (c.weight_kg | float) }} kg</div> <div style="font-weight: 600; font-size: 1.1rem;">{{ '%.1f' % (c.weight_kg | float) }} kg</div>
<div class="activity-detail"> <div class="activity-detail">
{% if c.bmi %}BMI {{ '%.1f' % (c.bmi | float) }} · {% endif %} {% if c.bmi %}BMI {{ '%.1f' % (c.bmi | float) }} · {% endif %}
{{ c.checked_in_at.strftime('%d %b %Y, %H:%M') }} {{ c.checked_in_at | sydney }}
</div> </div>
</div> </div>
</div> </div>
@@ -101,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>
@@ -131,7 +122,7 @@
<div class="activity-content"> <div class="activity-content">
<div class="activity-name">{{ a.display_name or a.username }}</div> <div class="activity-name">{{ a.display_name or a.username }}</div>
<div class="activity-detail">Logged {{ '%.1f' % (a.weight_kg | float) }} kg · {{ <div class="activity-detail">Logged {{ '%.1f' % (a.weight_kg | float) }} kg · {{
a.checked_in_at.strftime('%d %b, %H:%M') }}</div> a.checked_in_at | sydney('%d %b, %H:%M') }}</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -152,21 +143,18 @@
<script src="{{ url_for('static', filename='js/charts.js') }}"></script> <script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
fetch('/api/chart-data/{{ user.id }}') const chartLabels = {{ chart_labels | tojson
.then(r => r.json()) }};
.then(data => { const chartWeights = {{ chart_weights | tojson }};
if (data.labels.length > 0) { const weeklyLabels = {{ weekly_labels | tojson }};
createWeightChart('weightChart', data.labels, data.weights); const weeklyChanges = {{ weekly_changes | tojson }};
}
});
fetch('/api/weekly-change/{{ user.id }}') if (chartLabels.length > 0) {
.then(r => r.json()) createWeightChart('weightChart', chartLabels, chartWeights);
.then(data => { }
if (data.labels.length > 0) { if (weeklyLabels.length > 0) {
createWeeklyChangeChart('weeklyChart', data.labels, data.changes); createWeeklyChangeChart('weeklyChart', weeklyLabels, weeklyChanges);
} }
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -7,13 +7,37 @@
<p>Ranked by % body weight lost. May the best loser win!</p> <p>Ranked by % body weight lost. May the best loser win!</p>
</div> </div>
<!-- Comparison Chart --> <!-- Progress-Over-Time Chart -->
<div class="card" style="margin-bottom: 1.5rem;"> <div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header"> <div class="card-header">
<h2>📊 % Weight Lost Comparison</h2> <h2>📈 Progress Over Time</h2>
</div> </div>
<div class="chart-container">
<canvas id="comparisonChart"></canvas> <!-- Filters -->
<div class="chart-filters">
<div class="filter-group">
<label for="filterStart">From</label>
<input type="date" id="filterStart" class="form-input" value="{{ earliest }}">
</div>
<div class="filter-group">
<label for="filterEnd">To</label>
<input type="date" id="filterEnd" class="form-input" value="{{ latest }}">
</div>
<div class="filter-group filter-group-people">
<label>People</label>
<div class="person-filter-list" id="personFilterList">
{% for u in ranked %}
<label class="person-checkbox">
<input type="checkbox" value="{{ u.id }}" checked>
<span>{{ u.display_name or u.username }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
<div class="chart-container" style="position: relative; height: 380px;">
<canvas id="progressChart"></canvas>
</div> </div>
</div> </div>
@@ -32,6 +56,7 @@
<th>Lost (%)</th> <th>Lost (%)</th>
<th>Goal Progress</th> <th>Goal Progress</th>
<th>Check-ins</th> <th>Check-ins</th>
<th>Streak</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -72,6 +97,8 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ u.total_checkins }}</td> <td>{{ u.total_checkins }}</td>
<td>{% if u.streak > 0 %}<span style="color: var(--warning);">🔥 {{ u.streak }}d</span>{% else
%}<span style="color: var(--text-muted);"></span>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -90,13 +117,49 @@
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/charts.js') }}"></script> <script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { let _progressChart = null;
fetch('/api/comparison') let _debounceTimer = null;
function loadProgressChart() {
const start = document.getElementById('filterStart').value;
const end = document.getElementById('filterEnd').value;
// Gather checked user IDs
const checks = document.querySelectorAll('#personFilterList input[type=checkbox]:checked');
const userIds = Array.from(checks).map(cb => cb.value).join(',');
const params = new URLSearchParams();
if (start) params.set('start', start);
if (end) params.set('end', end);
if (userIds) params.set('user_ids', userIds);
fetch('/api/progress-over-time?' + params.toString())
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.names.length > 0) { if (_progressChart) {
createComparisonChart('comparisonChart', data.names, data.pct_lost); _progressChart.destroy();
_progressChart = null;
} }
if (data.users && data.users.length > 0) {
_progressChart = createProgressChart('progressChart', data.users);
}
});
}
function debouncedLoad() {
clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(loadProgressChart, 300);
}
document.addEventListener('DOMContentLoaded', function () {
// Initial load
loadProgressChart();
// Wire up filter changes
document.getElementById('filterStart').addEventListener('change', debouncedLoad);
document.getElementById('filterEnd').addEventListener('change', debouncedLoad);
document.querySelectorAll('#personFilterList input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', loadProgressChart);
}); });
}); });
</script> </script>

View File

@@ -0,0 +1,19 @@
<tr id="checkin-{{ c.id }}" class="editing-row">
<td>{{ c.checked_in_at | sydney }}</td>
<td>
<input type="number" name="weight_kg" class="edit-input" step="0.1" value="{{ '%.1f' % (c.weight_kg | float) }}"
required autofocus>
</td>
<td style="color: var(--text-muted); font-size: 0.8rem;">auto</td>
<td>
<input type="text" name="notes" class="edit-input" placeholder="Notes" value="{{ c.notes or '' }}">
</td>
<td>
<div class="checkin-actions">
<button class="btn-icon btn-icon-success" hx-put="/checkin/{{ c.id }}" hx-target="#checkin-{{ c.id }}"
hx-swap="outerHTML" hx-include="closest tr" title="Save"></button>
<button class="btn-icon" hx-get="/checkin/{{ c.id }}/view" hx-target="#checkin-{{ c.id }}"
hx-swap="outerHTML" title="Cancel"></button>
</div>
</td>
</tr>

View File

@@ -1,10 +1,14 @@
<tr id="checkin-{{ c.id }}"> <tr id="checkin-{{ c.id }}">
<td>{{ c.checked_in_at.strftime('%d %b %Y, %H:%M') }}</td> <td>{{ c.checked_in_at | sydney }}</td>
<td style="font-weight: 600;">{{ '%.1f' % (c.weight_kg | float) }} kg</td> <td style="font-weight: 600;">{{ '%.1f' % (c.weight_kg | float) }} kg</td>
<td>{{ '%.1f' % (c.bmi | float) if c.bmi else '—' }}</td> <td>{{ '%.1f' % (c.bmi | float) if c.bmi else '—' }}</td>
<td>{{ c.notes or '—' }}</td> <td>{{ c.notes or '—' }}</td>
<td> <td>
<div class="checkin-actions">
<button class="btn-icon" hx-get="/checkin/{{ c.id }}/edit" hx-target="#checkin-{{ c.id }}"
hx-swap="outerHTML" title="Edit">✏️</button>
<button class="btn-icon" hx-delete="/checkin/{{ c.id }}" hx-target="#checkin-{{ c.id }}" hx-swap="outerHTML" <button class="btn-icon" hx-delete="/checkin/{{ c.id }}" hx-target="#checkin-{{ c.id }}" hx-swap="outerHTML"
hx-confirm="Delete this check-in?">🗑️</button> hx-confirm="Delete this check-in?" title="Delete">🗑️</button>
</div>
</td> </td>
</tr> </tr>

View File

@@ -50,6 +50,21 @@
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border);">
<label class="toggle-label" for="is_private"
style="display: flex; align-items: center; gap: 0.75rem; cursor: pointer;">
<span class="toggle-switch">
<input type="checkbox" id="is_private" name="is_private" {{ 'checked' if user.is_private }}>
<span class="toggle-slider"></span>
</span>
<span>
<span style="font-weight: 600; color: var(--text-primary);">🔒 Private Account</span>
<span style="display: block; font-size: 0.8rem; color: var(--text-muted); margin-top: 0.15rem;">Only
you can see your check-ins</span>
</span>
</label>
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 0.5rem;">Save Changes</button> <button type="submit" class="btn btn-primary" style="margin-top: 0.5rem;">Save Changes</button>
</form> </form>
</div> </div>

View File

@@ -61,6 +61,22 @@
step="0.1"> step="0.1">
</div> </div>
<div class="form-group" style="margin-top: 0.5rem;">
<label class="toggle-label" for="is_private"
style="display: flex; align-items: center; gap: 0.75rem; cursor: pointer;">
<span class="toggle-switch">
<input type="checkbox" id="is_private" name="is_private">
<span class="toggle-slider"></span>
</span>
<span>
<span style="font-weight: 600; color: var(--text-primary);">🔒 Private Account</span>
<span
style="display: block; font-size: 0.8rem; color: var(--text-muted); margin-top: 0.15rem;">Only
you can see your check-ins</span>
</span>
</label>
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top: 0.5rem;">Create <button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top: 0.5rem;">Create
Account</button> Account</button>
</form> </form>

200
app/utils.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Shared business-logic helpers.
Keep route handlers thin — calculation logic lives here.
"""
from app.db import query, execute_many
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 _compute_streak_from_dates(days, today):
"""Compute current and best streak from a sorted-desc list of dates."""
if not days:
return {"current": 0, "best": 0}
# Current streak: must include today or yesterday to count
current = 0
expected = today
if days[0] == today or days[0] == today - timedelta(days=1):
expected = days[0]
for d in days:
if d == expected:
current += 1
expected -= timedelta(days=1)
else:
break
# Best streak
best = 1
run = 1
for i in range(1, len(days)):
if days[i] == days[i - 1] - timedelta(days=1):
run += 1
best = max(best, run)
else:
run = 1
best = max(best, current)
return {"current": current, "best": best}
def calculate_streak(user_id):
"""Calculate current and best consecutive-day check-in streaks."""
rows = query(
"""SELECT DISTINCT (checked_in_at AT TIME ZONE 'UTC' AT TIME ZONE 'Australia/Sydney')::date AS d
FROM checkins WHERE user_id = %s ORDER BY d DESC""",
(user_id,),
)
days = [r["d"] for r in rows]
today = datetime.now(SYDNEY_TZ).date()
return _compute_streak_from_dates(days, today)
def calculate_streaks_bulk(user_ids):
"""Calculate streaks for multiple users in a single query.
Returns a dict: {user_id: {"current": int, "best": int}}.
"""
if not user_ids:
return {}
placeholders = ",".join(["%s"] * len(user_ids))
rows = query(
f"""SELECT user_id,
(checked_in_at AT TIME ZONE 'UTC' AT TIME ZONE 'Australia/Sydney')::date AS d
FROM checkins
WHERE user_id IN ({placeholders})
GROUP BY user_id, d
ORDER BY user_id, d DESC""",
tuple(user_ids),
)
# Group by user
from collections import defaultdict
user_days = defaultdict(list)
for r in rows:
user_days[r["user_id"]].append(r["d"])
today = datetime.now(SYDNEY_TZ).date()
result = {}
for uid in user_ids:
result[uid] = _compute_streak_from_dates(user_days.get(uid, []), today)
return result
# ---------------------------------------------------------------------------
# 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),
]
achieved = [(user_id, key) for key, ok in milestone_checks if ok]
if achieved:
execute_many(
"INSERT INTO milestones (user_id, milestone_key) VALUES (%s, %s) ON CONFLICT DO NOTHING",
achieved,
)
# ---------------------------------------------------------------------------
# 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",
}

View File

@@ -0,0 +1,4 @@
-- Migration 002: Add private account flag
-- Allows users to hide their check-ins from other users
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE;

View File

@@ -0,0 +1,4 @@
-- Migration 003: Add indexes for performance
-- Partial index to speed up queries that filter on is_private = FALSE
CREATE INDEX IF NOT EXISTS idx_users_public ON users(id) WHERE is_private = FALSE;