Compare commits
2 Commits
c21a7890f3
...
10256a1283
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10256a1283 | ||
|
|
93c6822439 |
@@ -1,11 +1,120 @@
|
|||||||
from flask import Blueprint, jsonify, session
|
from flask import Blueprint, jsonify, request, session
|
||||||
from app import SYDNEY_TZ
|
from app import SYDNEY_TZ
|
||||||
from app.auth import login_required
|
from app.auth import login_required
|
||||||
from app.db import query, query_one
|
from app.db import query, query_one
|
||||||
from datetime import timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
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>")
|
@bp.route("/chart-data/<int:user_id>")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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 import SYDNEY_TZ
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
bp = Blueprint("leaderboard", __name__)
|
bp = Blueprint("leaderboard", __name__)
|
||||||
|
|
||||||
@@ -54,4 +56,25 @@ def index():
|
|||||||
# 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)
|
||||||
|
|||||||
@@ -814,6 +814,94 @@ tr:hover td {
|
|||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
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 {
|
||||||
@@ -891,6 +979,25 @@ tr:hover td {
|
|||||||
td {
|
td {
|
||||||
padding: 0.5rem 0.65rem;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -90,14 +114,50 @@
|
|||||||
{% 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>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user