from flask import Blueprint, jsonify, request, session from app import SYDNEY_TZ from app.auth import login_required from app.db import query, query_one from datetime import datetime, timezone 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) rows = query(f""" SELECT u.id AS user_id, u.display_name, u.username, u.starting_weight_kg, (SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at ASC LIMIT 1) AS first_weight, c.weight_kg, c.checked_in_at FROM checkins c JOIN users u ON u.id = c.user_id WHERE {where_sql} ORDER BY u.id, c.checked_in_at ASC """, params) # Group rows by user from collections import OrderedDict 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: # Convert dates to day offsets from first point base = datetime.strptime(points[0]["date"], "%Y-%m-%d") xs = [(datetime.strptime(p["date"], "%Y-%m-%d") - base).days for p in points] ys = [p["weight"] for p in points] 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/") @login_required def chart_data(user_id): """Return weight & BMI over time for Chart.js.""" # Privacy guard: don't expose private user data to others if user_id != session.get("user_id"): target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,)) if target and target["is_private"]: return jsonify({"labels": [], "weights": [], "bmis": []}) checkins = query( """SELECT weight_kg, bmi, checked_in_at FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC""", (user_id,), ) 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] bmis = [float(c["bmi"]) if c["bmi"] else None for c in checkins] return jsonify({ "labels": labels, "weights": weights, "bmis": bmis, }) @bp.route("/comparison") @login_required def comparison(): """Return all-user comparison data for bar charts.""" users = query(""" SELECT u.id, u.display_name, u.username, u.starting_weight_kg, (SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at ASC LIMIT 1) as first_weight, (SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight FROM users u WHERE (SELECT COUNT(*) FROM checkins WHERE user_id = u.id) > 0 AND u.is_private = FALSE ORDER BY u.display_name """) names = [] pct_lost = [] kg_lost = [] for u in users: start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0) current_w = float(u["current_weight"] or start_w) if start_w > 0: lost = start_w - current_w pct = round((lost / start_w) * 100, 1) else: lost = 0 pct = 0 names.append(u["display_name"] or u["username"]) pct_lost.append(pct) kg_lost.append(round(lost, 1)) return jsonify({ "names": names, "pct_lost": pct_lost, "kg_lost": kg_lost, }) @bp.route("/weekly-change/") @login_required def weekly_change(user_id): """Return weekly weight changes for bar chart.""" # Privacy guard: don't expose private user data to others if user_id != session.get("user_id"): target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,)) if target and target["is_private"]: return jsonify({"labels": [], "changes": []}) checkins = query( """SELECT weight_kg, checked_in_at FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC""", (user_id,), ) if len(checkins) < 2: return jsonify({"labels": [], "changes": []}) labels = [] changes = [] for i in range(1, len(checkins)): prev_w = float(checkins[i - 1]["weight_kg"]) curr_w = float(checkins[i]["weight_kg"]) change = round(curr_w - prev_w, 1) label = checkins[i]["checked_in_at"].replace(tzinfo=timezone.utc).astimezone(SYDNEY_TZ).strftime("%d %b") labels.append(label) changes.append(change) return jsonify({"labels": labels, "changes": changes})