Compare commits
8 Commits
c21a7890f3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2023e44624 | ||
|
|
c76b4cd6fc | ||
|
|
56168a182b | ||
|
|
f3abb4781b | ||
|
|
9323082d37 | ||
|
|
1c935a64be | ||
|
|
10256a1283 | ||
|
|
93c6822439 |
@@ -1,10 +1,7 @@
|
|||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from app.config import Config
|
from app.config import Config, SYDNEY_TZ
|
||||||
from app.db import init_db, close_db
|
from app.db import init_db, close_db
|
||||||
|
|
||||||
SYDNEY_TZ = timezone(timedelta(hours=11))
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -17,12 +14,17 @@ def create_app():
|
|||||||
# Jinja2 filter: convert UTC to Sydney time
|
# Jinja2 filter: convert UTC to Sydney time
|
||||||
@app.template_filter('sydney')
|
@app.template_filter('sydney')
|
||||||
def sydney_time_filter(dt, fmt='%d %b %Y, %H:%M'):
|
def sydney_time_filter(dt, fmt='%d %b %Y, %H:%M'):
|
||||||
|
from datetime import timezone
|
||||||
if dt is None:
|
if dt is None:
|
||||||
return ''
|
return ''
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
return dt.astimezone(SYDNEY_TZ).strftime(fmt)
|
return dt.astimezone(SYDNEY_TZ).strftime(fmt)
|
||||||
|
|
||||||
|
# Make milestone labels available to all templates
|
||||||
|
from app.utils import MILESTONE_LABELS
|
||||||
|
app.jinja_env.globals['MILESTONE_LABELS'] = MILESTONE_LABELS
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes.auth import bp as auth_bp
|
from app.routes.auth import bp as auth_bp
|
||||||
from app.routes.dashboard import bp as dashboard_bp
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
|
|||||||
25
app/auth.py
25
app/auth.py
@@ -1,5 +1,5 @@
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import session, redirect, url_for, request
|
from flask import g, session, redirect, url_for, request, jsonify
|
||||||
from app.db import query_one
|
from app.db import query_one
|
||||||
|
|
||||||
|
|
||||||
@@ -14,8 +14,27 @@ def login_required(f):
|
|||||||
|
|
||||||
|
|
||||||
def get_current_user():
|
def get_current_user():
|
||||||
"""Get the current logged-in user from the database."""
|
"""Get the current logged-in user (cached per-request on g)."""
|
||||||
|
if "current_user" in g:
|
||||||
|
return g.current_user
|
||||||
user_id = session.get("user_id")
|
user_id = session.get("user_id")
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return None
|
return None
|
||||||
return query_one("SELECT * FROM users WHERE id = %s", (user_id,))
|
g.current_user = query_one("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||||
|
return g.current_user
|
||||||
|
|
||||||
|
|
||||||
|
def privacy_guard(f):
|
||||||
|
"""Decorator for API endpoints that take a user_id parameter.
|
||||||
|
|
||||||
|
If the requested user is private and is not the current session user,
|
||||||
|
returns an empty JSON response instead of the actual data.
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(user_id, *args, **kwargs):
|
||||||
|
if user_id != session.get("user_id"):
|
||||||
|
target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,))
|
||||||
|
if target and target["is_private"]:
|
||||||
|
return jsonify({})
|
||||||
|
return f(user_id, *args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import timezone, timedelta
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Application-wide timezone for display formatting
|
||||||
|
SYDNEY_TZ = timezone(timedelta(hours=11))
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
DATABASE_URL = os.environ.get("DATABASE_URL")
|
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me-in-prod")
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me-in-prod")
|
||||||
|
PERMANENT_SESSION_LIFETIME = timedelta(days=30)
|
||||||
|
|||||||
40
app/db.py
40
app/db.py
@@ -1,33 +1,41 @@
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
|
import psycopg2.pool
|
||||||
from flask import g, current_app
|
from flask import g, current_app
|
||||||
|
|
||||||
|
# Module-level connection pool (initialised once per process)
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
|
||||||
def init_db(app):
|
def init_db(app):
|
||||||
"""Test the database connection on startup."""
|
"""Initialise the connection pool on startup."""
|
||||||
|
global _pool
|
||||||
try:
|
try:
|
||||||
conn = psycopg2.connect(app.config["DATABASE_URL"])
|
_pool = psycopg2.pool.SimpleConnectionPool(
|
||||||
conn.close()
|
minconn=2,
|
||||||
print(" * Database connection OK")
|
maxconn=10,
|
||||||
|
dsn=app.config["DATABASE_URL"],
|
||||||
|
)
|
||||||
|
print(" * Database connection pool OK (2–10 connections)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" * Database connection FAILED: {e}")
|
print(f" * Database connection pool FAILED: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get a database connection for the current request."""
|
"""Get a pooled database connection for the current request."""
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
g.db = psycopg2.connect(
|
g.db = _pool.getconn()
|
||||||
current_app.config["DATABASE_URL"],
|
g.db.cursor_factory = psycopg2.extras.RealDictCursor
|
||||||
cursor_factory=psycopg2.extras.RealDictCursor,
|
|
||||||
)
|
|
||||||
return g.db
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
def close_db(exception=None):
|
def close_db(exception=None):
|
||||||
"""Close database connection at end of request."""
|
"""Return database connection to the pool at end of request."""
|
||||||
db = g.pop("db", None)
|
db = g.pop("db", None)
|
||||||
if db is not None:
|
if db is not None:
|
||||||
db.close()
|
if exception:
|
||||||
|
db.rollback()
|
||||||
|
_pool.putconn(db)
|
||||||
|
|
||||||
|
|
||||||
def query(sql, params=None):
|
def query(sql, params=None):
|
||||||
@@ -62,3 +70,11 @@ def execute_returning(sql, params=None):
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
db.commit()
|
db.commit()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def execute_many(sql, params_list):
|
||||||
|
"""Execute a batch INSERT/UPDATE/DELETE and commit."""
|
||||||
|
db = get_db()
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.executemany(sql, params_list)
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -1,22 +1,133 @@
|
|||||||
from flask import Blueprint, jsonify, session
|
from collections import OrderedDict
|
||||||
from app import SYDNEY_TZ
|
from datetime import datetime, timezone
|
||||||
from app.auth import login_required
|
|
||||||
from app.db import query, query_one
|
from flask import Blueprint, jsonify, request, session
|
||||||
from datetime import timezone
|
from app.config import SYDNEY_TZ
|
||||||
|
from app.auth import login_required, privacy_guard
|
||||||
|
from app.db import query
|
||||||
|
from app.utils import calculate_weight_change
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
# Use CTE for first_weight instead of correlated subquery
|
||||||
|
rows = query(f"""
|
||||||
|
WITH first_weights AS (
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, weight_kg AS first_weight
|
||||||
|
FROM checkins
|
||||||
|
ORDER BY user_id, checked_in_at ASC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.id AS user_id,
|
||||||
|
u.display_name,
|
||||||
|
u.username,
|
||||||
|
u.starting_weight_kg,
|
||||||
|
fw.first_weight,
|
||||||
|
c.weight_kg,
|
||||||
|
c.checked_in_at
|
||||||
|
FROM checkins c
|
||||||
|
JOIN users u ON u.id = c.user_id
|
||||||
|
LEFT JOIN first_weights fw ON fw.user_id = u.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY u.id, c.checked_in_at ASC
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
# Group rows by user
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
@privacy_guard
|
||||||
def chart_data(user_id):
|
def chart_data(user_id):
|
||||||
"""Return weight & BMI over time for Chart.js."""
|
"""Return weight & BMI over time for Chart.js."""
|
||||||
# Privacy guard: don't expose private user data to others
|
|
||||||
if user_id != session.get("user_id"):
|
|
||||||
target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,))
|
|
||||||
if target and target["is_private"]:
|
|
||||||
return jsonify({"labels": [], "weights": [], "bmis": []})
|
|
||||||
|
|
||||||
checkins = query(
|
checkins = query(
|
||||||
"""SELECT weight_kg, bmi, checked_in_at
|
"""SELECT weight_kg, bmi, checked_in_at
|
||||||
FROM checkins WHERE user_id = %s
|
FROM checkins WHERE user_id = %s
|
||||||
@@ -39,17 +150,26 @@ def chart_data(user_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def comparison():
|
def comparison():
|
||||||
"""Return all-user comparison data for bar charts."""
|
"""Return all-user comparison data for bar charts."""
|
||||||
|
# Use CTE with window functions instead of correlated subqueries
|
||||||
users = query("""
|
users = query("""
|
||||||
|
WITH user_weights AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at ASC) AS first_weight,
|
||||||
|
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS current_weight,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS rn
|
||||||
|
FROM checkins
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.display_name,
|
u.display_name,
|
||||||
u.username,
|
u.username,
|
||||||
u.starting_weight_kg,
|
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,
|
uw.first_weight,
|
||||||
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight
|
uw.current_weight
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE (SELECT COUNT(*) FROM checkins WHERE user_id = u.id) > 0
|
JOIN user_weights uw ON uw.user_id = u.id AND uw.rn = 1
|
||||||
AND u.is_private = FALSE
|
WHERE u.is_private = FALSE
|
||||||
ORDER BY u.display_name
|
ORDER BY u.display_name
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -60,15 +180,10 @@ def comparison():
|
|||||||
for u in users:
|
for u in users:
|
||||||
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
||||||
current_w = float(u["current_weight"] or start_w)
|
current_w = float(u["current_weight"] or start_w)
|
||||||
if start_w > 0:
|
lost, pct = calculate_weight_change(start_w, current_w)
|
||||||
lost = start_w - current_w
|
|
||||||
pct = round((lost / start_w) * 100, 1)
|
|
||||||
else:
|
|
||||||
lost = 0
|
|
||||||
pct = 0
|
|
||||||
names.append(u["display_name"] or u["username"])
|
names.append(u["display_name"] or u["username"])
|
||||||
pct_lost.append(pct)
|
pct_lost.append(pct)
|
||||||
kg_lost.append(round(lost, 1))
|
kg_lost.append(lost)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"names": names,
|
"names": names,
|
||||||
@@ -79,14 +194,9 @@ def comparison():
|
|||||||
|
|
||||||
@bp.route("/weekly-change/<int:user_id>")
|
@bp.route("/weekly-change/<int:user_id>")
|
||||||
@login_required
|
@login_required
|
||||||
|
@privacy_guard
|
||||||
def weekly_change(user_id):
|
def weekly_change(user_id):
|
||||||
"""Return weekly weight changes for bar chart."""
|
"""Return weekly weight changes for bar chart."""
|
||||||
# Privacy guard: don't expose private user data to others
|
|
||||||
if user_id != session.get("user_id"):
|
|
||||||
target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,))
|
|
||||||
if target and target["is_private"]:
|
|
||||||
return jsonify({"labels": [], "changes": []})
|
|
||||||
|
|
||||||
checkins = query(
|
checkins = query(
|
||||||
"""SELECT weight_kg, checked_in_at
|
"""SELECT weight_kg, checked_in_at
|
||||||
FROM checkins WHERE user_id = %s
|
FROM checkins WHERE user_id = %s
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from app.db import query_one, execute_returning, execute
|
from app.db import query_one, execute_returning
|
||||||
|
from app.utils import parse_profile_fields
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__)
|
bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
@@ -10,13 +11,7 @@ def signup():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
display_name = request.form.get("display_name", "").strip()
|
fields = parse_profile_fields(request.form)
|
||||||
height_cm = request.form.get("height_cm") or None
|
|
||||||
age = request.form.get("age") or None
|
|
||||||
gender = request.form.get("gender") or None
|
|
||||||
goal_weight_kg = request.form.get("goal_weight_kg") or None
|
|
||||||
starting_weight_kg = request.form.get("starting_weight_kg") or None
|
|
||||||
is_private = request.form.get("is_private") == "on"
|
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
@@ -38,9 +33,14 @@ def signup():
|
|||||||
user = execute_returning(
|
user = execute_returning(
|
||||||
"""INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg, is_private)
|
"""INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg, is_private)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
|
||||||
(username, password_hash, display_name or username, height_cm, age, gender, goal_weight_kg, starting_weight_kg, is_private),
|
(username, password_hash,
|
||||||
|
fields["display_name"] or username,
|
||||||
|
fields["height_cm"], fields["age"], fields["gender"],
|
||||||
|
fields["goal_weight_kg"], fields["starting_weight_kg"],
|
||||||
|
fields["is_private"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
session.permanent = True
|
||||||
session["user_id"] = user["id"]
|
session["user_id"] = user["id"]
|
||||||
flash("Welcome! You're all signed up.", "success")
|
flash("Welcome! You're all signed up.", "success")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
@@ -59,6 +59,7 @@ def login():
|
|||||||
flash("Invalid username or password.", "error")
|
flash("Invalid username or password.", "error")
|
||||||
return render_template("login.html"), 401
|
return render_template("login.html"), 401
|
||||||
|
|
||||||
|
session.permanent = True
|
||||||
session["user_id"] = user["id"]
|
session["user_id"] = user["id"]
|
||||||
next_url = request.args.get("next", url_for("dashboard.index"))
|
next_url = request.args.get("next", url_for("dashboard.index"))
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
|
|||||||
@@ -1,57 +1,11 @@
|
|||||||
import math
|
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from app.auth import login_required, get_current_user
|
from app.auth import login_required, get_current_user
|
||||||
from app.db import query, query_one, execute, execute_returning
|
from app.db import query, query_one, execute, execute_returning
|
||||||
|
from app.utils import calculate_bmi, check_milestones
|
||||||
|
|
||||||
bp = Blueprint("checkin", __name__)
|
bp = Blueprint("checkin", __name__)
|
||||||
|
|
||||||
|
|
||||||
def calculate_bmi(weight_kg, height_cm):
|
|
||||||
"""Calculate BMI from weight (kg) and height (cm)."""
|
|
||||||
if not weight_kg or not height_cm or float(height_cm) == 0:
|
|
||||||
return None
|
|
||||||
h_m = float(height_cm) / 100.0
|
|
||||||
return round(float(weight_kg) / (h_m * h_m), 1)
|
|
||||||
|
|
||||||
|
|
||||||
def check_milestones(user_id, user):
|
|
||||||
"""Check and award any new milestones after a check-in."""
|
|
||||||
checkins = query(
|
|
||||||
"SELECT weight_kg, checked_in_at FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC",
|
|
||||||
(user_id,),
|
|
||||||
)
|
|
||||||
if not checkins:
|
|
||||||
return
|
|
||||||
|
|
||||||
starting = float(user.get("starting_weight_kg") or checkins[0]["weight_kg"])
|
|
||||||
current = float(checkins[-1]["weight_kg"])
|
|
||||||
total_lost = starting - current
|
|
||||||
count = len(checkins)
|
|
||||||
|
|
||||||
milestone_checks = [
|
|
||||||
("first_checkin", count >= 1),
|
|
||||||
("5_checkins", count >= 5),
|
|
||||||
("10_checkins", count >= 10),
|
|
||||||
("25_checkins", count >= 25),
|
|
||||||
("lost_1kg", total_lost >= 1),
|
|
||||||
("lost_2kg", total_lost >= 2),
|
|
||||||
("lost_5kg", total_lost >= 5),
|
|
||||||
("lost_10kg", total_lost >= 10),
|
|
||||||
("lost_15kg", total_lost >= 15),
|
|
||||||
("lost_20kg", total_lost >= 20),
|
|
||||||
]
|
|
||||||
|
|
||||||
for key, achieved in milestone_checks:
|
|
||||||
if achieved:
|
|
||||||
try:
|
|
||||||
execute(
|
|
||||||
"INSERT INTO milestones (user_id, milestone_key) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
|
||||||
(user_id, key),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/checkin", methods=["GET"])
|
@bp.route("/checkin", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
@@ -88,7 +42,6 @@ def create():
|
|||||||
(user["id"], weight_kg, bmi, notes or None),
|
(user["id"], weight_kg, bmi, notes or None),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check milestones
|
|
||||||
check_milestones(user["id"], user)
|
check_milestones(user["id"], user)
|
||||||
|
|
||||||
# If HTMX request, return just the new row
|
# If HTMX request, return just the new row
|
||||||
@@ -99,6 +52,72 @@ def create():
|
|||||||
return redirect(url_for("checkin.index"))
|
return redirect(url_for("checkin.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/checkin/<int:checkin_id>/edit", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def edit_form(checkin_id):
|
||||||
|
user = get_current_user()
|
||||||
|
checkin = query_one(
|
||||||
|
"SELECT * FROM checkins WHERE id = %s AND user_id = %s",
|
||||||
|
(checkin_id, user["id"]),
|
||||||
|
)
|
||||||
|
if not checkin:
|
||||||
|
return "", 404
|
||||||
|
return render_template("partials/checkin_edit_row.html", c=checkin, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/checkin/<int:checkin_id>/view", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def view_row(checkin_id):
|
||||||
|
user = get_current_user()
|
||||||
|
checkin = query_one(
|
||||||
|
"SELECT * FROM checkins WHERE id = %s AND user_id = %s",
|
||||||
|
(checkin_id, user["id"]),
|
||||||
|
)
|
||||||
|
if not checkin:
|
||||||
|
return "", 404
|
||||||
|
return render_template("partials/checkin_row.html", c=checkin, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/checkin/<int:checkin_id>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def update(checkin_id):
|
||||||
|
user = get_current_user()
|
||||||
|
weight_kg = request.form.get("weight_kg")
|
||||||
|
notes = request.form.get("notes", "").strip()
|
||||||
|
|
||||||
|
checkin = query_one(
|
||||||
|
"SELECT * FROM checkins WHERE id = %s AND user_id = %s",
|
||||||
|
(checkin_id, user["id"]),
|
||||||
|
)
|
||||||
|
if not checkin:
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
if not weight_kg:
|
||||||
|
flash("Weight is required.", "error")
|
||||||
|
return render_template("partials/checkin_edit_row.html", c=checkin, user=user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
weight_kg = float(weight_kg)
|
||||||
|
except ValueError:
|
||||||
|
flash("Invalid weight value.", "error")
|
||||||
|
return render_template("partials/checkin_edit_row.html", c=checkin, user=user)
|
||||||
|
|
||||||
|
bmi = calculate_bmi(weight_kg, user.get("height_cm"))
|
||||||
|
|
||||||
|
execute(
|
||||||
|
"""UPDATE checkins SET weight_kg = %s, bmi = %s, notes = %s
|
||||||
|
WHERE id = %s AND user_id = %s""",
|
||||||
|
(weight_kg, bmi, notes or None, checkin_id, user["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
check_milestones(user["id"], user)
|
||||||
|
|
||||||
|
updated = query_one(
|
||||||
|
"SELECT * FROM checkins WHERE id = %s", (checkin_id,)
|
||||||
|
)
|
||||||
|
return render_template("partials/checkin_row.html", c=updated, user=user)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/checkin/<int:checkin_id>", methods=["DELETE"])
|
@bp.route("/checkin/<int:checkin_id>", methods=["DELETE"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete(checkin_id):
|
def delete(checkin_id):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
from datetime import timezone
|
||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
from app.auth import login_required, get_current_user
|
from app.auth import login_required, get_current_user
|
||||||
from app.db import query, query_one
|
from app.db import query, query_one
|
||||||
|
from app.config import SYDNEY_TZ
|
||||||
|
from app.utils import calculate_streak, calculate_weight_change
|
||||||
|
|
||||||
bp = Blueprint("dashboard", __name__)
|
bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
@@ -9,38 +12,53 @@ bp = Blueprint("dashboard", __name__)
|
|||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
|
uid = user["id"]
|
||||||
|
|
||||||
# Get latest check-in
|
# --- Single query: latest, first, count via window functions ----------
|
||||||
latest = query_one(
|
summary = query_one("""
|
||||||
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 1",
|
SELECT
|
||||||
(user["id"],),
|
total,
|
||||||
)
|
first_weight,
|
||||||
|
latest_weight,
|
||||||
# Get check-in count
|
latest_bmi,
|
||||||
stats = query_one(
|
latest_at
|
||||||
"SELECT COUNT(*) as total_checkins FROM checkins WHERE user_id = %s",
|
FROM (
|
||||||
(user["id"],),
|
SELECT
|
||||||
)
|
COUNT(*) OVER () AS total,
|
||||||
|
FIRST_VALUE(weight_kg) OVER (ORDER BY checked_in_at ASC) AS first_weight,
|
||||||
# Calculate weight change
|
FIRST_VALUE(weight_kg) OVER (ORDER BY checked_in_at DESC) AS latest_weight,
|
||||||
first_checkin = query_one(
|
FIRST_VALUE(bmi) OVER (ORDER BY checked_in_at DESC) AS latest_bmi,
|
||||||
"SELECT weight_kg FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC LIMIT 1",
|
FIRST_VALUE(checked_in_at) OVER (ORDER BY checked_in_at DESC) AS latest_at,
|
||||||
(user["id"],),
|
ROW_NUMBER() OVER (ORDER BY checked_in_at DESC) AS rn
|
||||||
)
|
FROM checkins
|
||||||
|
WHERE user_id = %s
|
||||||
|
) sub
|
||||||
|
WHERE rn = 1
|
||||||
|
""", (uid,))
|
||||||
|
|
||||||
|
# Build lightweight "latest" dict for the template
|
||||||
|
latest = None
|
||||||
weight_change = None
|
weight_change = None
|
||||||
weight_change_pct = None
|
weight_change_pct = None
|
||||||
if latest and first_checkin:
|
total_checkins = 0
|
||||||
start_w = float(first_checkin["weight_kg"])
|
|
||||||
current_w = float(latest["weight_kg"])
|
if summary:
|
||||||
weight_change = round(current_w - start_w, 1)
|
total_checkins = summary["total"]
|
||||||
if start_w > 0:
|
latest = {
|
||||||
weight_change_pct = round((weight_change / start_w) * 100, 1)
|
"weight_kg": summary["latest_weight"],
|
||||||
|
"bmi": summary["latest_bmi"],
|
||||||
|
"checked_in_at": summary["latest_at"],
|
||||||
|
}
|
||||||
|
kg_lost, pct_lost = calculate_weight_change(
|
||||||
|
summary["first_weight"], summary["latest_weight"]
|
||||||
|
)
|
||||||
|
weight_change = round(-kg_lost, 1)
|
||||||
|
weight_change_pct = round(-pct_lost, 1)
|
||||||
|
|
||||||
# Recent check-ins (last 5)
|
# Recent check-ins (last 5)
|
||||||
recent_checkins = query(
|
recent_checkins = query(
|
||||||
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 5",
|
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 5",
|
||||||
(user["id"],),
|
(uid,),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Activity feed (recent check-ins from all users)
|
# Activity feed (recent check-ins from all users)
|
||||||
@@ -51,22 +69,57 @@ def index():
|
|||||||
WHERE u.is_private = FALSE OR u.id = %s
|
WHERE u.is_private = FALSE OR u.id = %s
|
||||||
ORDER BY c.checked_in_at DESC
|
ORDER BY c.checked_in_at DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
""", (user["id"],))
|
""", (uid,))
|
||||||
|
|
||||||
# Milestones
|
# Milestones
|
||||||
milestones = query(
|
milestones = query(
|
||||||
"SELECT * FROM milestones WHERE user_id = %s ORDER BY achieved_at DESC",
|
"SELECT * FROM milestones WHERE user_id = %s ORDER BY achieved_at DESC",
|
||||||
(user["id"],),
|
(uid,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Streak
|
||||||
|
streak = calculate_streak(uid)
|
||||||
|
|
||||||
|
# --- Pre-compute chart data (eliminates 2 client-side fetches) --------
|
||||||
|
chart_checkins = query(
|
||||||
|
"""SELECT weight_kg, bmi, checked_in_at
|
||||||
|
FROM checkins WHERE user_id = %s
|
||||||
|
ORDER BY checked_in_at ASC""",
|
||||||
|
(uid,),
|
||||||
|
)
|
||||||
|
|
||||||
|
chart_labels = []
|
||||||
|
chart_weights = []
|
||||||
|
weekly_labels = []
|
||||||
|
weekly_changes = []
|
||||||
|
|
||||||
|
for i, c in enumerate(chart_checkins):
|
||||||
|
dt = c["checked_in_at"]
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
label = dt.astimezone(SYDNEY_TZ).strftime("%d %b")
|
||||||
|
chart_labels.append(label)
|
||||||
|
chart_weights.append(float(c["weight_kg"]))
|
||||||
|
|
||||||
|
if i > 0:
|
||||||
|
prev_w = float(chart_checkins[i - 1]["weight_kg"])
|
||||||
|
curr_w = float(c["weight_kg"])
|
||||||
|
weekly_labels.append(label)
|
||||||
|
weekly_changes.append(round(curr_w - prev_w, 1))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
user=user,
|
user=user,
|
||||||
latest=latest,
|
latest=latest,
|
||||||
stats=stats,
|
stats={"total_checkins": total_checkins},
|
||||||
weight_change=weight_change,
|
weight_change=weight_change,
|
||||||
weight_change_pct=weight_change_pct,
|
weight_change_pct=weight_change_pct,
|
||||||
recent_checkins=recent_checkins,
|
recent_checkins=recent_checkins,
|
||||||
activity=activity,
|
activity=activity,
|
||||||
milestones=milestones,
|
milestones=milestones,
|
||||||
|
streak=streak,
|
||||||
|
chart_labels=chart_labels,
|
||||||
|
chart_weights=chart_weights,
|
||||||
|
weekly_labels=weekly_labels,
|
||||||
|
weekly_changes=weekly_changes,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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.config import SYDNEY_TZ
|
||||||
|
from app.utils import calculate_streaks_bulk, calculate_weight_change
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
bp = Blueprint("leaderboard", __name__)
|
bp = Blueprint("leaderboard", __name__)
|
||||||
|
|
||||||
@@ -8,35 +11,44 @@ bp = Blueprint("leaderboard", __name__)
|
|||||||
@bp.route("/leaderboard")
|
@bp.route("/leaderboard")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
# Get all users with their weight stats
|
# Get all users with weight stats using window functions (no correlated subqueries)
|
||||||
users = query("""
|
users = query("""
|
||||||
|
WITH user_weights AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at ASC) AS first_weight,
|
||||||
|
FIRST_VALUE(weight_kg) OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS current_weight,
|
||||||
|
COUNT(*) OVER (PARTITION BY user_id) AS total_checkins,
|
||||||
|
MAX(checked_in_at) OVER (PARTITION BY user_id) AS last_checkin,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY checked_in_at DESC) AS rn
|
||||||
|
FROM checkins
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.display_name,
|
u.display_name,
|
||||||
u.username,
|
u.username,
|
||||||
u.starting_weight_kg,
|
u.starting_weight_kg,
|
||||||
u.goal_weight_kg,
|
u.goal_weight_kg,
|
||||||
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at ASC LIMIT 1) as first_weight,
|
uw.first_weight,
|
||||||
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight,
|
uw.current_weight,
|
||||||
(SELECT COUNT(*) FROM checkins WHERE user_id = u.id) as total_checkins,
|
uw.total_checkins,
|
||||||
(SELECT checked_in_at FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as last_checkin
|
uw.last_checkin
|
||||||
FROM users u
|
FROM users u
|
||||||
|
JOIN user_weights uw ON uw.user_id = u.id AND uw.rn = 1
|
||||||
WHERE u.is_private = FALSE
|
WHERE u.is_private = FALSE
|
||||||
ORDER BY u.created_at
|
ORDER BY u.created_at
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Batch-compute streaks for all users in one query
|
||||||
|
user_ids = [u["id"] for u in users]
|
||||||
|
all_streaks = calculate_streaks_bulk(user_ids)
|
||||||
|
|
||||||
# Calculate rankings
|
# Calculate rankings
|
||||||
ranked = []
|
ranked = []
|
||||||
for u in users:
|
for u in users:
|
||||||
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
|
||||||
current_w = float(u["current_weight"] or start_w)
|
current_w = float(u["current_weight"] or start_w)
|
||||||
|
weight_lost, pct_lost = calculate_weight_change(start_w, current_w)
|
||||||
if start_w > 0:
|
|
||||||
weight_lost = start_w - current_w
|
|
||||||
pct_lost = round((weight_lost / start_w) * 100, 1)
|
|
||||||
else:
|
|
||||||
weight_lost = 0
|
|
||||||
pct_lost = 0
|
|
||||||
|
|
||||||
goal = float(u["goal_weight_kg"]) if u["goal_weight_kg"] else None
|
goal = float(u["goal_weight_kg"]) if u["goal_weight_kg"] else None
|
||||||
goal_progress = None
|
goal_progress = None
|
||||||
@@ -44,14 +56,37 @@ def index():
|
|||||||
total_to_lose = start_w - goal
|
total_to_lose = start_w - goal
|
||||||
goal_progress = min(100, round((weight_lost / total_to_lose) * 100, 1)) if total_to_lose > 0 else 0
|
goal_progress = min(100, round((weight_lost / total_to_lose) * 100, 1)) if total_to_lose > 0 else 0
|
||||||
|
|
||||||
|
streak = all_streaks.get(u["id"], {"current": 0, "best": 0})
|
||||||
ranked.append({
|
ranked.append({
|
||||||
**u,
|
**u,
|
||||||
"weight_lost": round(weight_lost, 1),
|
"weight_lost": weight_lost,
|
||||||
"pct_lost": pct_lost,
|
"pct_lost": pct_lost,
|
||||||
"goal_progress": goal_progress,
|
"goal_progress": goal_progress,
|
||||||
|
"streak": streak["current"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# 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)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from app.auth import login_required, get_current_user
|
from app.auth import login_required, get_current_user
|
||||||
from app.db import execute
|
from app.db import execute
|
||||||
|
from app.utils import parse_profile_fields
|
||||||
|
|
||||||
bp = Blueprint("profile", __name__)
|
bp = Blueprint("profile", __name__)
|
||||||
|
|
||||||
@@ -16,21 +17,17 @@ def index():
|
|||||||
@login_required
|
@login_required
|
||||||
def update():
|
def update():
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
display_name = request.form.get("display_name", "").strip()
|
fields = parse_profile_fields(request.form)
|
||||||
height_cm = request.form.get("height_cm") or None
|
|
||||||
age = request.form.get("age") or None
|
|
||||||
gender = request.form.get("gender") or None
|
|
||||||
goal_weight_kg = request.form.get("goal_weight_kg") or None
|
|
||||||
starting_weight_kg = request.form.get("starting_weight_kg") or None
|
|
||||||
is_private = request.form.get("is_private") == "on"
|
|
||||||
|
|
||||||
execute(
|
execute(
|
||||||
"""UPDATE users
|
"""UPDATE users
|
||||||
SET display_name = %s, height_cm = %s, age = %s, gender = %s,
|
SET display_name = %s, height_cm = %s, age = %s, gender = %s,
|
||||||
goal_weight_kg = %s, starting_weight_kg = %s, is_private = %s
|
goal_weight_kg = %s, starting_weight_kg = %s, is_private = %s
|
||||||
WHERE id = %s""",
|
WHERE id = %s""",
|
||||||
(display_name or user["username"], height_cm, age, gender,
|
(fields["display_name"] or user["username"],
|
||||||
goal_weight_kg, starting_weight_kg, is_private, user["id"]),
|
fields["height_cm"], fields["age"], fields["gender"],
|
||||||
|
fields["goal_weight_kg"], fields["starting_weight_kg"],
|
||||||
|
fields["is_private"], user["id"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-card-streak::before {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -737,6 +742,42 @@ tr:hover td {
|
|||||||
border-color: rgba(239, 68, 68, 0.2);
|
border-color: rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon-success:hover {
|
||||||
|
color: var(--success) !important;
|
||||||
|
background: var(--success-bg) !important;
|
||||||
|
border-color: rgba(16, 185, 129, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== INLINE EDIT ========== */
|
||||||
|
.checkin-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 120px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
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;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-row td {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== EMPTY STATE ========== */
|
/* ========== EMPTY STATE ========== */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -814,6 +855,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 {
|
||||||
@@ -845,20 +974,46 @@ tr:hover td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.charts-grid {
|
.charts-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-2,
|
.grid-2,
|
||||||
.grid-3 {
|
.grid-3 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-inline {
|
.form-inline {
|
||||||
@@ -870,12 +1025,16 @@ tr:hover td {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header h1 {
|
.page-header {
|
||||||
font-size: 1.4rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.page-header h1 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -891,10 +1050,46 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -34,9 +34,11 @@
|
|||||||
<div class="stat-label">Current BMI</div>
|
<div class="stat-label">Current BMI</div>
|
||||||
<div class="stat-value">{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else '—' }}</div>
|
<div class="stat-value">{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card {{ 'stat-card-streak' if streak.current >= 3 }}">
|
||||||
<div class="stat-label">Check-ins</div>
|
<div class="stat-label">Check-ins</div>
|
||||||
<div class="stat-value">{{ stats.total_checkins if stats else 0 }}</div>
|
<div class="stat-value">{{ stats.total_checkins if stats else 0 }}</div>
|
||||||
|
<div class="stat-change" style="color: var(--warning);">🔥 {{ streak.current }}-day streak{% if streak.best >
|
||||||
|
streak.current %} · Best: {{ streak.best }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,18 +103,7 @@
|
|||||||
<div class="milestones-grid">
|
<div class="milestones-grid">
|
||||||
{% for m in milestones %}
|
{% for m in milestones %}
|
||||||
<span class="milestone-badge {{ 'gold' if 'lost' in m.milestone_key else '' }}">
|
<span class="milestone-badge {{ 'gold' if 'lost' in m.milestone_key else '' }}">
|
||||||
{% if m.milestone_key == 'first_checkin' %}✅ First Check-in
|
{{ MILESTONE_LABELS.get(m.milestone_key, m.milestone_key) }}
|
||||||
{% elif m.milestone_key == '5_checkins' %}🔥 5 Check-ins
|
|
||||||
{% elif m.milestone_key == '10_checkins' %}💪 10 Check-ins
|
|
||||||
{% elif m.milestone_key == '25_checkins' %}🎯 25 Check-ins
|
|
||||||
{% elif m.milestone_key == 'lost_1kg' %}⭐ 1kg Lost
|
|
||||||
{% elif m.milestone_key == 'lost_2kg' %}⭐ 2kg Lost
|
|
||||||
{% elif m.milestone_key == 'lost_5kg' %}🌟 5kg Lost
|
|
||||||
{% elif m.milestone_key == 'lost_10kg' %}💎 10kg Lost
|
|
||||||
{% elif m.milestone_key == 'lost_15kg' %}👑 15kg Lost
|
|
||||||
{% elif m.milestone_key == 'lost_20kg' %}🏆 20kg Lost
|
|
||||||
{% else %}{{ m.milestone_key }}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,21 +143,18 @@
|
|||||||
<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 () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
fetch('/api/chart-data/{{ user.id }}')
|
const chartLabels = {{ chart_labels | tojson
|
||||||
.then(r => r.json())
|
}};
|
||||||
.then(data => {
|
const chartWeights = {{ chart_weights | tojson }};
|
||||||
if (data.labels.length > 0) {
|
const weeklyLabels = {{ weekly_labels | tojson }};
|
||||||
createWeightChart('weightChart', data.labels, data.weights);
|
const weeklyChanges = {{ weekly_changes | tojson }};
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch('/api/weekly-change/{{ user.id }}')
|
if (chartLabels.length > 0) {
|
||||||
.then(r => r.json())
|
createWeightChart('weightChart', chartLabels, chartWeights);
|
||||||
.then(data => {
|
}
|
||||||
if (data.labels.length > 0) {
|
if (weeklyLabels.length > 0) {
|
||||||
createWeeklyChangeChart('weeklyChart', data.labels, data.changes);
|
createWeeklyChangeChart('weeklyChart', weeklyLabels, weeklyChanges);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -32,6 +56,7 @@
|
|||||||
<th>Lost (%)</th>
|
<th>Lost (%)</th>
|
||||||
<th>Goal Progress</th>
|
<th>Goal Progress</th>
|
||||||
<th>Check-ins</th>
|
<th>Check-ins</th>
|
||||||
|
<th>Streak</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -72,6 +97,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ u.total_checkins }}</td>
|
<td>{{ u.total_checkins }}</td>
|
||||||
|
<td>{% if u.streak > 0 %}<span style="color: var(--warning);">🔥 {{ u.streak }}d</span>{% else
|
||||||
|
%}<span style="color: var(--text-muted);">—</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -90,13 +117,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>
|
||||||
|
|||||||
19
app/templates/partials/checkin_edit_row.html
Normal file
19
app/templates/partials/checkin_edit_row.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<tr id="checkin-{{ c.id }}" class="editing-row">
|
||||||
|
<td>{{ c.checked_in_at | sydney }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" name="weight_kg" class="edit-input" step="0.1" value="{{ '%.1f' % (c.weight_kg | float) }}"
|
||||||
|
required autofocus>
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-muted); font-size: 0.8rem;">auto</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="notes" class="edit-input" placeholder="Notes" value="{{ c.notes or '' }}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="checkin-actions">
|
||||||
|
<button class="btn-icon btn-icon-success" hx-put="/checkin/{{ c.id }}" hx-target="#checkin-{{ c.id }}"
|
||||||
|
hx-swap="outerHTML" hx-include="closest tr" title="Save">✓</button>
|
||||||
|
<button class="btn-icon" hx-get="/checkin/{{ c.id }}/view" hx-target="#checkin-{{ c.id }}"
|
||||||
|
hx-swap="outerHTML" title="Cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -4,7 +4,11 @@
|
|||||||
<td>{{ '%.1f' % (c.bmi | float) if c.bmi else '—' }}</td>
|
<td>{{ '%.1f' % (c.bmi | float) if c.bmi else '—' }}</td>
|
||||||
<td>{{ c.notes or '—' }}</td>
|
<td>{{ c.notes or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="checkin-actions">
|
||||||
|
<button class="btn-icon" hx-get="/checkin/{{ c.id }}/edit" hx-target="#checkin-{{ c.id }}"
|
||||||
|
hx-swap="outerHTML" title="Edit">✏️</button>
|
||||||
<button class="btn-icon" hx-delete="/checkin/{{ c.id }}" hx-target="#checkin-{{ c.id }}" hx-swap="outerHTML"
|
<button class="btn-icon" hx-delete="/checkin/{{ c.id }}" hx-target="#checkin-{{ c.id }}" hx-swap="outerHTML"
|
||||||
hx-confirm="Delete this check-in?">🗑️</button>
|
hx-confirm="Delete this check-in?" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
200
app/utils.py
Normal file
200
app/utils.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Shared business-logic helpers.
|
||||||
|
|
||||||
|
Keep route handlers thin — calculation logic lives here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.db import query, execute_many
|
||||||
|
from app.config import SYDNEY_TZ
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Milestones — single source of truth for keys, thresholds, and labels
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MILESTONES = [
|
||||||
|
# (key, check_fn_or_description, emoji + display label)
|
||||||
|
("first_checkin", "count >= 1", "✅ First Check-in"),
|
||||||
|
("5_checkins", "count >= 5", "🔥 5 Check-ins"),
|
||||||
|
("10_checkins", "count >= 10", "💪 10 Check-ins"),
|
||||||
|
("25_checkins", "count >= 25", "🎯 25 Check-ins"),
|
||||||
|
("lost_1kg", "lost >= 1", "⭐ 1kg Lost"),
|
||||||
|
("lost_2kg", "lost >= 2", "⭐ 2kg Lost"),
|
||||||
|
("lost_5kg", "lost >= 5", "🌟 5kg Lost"),
|
||||||
|
("lost_10kg", "lost >= 10", "💎 10kg Lost"),
|
||||||
|
("lost_15kg", "lost >= 15", "👑 15kg Lost"),
|
||||||
|
("lost_20kg", "lost >= 20", "🏆 20kg Lost"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Quick lookup: milestone_key → display label (used in templates)
|
||||||
|
MILESTONE_LABELS = {key: label for key, _, label in MILESTONES}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Weight calculations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def calculate_bmi(weight_kg, height_cm):
|
||||||
|
"""Calculate BMI from weight (kg) and height (cm)."""
|
||||||
|
if not weight_kg or not height_cm or float(height_cm) == 0:
|
||||||
|
return None
|
||||||
|
h_m = float(height_cm) / 100.0
|
||||||
|
return round(float(weight_kg) / (h_m * h_m), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_weight_change(start_w, current_w):
|
||||||
|
"""Return (kg_lost, pct_lost) from a start and current weight.
|
||||||
|
|
||||||
|
kg_lost is positive when weight decreased.
|
||||||
|
pct_lost is the percentage of start weight that was lost.
|
||||||
|
"""
|
||||||
|
start_w = float(start_w or 0)
|
||||||
|
current_w = float(current_w or start_w)
|
||||||
|
|
||||||
|
if start_w > 0:
|
||||||
|
kg_lost = round(start_w - current_w, 1)
|
||||||
|
pct_lost = round((kg_lost / start_w) * 100, 1)
|
||||||
|
else:
|
||||||
|
kg_lost = 0.0
|
||||||
|
pct_lost = 0.0
|
||||||
|
|
||||||
|
return kg_lost, pct_lost
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Streaks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _compute_streak_from_dates(days, today):
|
||||||
|
"""Compute current and best streak from a sorted-desc list of dates."""
|
||||||
|
if not days:
|
||||||
|
return {"current": 0, "best": 0}
|
||||||
|
|
||||||
|
# Current streak: must include today or yesterday to count
|
||||||
|
current = 0
|
||||||
|
expected = today
|
||||||
|
if days[0] == today or days[0] == today - timedelta(days=1):
|
||||||
|
expected = days[0]
|
||||||
|
for d in days:
|
||||||
|
if d == expected:
|
||||||
|
current += 1
|
||||||
|
expected -= timedelta(days=1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Best streak
|
||||||
|
best = 1
|
||||||
|
run = 1
|
||||||
|
for i in range(1, len(days)):
|
||||||
|
if days[i] == days[i - 1] - timedelta(days=1):
|
||||||
|
run += 1
|
||||||
|
best = max(best, run)
|
||||||
|
else:
|
||||||
|
run = 1
|
||||||
|
|
||||||
|
best = max(best, current)
|
||||||
|
return {"current": current, "best": best}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_streak(user_id):
|
||||||
|
"""Calculate current and best consecutive-day check-in streaks."""
|
||||||
|
rows = query(
|
||||||
|
"""SELECT DISTINCT (checked_in_at AT TIME ZONE 'UTC' AT TIME ZONE 'Australia/Sydney')::date AS d
|
||||||
|
FROM checkins WHERE user_id = %s ORDER BY d DESC""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
days = [r["d"] for r in rows]
|
||||||
|
today = datetime.now(SYDNEY_TZ).date()
|
||||||
|
return _compute_streak_from_dates(days, today)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_streaks_bulk(user_ids):
|
||||||
|
"""Calculate streaks for multiple users in a single query.
|
||||||
|
|
||||||
|
Returns a dict: {user_id: {"current": int, "best": int}}.
|
||||||
|
"""
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
placeholders = ",".join(["%s"] * len(user_ids))
|
||||||
|
rows = query(
|
||||||
|
f"""SELECT user_id,
|
||||||
|
(checked_in_at AT TIME ZONE 'UTC' AT TIME ZONE 'Australia/Sydney')::date AS d
|
||||||
|
FROM checkins
|
||||||
|
WHERE user_id IN ({placeholders})
|
||||||
|
GROUP BY user_id, d
|
||||||
|
ORDER BY user_id, d DESC""",
|
||||||
|
tuple(user_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group by user
|
||||||
|
from collections import defaultdict
|
||||||
|
user_days = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
user_days[r["user_id"]].append(r["d"])
|
||||||
|
|
||||||
|
today = datetime.now(SYDNEY_TZ).date()
|
||||||
|
result = {}
|
||||||
|
for uid in user_ids:
|
||||||
|
result[uid] = _compute_streak_from_dates(user_days.get(uid, []), today)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Milestone checker
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def check_milestones(user_id, user):
|
||||||
|
"""Check and award any new milestones after a check-in."""
|
||||||
|
checkins = query(
|
||||||
|
"SELECT weight_kg, checked_in_at FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
if not checkins:
|
||||||
|
return
|
||||||
|
|
||||||
|
starting = float(user.get("starting_weight_kg") or checkins[0]["weight_kg"])
|
||||||
|
current = float(checkins[-1]["weight_kg"])
|
||||||
|
total_lost = starting - current
|
||||||
|
count = len(checkins)
|
||||||
|
|
||||||
|
milestone_checks = [
|
||||||
|
("first_checkin", count >= 1),
|
||||||
|
("5_checkins", count >= 5),
|
||||||
|
("10_checkins", count >= 10),
|
||||||
|
("25_checkins", count >= 25),
|
||||||
|
("lost_1kg", total_lost >= 1),
|
||||||
|
("lost_2kg", total_lost >= 2),
|
||||||
|
("lost_5kg", total_lost >= 5),
|
||||||
|
("lost_10kg", total_lost >= 10),
|
||||||
|
("lost_15kg", total_lost >= 15),
|
||||||
|
("lost_20kg", total_lost >= 20),
|
||||||
|
]
|
||||||
|
|
||||||
|
achieved = [(user_id, key) for key, ok in milestone_checks if ok]
|
||||||
|
if achieved:
|
||||||
|
execute_many(
|
||||||
|
"INSERT INTO milestones (user_id, milestone_key) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
achieved,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Form helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_profile_fields(form):
|
||||||
|
"""Extract the common profile fields from a request form.
|
||||||
|
|
||||||
|
Returns a dict suitable for both signup and profile-update flows.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"display_name": form.get("display_name", "").strip(),
|
||||||
|
"height_cm": form.get("height_cm") or None,
|
||||||
|
"age": form.get("age") or None,
|
||||||
|
"gender": form.get("gender") or None,
|
||||||
|
"goal_weight_kg": form.get("goal_weight_kg") or None,
|
||||||
|
"starting_weight_kg": form.get("starting_weight_kg") or None,
|
||||||
|
"is_private": form.get("is_private") == "on",
|
||||||
|
}
|
||||||
4
migrations/003_add_indexes.sql
Normal file
4
migrations/003_add_indexes.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 003: Add indexes for performance
|
||||||
|
-- Partial index to speed up queries that filter on is_private = FALSE
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_public ON users(id) WHERE is_private = FALSE;
|
||||||
Reference in New Issue
Block a user