Change leadership graph to line and add linear best fit
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user