Change leadership graph to line and add linear best fit

This commit is contained in:
Peter Stockings
2026-02-24 14:00:51 +11:00
parent c21a7890f3
commit 93c6822439
5 changed files with 391 additions and 15 deletions

View File

@@ -1,11 +1,120 @@
from flask import Blueprint, jsonify, session
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 timezone
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/<int:user_id>")
@login_required