diff --git a/app/routes/api.py b/app/routes/api.py index f8e83c3..cb74eeb 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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/") @login_required diff --git a/app/static/css/style.css b/app/static/css/style.css index 9a73c20..08394b9 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -814,6 +814,94 @@ tr:hover td { 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 ========== */ @media (max-width: 768px) { .navbar { diff --git a/app/static/js/charts.js b/app/static/js/charts.js index 777a1ed..83f1d59 100644 --- a/app/static/js/charts.js +++ b/app/static/js/charts.js @@ -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`, + }, + }, + }, + }, + }); +} + diff --git a/app/templates/base.html b/app/templates/base.html index 50ab092..2b34050 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,6 @@ + @@ -7,11 +8,15 @@ - + + + {% if session.get('user_id') %}