Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Stockings
10256a1283 Prefill date selectors on leadership page with earliest/latest dates 2026-02-24 20:09:18 +11:00
Peter Stockings
93c6822439 Change leadership graph to line and add linear best fit 2026-02-24 14:00:51 +11:00
6 changed files with 435 additions and 17 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 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

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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`,
},
},
},
},
});
}

View File

@@ -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>

View File

@@ -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,13 +114,49 @@
{% 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>