Compare commits

..

4 Commits

Author SHA1 Message Date
Peter Stockings
56168a182b Refactor codebase 2026-02-24 21:23:14 +11:00
Peter Stockings
f3abb4781b Show daily streak count on leaderboard as well 2026-02-24 21:12:45 +11:00
Peter Stockings
9323082d37 Show daily streak on dashboard 2026-02-24 20:58:04 +11:00
Peter Stockings
1c935a64be Allow users to edit checkins 2026-02-24 20:57:45 +11:00
15 changed files with 428 additions and 131 deletions

View File

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

View File

@@ -1,5 +1,5 @@
from functools import wraps from functools import wraps
from flask import session, redirect, url_for, request from flask import session, redirect, url_for, request, jsonify
from app.db import query_one from app.db import query_one
@@ -19,3 +19,19 @@ def get_current_user():
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,)) return query_one("SELECT * FROM users WHERE id = %s", (user_id,))
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

View File

@@ -1,8 +1,12 @@
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")

View File

@@ -1,9 +1,12 @@
from flask import Blueprint, jsonify, request, session from collections import OrderedDict
from app import SYDNEY_TZ
from app.auth import login_required
from app.db import query, query_one
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import Blueprint, jsonify, request, session
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 # Distinct hues for up to 12 users; cycles if more
@@ -58,7 +61,6 @@ def progress_over_time():
""", params) """, params)
# Group rows by user # Group rows by user
from collections import OrderedDict
users_map = OrderedDict() users_map = OrderedDict()
for r in rows: for r in rows:
uid = r["user_id"] uid = r["user_id"]
@@ -90,7 +92,6 @@ def progress_over_time():
points = entry["data"] points = entry["data"]
best_fit = {"slope": 0, "intercept": 0} best_fit = {"slope": 0, "intercept": 0}
if len(points) >= 2: if len(points) >= 2:
# Convert dates to day offsets from first point
base = datetime.strptime(points[0]["date"], "%Y-%m-%d") base = datetime.strptime(points[0]["date"], "%Y-%m-%d")
xs = [(datetime.strptime(p["date"], "%Y-%m-%d") - base).days for p in points] xs = [(datetime.strptime(p["date"], "%Y-%m-%d") - base).days for p in points]
ys = [p["weight"] for p in points] ys = [p["weight"] for p in points]
@@ -118,14 +119,9 @@ def progress_over_time():
@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
@@ -169,15 +165,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,
@@ -188,14 +179,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

View File

@@ -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,7 +33,11 @@ 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["user_id"] = user["id"] session["user_id"] = user["id"]

View File

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

View File

@@ -1,6 +1,7 @@
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.utils import calculate_streak, calculate_weight_change
bp = Blueprint("dashboard", __name__) bp = Blueprint("dashboard", __name__)
@@ -31,11 +32,11 @@ def index():
weight_change = None weight_change = None
weight_change_pct = None weight_change_pct = None
if latest and first_checkin: if latest and first_checkin:
start_w = float(first_checkin["weight_kg"]) kg_lost, pct_lost = calculate_weight_change(
current_w = float(latest["weight_kg"]) first_checkin["weight_kg"], latest["weight_kg"]
weight_change = round(current_w - start_w, 1) )
if start_w > 0: weight_change = round(-kg_lost, 1) # negative = gained, positive = lost
weight_change_pct = round((weight_change / start_w) * 100, 1) weight_change_pct = round(-pct_lost, 1)
# Recent check-ins (last 5) # Recent check-ins (last 5)
recent_checkins = query( recent_checkins = query(
@@ -59,6 +60,9 @@ def index():
(user["id"],), (user["id"],),
) )
# Streak
streak = calculate_streak(user["id"])
return render_template( return render_template(
"dashboard.html", "dashboard.html",
user=user, user=user,
@@ -69,4 +73,5 @@ def index():
recent_checkins=recent_checkins, recent_checkins=recent_checkins,
activity=activity, activity=activity,
milestones=milestones, milestones=milestones,
streak=streak,
) )

View File

@@ -1,7 +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, query_one from app.db import query, query_one
from app import SYDNEY_TZ from app.config import SYDNEY_TZ
from app.utils import calculate_streak, calculate_weight_change
from datetime import timezone from datetime import timezone
bp = Blueprint("leaderboard", __name__) bp = Blueprint("leaderboard", __name__)
@@ -32,13 +33,7 @@ def index():
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
@@ -46,11 +41,13 @@ 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 = calculate_streak(u["id"])
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)

View File

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

View File

@@ -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;
@@ -933,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 {
@@ -958,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 {
@@ -998,10 +1069,27 @@ tr:hover td {
.filter-group-people { .filter-group-people {
min-width: unset; 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;
} }
} }

View File

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

View File

@@ -56,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>
@@ -96,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>

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

View File

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

167
app/utils.py Normal file
View File

@@ -0,0 +1,167 @@
"""
Shared business-logic helpers.
Keep route handlers thin — calculation logic lives here.
"""
from app.db import query, execute
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 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,),
)
if not rows:
return {"current": 0, "best": 0}
days = [r["d"] for r in rows]
today = datetime.now(SYDNEY_TZ).date()
# 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}
# ---------------------------------------------------------------------------
# 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),
]
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
# ---------------------------------------------------------------------------
# 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",
}