Initial commit

This commit is contained in:
Peter Stockings
2026-02-22 22:53:22 +11:00
commit ccdb3d8dc7
26 changed files with 2238 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
*.egg
# Virtual environments
venv/
.venv/
env/
# Environment variables
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
Thumbs.db
Desktop.ini
.DS_Store

29
app/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
from flask import Flask
from app.config import Config
from app.db import init_db, close_db
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
# Database lifecycle
init_db(app)
app.teardown_appcontext(close_db)
# Register blueprints
from app.routes.auth import bp as auth_bp
from app.routes.dashboard import bp as dashboard_bp
from app.routes.checkin import bp as checkin_bp
from app.routes.profile import bp as profile_bp
from app.routes.leaderboard import bp as leaderboard_bp
from app.routes.api import bp as api_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(checkin_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(leaderboard_bp)
app.register_blueprint(api_bp)
return app

21
app/auth.py Normal file
View File

@@ -0,0 +1,21 @@
from functools import wraps
from flask import session, redirect, url_for, request
from app.db import query_one
def login_required(f):
"""Decorator to require authentication."""
@wraps(f)
def decorated_function(*args, **kwargs):
if "user_id" not in session:
return redirect(url_for("auth.login", next=request.url))
return f(*args, **kwargs)
return decorated_function
def get_current_user():
"""Get the current logged-in user from the database."""
user_id = session.get("user_id")
if user_id is None:
return None
return query_one("SELECT * FROM users WHERE id = %s", (user_id,))

9
app/config.py Normal file
View File

@@ -0,0 +1,9 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
DATABASE_URL = os.environ.get("DATABASE_URL")
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me-in-prod")

64
app/db.py Normal file
View File

@@ -0,0 +1,64 @@
import psycopg2
import psycopg2.extras
from flask import g, current_app
def init_db(app):
"""Test the database connection on startup."""
try:
conn = psycopg2.connect(app.config["DATABASE_URL"])
conn.close()
print(" * Database connection OK")
except Exception as e:
print(f" * Database connection FAILED: {e}")
def get_db():
"""Get a database connection for the current request."""
if "db" not in g:
g.db = psycopg2.connect(
current_app.config["DATABASE_URL"],
cursor_factory=psycopg2.extras.RealDictCursor,
)
return g.db
def close_db(exception=None):
"""Close database connection at end of request."""
db = g.pop("db", None)
if db is not None:
db.close()
def query(sql, params=None):
"""Execute a SELECT query and return all rows as dicts."""
db = get_db()
with db.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
def query_one(sql, params=None):
"""Execute a SELECT query and return one row as a dict."""
db = get_db()
with db.cursor() as cur:
cur.execute(sql, params)
return cur.fetchone()
def execute(sql, params=None):
"""Execute an INSERT/UPDATE/DELETE and commit."""
db = get_db()
with db.cursor() as cur:
cur.execute(sql, params)
db.commit()
def execute_returning(sql, params=None):
"""Execute an INSERT/UPDATE/DELETE with RETURNING and commit."""
db = get_db()
with db.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
db.commit()
return row

0
app/routes/__init__.py Normal file
View File

95
app/routes/api.py Normal file
View File

@@ -0,0 +1,95 @@
from flask import Blueprint, jsonify
from app.auth import login_required
from app.db import query
bp = Blueprint("api", __name__, url_prefix="/api")
@bp.route("/chart-data/<int:user_id>")
@login_required
def chart_data(user_id):
"""Return weight & BMI over time for Chart.js."""
checkins = query(
"""SELECT weight_kg, bmi, checked_in_at
FROM checkins WHERE user_id = %s
ORDER BY checked_in_at ASC""",
(user_id,),
)
labels = [c["checked_in_at"].strftime("%d %b") for c in checkins]
weights = [float(c["weight_kg"]) for c in checkins]
bmis = [float(c["bmi"]) if c["bmi"] else None for c in checkins]
return jsonify({
"labels": labels,
"weights": weights,
"bmis": bmis,
})
@bp.route("/comparison")
@login_required
def comparison():
"""Return all-user comparison data for bar charts."""
users = query("""
SELECT
u.id,
u.display_name,
u.username,
u.starting_weight_kg,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at ASC LIMIT 1) as first_weight,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight
FROM users u
WHERE (SELECT COUNT(*) FROM checkins WHERE user_id = u.id) > 0
ORDER BY u.display_name
""")
names = []
pct_lost = []
kg_lost = []
for u in users:
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
current_w = float(u["current_weight"] or start_w)
if start_w > 0:
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"])
pct_lost.append(pct)
kg_lost.append(round(lost, 1))
return jsonify({
"names": names,
"pct_lost": pct_lost,
"kg_lost": kg_lost,
})
@bp.route("/weekly-change/<int:user_id>")
@login_required
def weekly_change(user_id):
"""Return weekly weight changes for bar chart."""
checkins = query(
"""SELECT weight_kg, checked_in_at
FROM checkins WHERE user_id = %s
ORDER BY checked_in_at ASC""",
(user_id,),
)
if len(checkins) < 2:
return jsonify({"labels": [], "changes": []})
labels = []
changes = []
for i in range(1, len(checkins)):
prev_w = float(checkins[i - 1]["weight_kg"])
curr_w = float(checkins[i]["weight_kg"])
change = round(curr_w - prev_w, 1)
label = checkins[i]["checked_in_at"].strftime("%d %b")
labels.append(label)
changes.append(change)
return jsonify({"labels": labels, "changes": changes})

72
app/routes/auth.py Normal file
View File

@@ -0,0 +1,72 @@
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
from werkzeug.security import generate_password_hash, check_password_hash
from app.db import query_one, execute_returning, execute
bp = Blueprint("auth", __name__)
@bp.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
display_name = request.form.get("display_name", "").strip()
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
# Validation
if not username or not password:
flash("Username and password are required.", "error")
return render_template("signup.html"), 400
if len(password) < 4:
flash("Password must be at least 4 characters.", "error")
return render_template("signup.html"), 400
# Check if username taken
existing = query_one("SELECT id FROM users WHERE username = %s", (username,))
if existing:
flash("Username already taken.", "error")
return render_template("signup.html"), 400
# Create user
password_hash = generate_password_hash(password)
user = execute_returning(
"""INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg)
VALUES (%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),
)
session["user_id"] = user["id"]
flash("Welcome! You're all signed up.", "success")
return redirect(url_for("dashboard.index"))
return render_template("signup.html")
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = query_one("SELECT * FROM users WHERE username = %s", (username,))
if not user or not check_password_hash(user["password_hash"], password):
flash("Invalid username or password.", "error")
return render_template("login.html"), 401
session["user_id"] = user["id"]
next_url = request.args.get("next", url_for("dashboard.index"))
return redirect(next_url)
return render_template("login.html")
@bp.route("/logout")
def logout():
session.clear()
flash("You've been logged out.", "info")
return redirect(url_for("auth.login"))

115
app/routes/checkin.py Normal file
View File

@@ -0,0 +1,115 @@
import math
from flask import Blueprint, render_template, request, redirect, url_for, flash
from app.auth import login_required, get_current_user
from app.db import query, query_one, execute, execute_returning
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"])
@login_required
def index():
user = get_current_user()
checkins = query(
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC",
(user["id"],),
)
return render_template("checkin.html", user=user, checkins=checkins)
@bp.route("/checkin", methods=["POST"])
@login_required
def create():
user = get_current_user()
weight_kg = request.form.get("weight_kg")
notes = request.form.get("notes", "").strip()
if not weight_kg:
flash("Weight is required.", "error")
return redirect(url_for("checkin.index"))
try:
weight_kg = float(weight_kg)
except ValueError:
flash("Invalid weight value.", "error")
return redirect(url_for("checkin.index"))
bmi = calculate_bmi(weight_kg, user.get("height_cm"))
checkin = execute_returning(
"""INSERT INTO checkins (user_id, weight_kg, bmi, notes)
VALUES (%s, %s, %s, %s) RETURNING *""",
(user["id"], weight_kg, bmi, notes or None),
)
# Check milestones
check_milestones(user["id"], user)
# If HTMX request, return just the new row
if request.headers.get("HX-Request"):
return render_template("partials/checkin_row.html", c=checkin, user=user)
flash("Check-in recorded!", "success")
return redirect(url_for("checkin.index"))
@bp.route("/checkin/<int:checkin_id>", methods=["DELETE"])
@login_required
def delete(checkin_id):
user = get_current_user()
execute(
"DELETE FROM checkins WHERE id = %s AND user_id = %s",
(checkin_id, user["id"]),
)
if request.headers.get("HX-Request"):
return ""
flash("Check-in deleted.", "info")
return redirect(url_for("checkin.index"))

71
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,71 @@
from flask import Blueprint, render_template
from app.auth import login_required, get_current_user
from app.db import query, query_one
bp = Blueprint("dashboard", __name__)
@bp.route("/")
@login_required
def index():
user = get_current_user()
# Get latest check-in
latest = query_one(
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 1",
(user["id"],),
)
# Get check-in count
stats = query_one(
"SELECT COUNT(*) as total_checkins FROM checkins WHERE user_id = %s",
(user["id"],),
)
# Calculate weight change
first_checkin = query_one(
"SELECT weight_kg FROM checkins WHERE user_id = %s ORDER BY checked_in_at ASC LIMIT 1",
(user["id"],),
)
weight_change = None
weight_change_pct = None
if latest and first_checkin:
start_w = float(first_checkin["weight_kg"])
current_w = float(latest["weight_kg"])
weight_change = round(current_w - start_w, 1)
if start_w > 0:
weight_change_pct = round((weight_change / start_w) * 100, 1)
# Recent check-ins (last 5)
recent_checkins = query(
"SELECT * FROM checkins WHERE user_id = %s ORDER BY checked_in_at DESC LIMIT 5",
(user["id"],),
)
# Activity feed (recent check-ins from all users)
activity = query("""
SELECT c.*, u.display_name, u.username
FROM checkins c
JOIN users u ON c.user_id = u.id
ORDER BY c.checked_in_at DESC
LIMIT 10
""")
# Milestones
milestones = query(
"SELECT * FROM milestones WHERE user_id = %s ORDER BY achieved_at DESC",
(user["id"],),
)
return render_template(
"dashboard.html",
user=user,
latest=latest,
stats=stats,
weight_change=weight_change,
weight_change_pct=weight_change_pct,
recent_checkins=recent_checkins,
activity=activity,
milestones=milestones,
)

56
app/routes/leaderboard.py Normal file
View File

@@ -0,0 +1,56 @@
from flask import Blueprint, render_template
from app.auth import login_required
from app.db import query
bp = Blueprint("leaderboard", __name__)
@bp.route("/leaderboard")
@login_required
def index():
# Get all users with their weight stats
users = query("""
SELECT
u.id,
u.display_name,
u.username,
u.starting_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,
(SELECT weight_kg FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as current_weight,
(SELECT COUNT(*) FROM checkins WHERE user_id = u.id) as total_checkins,
(SELECT checked_in_at FROM checkins WHERE user_id = u.id ORDER BY checked_in_at DESC LIMIT 1) as last_checkin
FROM users u
ORDER BY u.created_at
""")
# Calculate rankings
ranked = []
for u in users:
start_w = float(u["starting_weight_kg"] or u["first_weight"] or 0)
current_w = float(u["current_weight"] or start_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_progress = None
if goal and 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
ranked.append({
**u,
"weight_lost": round(weight_lost, 1),
"pct_lost": pct_lost,
"goal_progress": goal_progress,
})
# Sort by % lost (descending)
ranked.sort(key=lambda x: x["pct_lost"], reverse=True)
return render_template("leaderboard.html", ranked=ranked)

40
app/routes/profile.py Normal file
View File

@@ -0,0 +1,40 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from app.auth import login_required, get_current_user
from app.db import execute
bp = Blueprint("profile", __name__)
@bp.route("/profile", methods=["GET"])
@login_required
def index():
user = get_current_user()
return render_template("profile.html", user=user)
@bp.route("/profile", methods=["POST"])
@login_required
def update():
user = get_current_user()
display_name = request.form.get("display_name", "").strip()
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
execute(
"""UPDATE users
SET display_name = %s, height_cm = %s, age = %s, gender = %s,
goal_weight_kg = %s, starting_weight_kg = %s
WHERE id = %s""",
(display_name or user["username"], height_cm, age, gender,
goal_weight_kg, starting_weight_kg, user["id"]),
)
if request.headers.get("HX-Request"):
flash("Profile updated!", "success")
return render_template("profile.html", user=get_current_user())
flash("Profile updated!", "success")
return redirect(url_for("profile.index"))

736
app/static/css/style.css Normal file
View File

@@ -0,0 +1,736 @@
/* ========================================
WeightTracker — Dark Theme CSS
======================================== */
:root {
/* Colors */
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-card: #1a2233;
--bg-card-hover: #212d42;
--bg-input: #0f1624;
--border: #2a3548;
--border-focus: #3b82f6;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-glow: rgba(59, 130, 246, 0.15);
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.1);
--danger: #ef4444;
--danger-bg: rgba(239, 68, 68, 0.1);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--info: #06b6d4;
/* Gradients */
--gradient-accent: linear-gradient(135deg, #3b82f6, #8b5cf6);
--gradient-success: linear-gradient(135deg, #10b981, #06b6d4);
--gradient-card: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(139, 92, 246, 0.05));
/* Spacing */
--radius: 12px;
--radius-sm: 8px;
--radius-lg: 16px;
/* Shadows */
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15);
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ========== NAVBAR ========== */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 64px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
}
.nav-brand a {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: 1.15rem;
}
.nav-icon { font-size: 1.4rem; }
.nav-links {
display: flex;
gap: 0.25rem;
}
.nav-links a {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.9rem;
border-radius: var(--radius-sm);
text-decoration: none;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-links a:hover {
color: var(--text-primary);
background: var(--accent-glow);
}
.nav-links a.active {
color: var(--accent);
background: var(--accent-glow);
}
.nav-link-icon { font-size: 1.1rem; }
.nav-toggle {
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
}
.nav-toggle span {
display: block;
width: 22px;
height: 2px;
background: var(--text-secondary);
border-radius: 2px;
transition: all 0.2s;
}
.nav-actions { display: flex; align-items: center; }
/* ========== CONTAINER ========== */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
/* ========== FLASH MESSAGES ========== */
.flash-messages { margin-bottom: 1rem; }
.flash {
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
animation: slideIn 0.3s ease;
}
.flash-success { background: var(--success-bg); color: var(--success); border: 1px solid rgba(16, 185, 129, 0.2); }
.flash-error { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.2); }
.flash-info { background: rgba(6, 182, 212, 0.1); color: var(--info); border: 1px solid rgba(6, 182, 212, 0.2); }
.flash-close {
background: none;
border: none;
color: inherit;
font-size: 1.2rem;
cursor: pointer;
opacity: 0.7;
}
.flash-close:hover { opacity: 1; }
/* ========== PAGE HEADERS ========== */
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.02em;
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.page-header p {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* ========== CARDS ========== */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
transition: all 0.2s ease;
}
.card:hover {
border-color: rgba(59, 130, 246, 0.2);
box-shadow: var(--shadow-glow);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-header h2 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.card-header .badge {
font-size: 0.7rem;
padding: 0.2rem 0.6rem;
border-radius: 100px;
background: var(--accent-glow);
color: var(--accent);
font-weight: 600;
}
/* ========== STAT CARDS ========== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gradient-accent);
opacity: 0;
transition: opacity 0.2s;
}
.stat-card:hover::before { opacity: 1; }
.stat-label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.stat-change {
font-size: 0.8rem;
font-weight: 500;
margin-top: 0.25rem;
}
.stat-change.positive { color: var(--success); }
.stat-change.negative { color: var(--danger); }
/* ========== FORMS ========== */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
.form-input {
width: 100%;
padding: 0.65rem 0.9rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
transition: all 0.2s ease;
outline: none;
}
.form-input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.form-input::placeholder { color: var(--text-muted); }
select.form-input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2394a3b8' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.form-inline {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
.form-inline .form-group { flex: 1; margin-bottom: 0; }
/* ========== BUTTONS ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.65rem 1.25rem;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
white-space: nowrap;
}
.btn-primary {
background: var(--gradient-accent);
color: white;
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4);
}
.btn-success {
background: var(--gradient-success);
color: white;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover {
color: var(--text-primary);
border-color: var(--text-muted);
background: var(--bg-card);
}
.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.8rem; }
.btn-lg { padding: 0.85rem 1.75rem; font-size: 1rem; }
.btn-block { width: 100%; }
/* ========== TABLES ========== */
.table-wrap {
overflow-x: auto;
border-radius: var(--radius);
border: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-card-hover); }
/* ========== LEADERBOARD ========== */
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-weight: 700;
font-size: 0.8rem;
}
.rank-1 { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #1a1a1a; }
.rank-2 { background: linear-gradient(135deg, #cbd5e1, #94a3b8); color: #1a1a1a; }
.rank-3 { background: linear-gradient(135deg, #d97706, #b45309); color: white; }
.rank-other { background: var(--bg-secondary); color: var(--text-muted); }
.progress-bar-track {
width: 100%;
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 3px;
background: var(--gradient-accent);
transition: width 0.5s ease;
}
/* ========== ACTIVITY FEED ========== */
.activity-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.activity-item:last-child { border-bottom: none; }
.activity-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--gradient-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.activity-content { flex: 1; }
.activity-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.875rem;
}
.activity-detail {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
/* ========== MILESTONES ========== */
.milestones-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.milestone-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.7rem;
background: var(--accent-glow);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 100px;
font-size: 0.75rem;
font-weight: 500;
color: var(--accent);
}
.milestone-badge.gold {
background: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.2);
color: var(--warning);
}
/* ========== CHARTS ========== */
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.chart-container {
position: relative;
height: 280px;
}
/* ========== AUTH PAGES ========== */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 100px);
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow);
}
.auth-card h1 {
font-size: 1.75rem;
font-weight: 800;
text-align: center;
margin-bottom: 0.25rem;
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.auth-card .auth-subtitle {
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.auth-card .auth-footer {
text-align: center;
margin-top: 1.25rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.auth-card .auth-footer a {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.auth-card .auth-footer a:hover { text-decoration: underline; }
/* ========== GRID LAYOUTS ========== */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.grid-3 {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
}
/* ========== ANIMATIONS ========== */
@keyframes slideIn {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.htmx-added { animation: slideIn 0.3s ease; }
.htmx-settling { animation: fadeIn 0.2s ease; }
/* ========== DELETE BUTTON ========== */
.btn-icon {
width: 30px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
}
.btn-icon:hover {
color: var(--danger);
background: var(--danger-bg);
border-color: rgba(239, 68, 68, 0.2);
}
/* ========== EMPTY STATE ========== */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.1rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.empty-state p {
font-size: 0.85rem;
max-width: 300px;
margin: 0 auto 1rem;
}
/* ========== RESPONSIVE ========== */
@media (max-width: 768px) {
.navbar {
padding: 0 1rem;
}
.nav-links {
display: none;
position: absolute;
top: 64px;
left: 0;
right: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-direction: column;
padding: 0.5rem;
}
.nav-links.open { display: flex; }
.nav-toggle { display: flex; }
.nav-actions { display: none; }
.container { padding: 1rem; }
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.charts-grid {
grid-template-columns: 1fr;
}
.grid-2, .grid-3 {
grid-template-columns: 1fr;
}
.form-inline {
flex-direction: column;
align-items: stretch;
}
.form-inline .form-group { margin-bottom: 0.75rem; }
.page-header h1 { font-size: 1.4rem; }
.stat-value { font-size: 1.4rem; }
.auth-card { margin: 1rem; padding: 1.5rem; }
.table-wrap {
font-size: 0.8rem;
}
th, td { padding: 0.5rem 0.65rem; }
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}

146
app/static/js/charts.js Normal file
View File

@@ -0,0 +1,146 @@
/**
* Chart.js configurations for WeightTracker
* Uses a consistent dark theme with accent colors
*/
const chartDefaults = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#1a2233',
titleColor: '#f1f5f9',
bodyColor: '#94a3b8',
borderColor: '#2a3548',
borderWidth: 1,
cornerRadius: 8,
padding: 10,
displayColors: false,
},
},
scales: {
x: {
grid: { color: 'rgba(42, 53, 72, 0.5)', drawBorder: false },
ticks: { color: '#64748b', font: { size: 11, family: 'Inter' } },
},
y: {
grid: { color: 'rgba(42, 53, 72, 0.5)', drawBorder: false },
ticks: { color: '#64748b', font: { size: 11, family: 'Inter' } },
},
},
};
function createWeightChart(canvasId, labels, weights) {
const ctx = document.getElementById(canvasId);
if (!ctx) return;
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Weight (kg)',
data: weights,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
pointBackgroundColor: '#3b82f6',
pointBorderColor: '#1a2233',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
}],
},
options: {
...chartDefaults,
plugins: {
...chartDefaults.plugins,
tooltip: {
...chartDefaults.plugins.tooltip,
callbacks: {
label: ctx => `${ctx.parsed.y} kg`,
},
},
},
},
});
}
function createWeeklyChangeChart(canvasId, labels, changes) {
const ctx = document.getElementById(canvasId);
if (!ctx) return;
const colors = changes.map(c => c <= 0 ? '#10b981' : '#ef4444');
const bgColors = changes.map(c => c <= 0 ? 'rgba(16, 185, 129, 0.7)' : 'rgba(239, 68, 68, 0.7)');
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Change (kg)',
data: changes,
backgroundColor: bgColors,
borderColor: colors,
borderWidth: 1,
borderRadius: 4,
}],
},
options: {
...chartDefaults,
plugins: {
...chartDefaults.plugins,
tooltip: {
...chartDefaults.plugins.tooltip,
callbacks: {
label: ctx => `${ctx.parsed.y > 0 ? '+' : ''}${ctx.parsed.y} kg`,
},
},
},
},
});
}
function createComparisonChart(canvasId, names, pctLost) {
const ctx = document.getElementById(canvasId);
if (!ctx) return;
const hues = [210, 160, 270, 30, 340, 190];
const bgColors = pctLost.map((_, i) => `hsla(${hues[i % hues.length]}, 70%, 55%, 0.7)`);
const borderColors = pctLost.map((_, i) => `hsl(${hues[i % hues.length]}, 70%, 55%)`);
new Chart(ctx, {
type: 'bar',
data: {
labels: names,
datasets: [{
label: '% Lost',
data: pctLost,
backgroundColor: bgColors,
borderColor: borderColors,
borderWidth: 1,
borderRadius: 6,
}],
},
options: {
...chartDefaults,
indexAxis: names.length > 5 ? 'y' : 'x',
plugins: {
...chartDefaults.plugins,
tooltip: {
...chartDefaults.plugins.tooltip,
callbacks: {
label: ctx => `${ctx.parsed.y || ctx.parsed.x}% lost`,
},
},
},
},
});
}

70
app/templates/base.html Normal file
View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}WeightTracker{% endblock %}</title>
<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.gstatic.com" crossorigin>
<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') }}">
<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>
</head>
<body>
{% if session.get('user_id') %}
<nav class="navbar">
<div class="nav-brand">
<a href="{{ url_for('dashboard.index') }}">
<span class="nav-icon">⚖️</span>
<span class="nav-title">WeightTracker</span>
</a>
</div>
<div class="nav-links">
<a href="{{ url_for('dashboard.index') }}" class="{{ 'active' if request.endpoint == 'dashboard.index' }}">
<span class="nav-link-icon">📊</span>
<span>Dashboard</span>
</a>
<a href="{{ url_for('checkin.index') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('checkin') }}">
<span class="nav-link-icon">✏️</span>
<span>Check-in</span>
</a>
<a href="{{ url_for('leaderboard.index') }}" class="{{ 'active' if request.endpoint == 'leaderboard.index' }}">
<span class="nav-link-icon">🏆</span>
<span>Leaderboard</span>
</a>
<a href="{{ url_for('profile.index') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('profile') }}">
<span class="nav-link-icon">👤</span>
<span>Profile</span>
</a>
</div>
<div class="nav-actions">
<a href="{{ url_for('auth.logout') }}" class="btn btn-ghost btn-sm">Logout</a>
</div>
<button class="nav-toggle" onclick="document.querySelector('.nav-links').classList.toggle('open')">
<span></span><span></span><span></span>
</button>
</nav>
{% endif %}
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">
{{ message }}
<button class="flash-close" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Check-in — WeightTracker{% endblock %}
{% block content %}
<div class="page-header">
<h1>✏️ Check-in</h1>
<p>Record your weight. Keep it consistent!</p>
</div>
<!-- Check-in Form -->
<div class="card" style="margin-bottom: 1.5rem;">
<form hx-post="{{ url_for('checkin.create') }}" hx-target="#checkin-list" hx-swap="afterbegin"
hx-on::after-request="this.reset()">
<div class="form-inline">
<div class="form-group">
<label class="form-label" for="weight_kg">Weight (kg)</label>
<input class="form-input" type="number" id="weight_kg" name="weight_kg" step="0.1"
placeholder="e.g. 78.5" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="notes">Notes (optional)</label>
<input class="form-input" type="text" id="notes" name="notes" placeholder="How are you feeling?">
</div>
<button type="submit" class="btn btn-primary">Log Weight</button>
</div>
</form>
</div>
<!-- Check-in History -->
<div class="card">
<div class="card-header">
<h2>📋 History</h2>
<span class="badge">{{ checkins | length }} entries</span>
</div>
{% if checkins %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Weight</th>
<th>BMI</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="checkin-list">
{% for c in checkins %}
{% include "partials/checkin_row.html" %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div id="checkin-list"></div>
<div class="empty-state">
<div class="empty-state-icon">⚖️</div>
<h3>No check-ins yet</h3>
<p>Enter your weight above to start tracking.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,172 @@
{% extends "base.html" %}
{% block title %}Dashboard — WeightTracker{% endblock %}
{% block content %}
<div class="page-header">
<h1>Hey, {{ user.display_name or user.username }}! 👋</h1>
<p>Here's your progress at a glance.</p>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Current Weight</div>
<div class="stat-value">{{ '%.1f' % (latest.weight_kg | float) if latest else '—' }} <span
style="font-size: 0.9rem; font-weight: 400; color: var(--text-muted);">kg</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Weight Change</div>
<div class="stat-value">
{% if weight_change is not none %}
{{ '%+.1f' % weight_change }} <span
style="font-size: 0.9rem; font-weight: 400; color: var(--text-muted);">kg</span>
{% else %}
{% endif %}
</div>
{% if weight_change_pct is not none %}
<div class="stat-change {{ 'positive' if weight_change < 0 else 'negative' }}">
{{ '%+.1f' % weight_change_pct }}% from start
</div>
{% endif %}
</div>
<div class="stat-card">
<div class="stat-label">Current BMI</div>
<div class="stat-value">{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else '—' }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Check-ins</div>
<div class="stat-value">{{ stats.total_checkins if stats else 0 }}</div>
</div>
</div>
<!-- Charts -->
<div class="charts-grid">
<div class="card">
<div class="card-header">
<h2>📈 Weight Over Time</h2>
</div>
<div class="chart-container">
<canvas id="weightChart"></canvas>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>📊 Weekly Change</h2>
</div>
<div class="chart-container">
<canvas id="weeklyChart"></canvas>
</div>
</div>
</div>
<div class="grid-3">
<!-- Recent Check-ins -->
<div class="card">
<div class="card-header">
<h2>🕐 Recent Check-ins</h2>
<a href="{{ url_for('checkin.index') }}" class="btn btn-ghost btn-sm">View All</a>
</div>
{% if recent_checkins %}
<div>
{% for c in recent_checkins %}
<div class="activity-item">
<div style="flex: 1;">
<div style="font-weight: 600; font-size: 1.1rem;">{{ '%.1f' % (c.weight_kg | float) }} kg</div>
<div class="activity-detail">
{% if c.bmi %}BMI {{ '%.1f' % (c.bmi | float) }} · {% endif %}
{{ c.checked_in_at.strftime('%d %b %Y, %H:%M') }}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📝</div>
<h3>No check-ins yet</h3>
<p>Log your first weigh-in to start tracking!</p>
<a href="{{ url_for('checkin.index') }}" class="btn btn-primary btn-sm">Check In Now</a>
</div>
{% endif %}
</div>
<!-- Activity Feed & Milestones -->
<div>
{% if milestones %}
<div class="card" style="margin-bottom: 1rem;">
<div class="card-header">
<h2>🏅 Milestones</h2>
</div>
<div class="milestones-grid">
{% for m in milestones %}
<span class="milestone-badge {{ 'gold' if 'lost' in m.milestone_key else '' }}">
{% if m.milestone_key == 'first_checkin' %}✅ First Check-in
{% 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>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h2>🔔 Activity Feed</h2>
</div>
{% if activity %}
<div>
{% for a in activity %}
<div class="activity-item">
<div class="activity-avatar">{{ (a.display_name or a.username)[:1] | upper }}</div>
<div class="activity-content">
<div class="activity-name">{{ a.display_name or a.username }}</div>
<div class="activity-detail">Logged {{ '%.1f' % (a.weight_kg | float) }} kg · {{
a.checked_in_at.strftime('%d %b, %H:%M') }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📢</div>
<p>No activity yet. Be the first to check in!</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
fetch('/api/chart-data/{{ user.id }}')
.then(r => r.json())
.then(data => {
if (data.labels.length > 0) {
createWeightChart('weightChart', data.labels, data.weights);
}
});
fetch('/api/weekly-change/{{ user.id }}')
.then(r => r.json())
.then(data => {
if (data.labels.length > 0) {
createWeeklyChangeChart('weeklyChart', data.labels, data.changes);
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Leaderboard — WeightTracker{% endblock %}
{% block content %}
<div class="page-header">
<h1>🏆 Leaderboard</h1>
<p>Ranked by % body weight lost. May the best loser win!</p>
</div>
<!-- Comparison Chart -->
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<h2>📊 % Weight Lost Comparison</h2>
</div>
<div class="chart-container">
<canvas id="comparisonChart"></canvas>
</div>
</div>
<!-- Leaderboard Table -->
<div class="card">
{% if ranked %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Name</th>
<th>Start</th>
<th>Current</th>
<th>Lost (kg)</th>
<th>Lost (%)</th>
<th>Goal Progress</th>
<th>Check-ins</th>
</tr>
</thead>
<tbody>
{% for u in ranked %}
<tr>
<td>
<span
class="rank-badge {{ 'rank-1' if loop.index == 1 else 'rank-2' if loop.index == 2 else 'rank-3' if loop.index == 3 else 'rank-other' }}">
{{ loop.index }}
</span>
</td>
<td style="font-weight: 600; color: var(--text-primary);">{{ u.display_name or u.username }}</td>
<td>{{ '%.1f' % (u.starting_weight_kg | float) if u.starting_weight_kg else '—' }}</td>
<td>{{ '%.1f' % (u.current_weight | float) if u.current_weight else '—' }}</td>
<td>
<span
style="color: {{ 'var(--success)' if u.weight_lost > 0 else 'var(--danger)' if u.weight_lost < 0 else 'var(--text-muted)' }}; font-weight: 600;">
{{ '%+.1f' % (-u.weight_lost) if u.weight_lost != 0 else '0.0' }}
</span>
</td>
<td>
<span
style="color: {{ 'var(--success)' if u.pct_lost > 0 else 'var(--danger)' if u.pct_lost < 0 else 'var(--text-muted)' }}; font-weight: 700;">
{{ '%.1f' % u.pct_lost }}%
</span>
</td>
<td>
{% if u.goal_progress is not none %}
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div class="progress-bar-track" style="flex: 1;">
<div class="progress-bar-fill" style="width: {{ u.goal_progress }}%;"></div>
</div>
<span style="font-size: 0.75rem; color: var(--text-muted); white-space: nowrap;">{{ '%.0f' %
u.goal_progress }}%</span>
</div>
{% else %}
<span style="color: var(--text-muted);"></span>
{% endif %}
</td>
<td>{{ u.total_checkins }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">🏆</div>
<h3>No competitors yet</h3>
<p>Start checking in to appear on the leaderboard!</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
fetch('/api/comparison')
.then(r => r.json())
.then(data => {
if (data.names.length > 0) {
createComparisonChart('comparisonChart', data.names, data.pct_lost);
}
});
});
</script>
{% endblock %}

29
app/templates/login.html Normal file
View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Log In — WeightTracker{% endblock %}
{% block content %}
<div class="auth-page">
<div class="auth-card">
<h1>⚖️ WeightTracker</h1>
<p class="auth-subtitle">Welcome back. Let's check your progress.</p>
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input class="form-input" type="text" id="username" name="username" placeholder="Your username" required
autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input class="form-input" type="password" id="password" name="password" placeholder="Your password"
required>
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top: 0.5rem;">Log In</button>
</form>
<p class="auth-footer">Don't have an account? <a href="{{ url_for('auth.signup') }}">Sign up</a></p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,10 @@
<tr id="checkin-{{ c.id }}">
<td>{{ c.checked_in_at.strftime('%d %b %Y, %H:%M') }}</td>
<td style="font-weight: 600;">{{ '%.1f' % (c.weight_kg | float) }} kg</td>
<td>{{ '%.1f' % (c.bmi | float) if c.bmi else '—' }}</td>
<td>{{ c.notes or '—' }}</td>
<td>
<button class="btn-icon" hx-delete="/checkin/{{ c.id }}" hx-target="#checkin-{{ c.id }}" hx-swap="outerHTML"
hx-confirm="Delete this check-in?">🗑️</button>
</td>
</tr>

View File

@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Profile — WeightTracker{% endblock %}
{% block content %}
<div class="page-header">
<h1>👤 Profile</h1>
<p>Update your stats and goals.</p>
</div>
<div class="card" style="max-width: 600px;">
<form method="POST" action="{{ url_for('profile.update') }}">
<div class="form-group">
<label class="form-label" for="display_name">Display Name</label>
<input class="form-input" type="text" id="display_name" name="display_name"
value="{{ user.display_name or '' }}">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="height_cm">Height (cm)</label>
<input class="form-input" type="number" id="height_cm" name="height_cm"
value="{{ user.height_cm or '' }}" step="0.1">
</div>
<div class="form-group">
<label class="form-label" for="age">Age</label>
<input class="form-input" type="number" id="age" name="age" value="{{ user.age or '' }}">
</div>
</div>
<div class="form-group">
<label class="form-label" for="gender">Gender</label>
<select class="form-input" id="gender" name="gender">
<option value="">Select...</option>
<option value="male" {{ 'selected' if user.gender=='male' }}>Male</option>
<option value="female" {{ 'selected' if user.gender=='female' }}>Female</option>
<option value="other" {{ 'selected' if user.gender=='other' }}>Other</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="starting_weight_kg">Starting Weight (kg)</label>
<input class="form-input" type="number" id="starting_weight_kg" name="starting_weight_kg"
value="{{ user.starting_weight_kg or '' }}" step="0.1">
</div>
<div class="form-group">
<label class="form-label" for="goal_weight_kg">Goal Weight (kg)</label>
<input class="form-input" type="number" id="goal_weight_kg" name="goal_weight_kg"
value="{{ user.goal_weight_kg or '' }}" step="0.1">
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 0.5rem;">Save Changes</button>
</form>
</div>
{% endblock %}

71
app/templates/signup.html Normal file
View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Sign Up — WeightTracker{% endblock %}
{% block content %}
<div class="auth-page">
<div class="auth-card">
<h1>⚖️ WeightTracker</h1>
<p class="auth-subtitle">Join the competition. Track your progress.</p>
<form method="POST" action="{{ url_for('auth.signup') }}">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input class="form-input" type="text" id="username" name="username" placeholder="Pick a username"
required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input class="form-input" type="password" id="password" name="password"
placeholder="At least 4 characters" required minlength="4">
</div>
<div class="form-group">
<label class="form-label" for="display_name">Display Name</label>
<input class="form-input" type="text" id="display_name" name="display_name"
placeholder="How your friends will see you">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="height_cm">Height (cm)</label>
<input class="form-input" type="number" id="height_cm" name="height_cm" placeholder="175"
step="0.1">
</div>
<div class="form-group">
<label class="form-label" for="starting_weight_kg">Starting Weight (kg)</label>
<input class="form-input" type="number" id="starting_weight_kg" name="starting_weight_kg"
placeholder="80" step="0.1">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="age">Age</label>
<input class="form-input" type="number" id="age" name="age" placeholder="25">
</div>
<div class="form-group">
<label class="form-label" for="gender">Gender</label>
<select class="form-input" id="gender" name="gender">
<option value="">Select...</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label" for="goal_weight_kg">Goal Weight (kg)</label>
<input class="form-input" type="number" id="goal_weight_kg" name="goal_weight_kg" placeholder="70"
step="0.1">
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top: 0.5rem;">Create
Account</button>
</form>
<p class="auth-footer">Already have an account? <a href="{{ url_for('auth.login') }}">Log in</a></p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,67 @@
-- Migration 001: Initial Schema
-- Creates the core tables for the weight tracker app
-- Users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100),
height_cm DECIMAL(5,1),
age INTEGER,
gender VARCHAR(20),
goal_weight_kg DECIMAL(5,1),
starting_weight_kg DECIMAL(5,1),
created_at TIMESTAMP DEFAULT NOW()
);
-- Check-ins table
CREATE TABLE IF NOT EXISTS checkins (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
weight_kg DECIMAL(5,1) NOT NULL,
bmi DECIMAL(4,1),
notes TEXT,
checked_in_at TIMESTAMP DEFAULT NOW()
);
-- Milestones table
CREATE TABLE IF NOT EXISTS milestones (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
milestone_key VARCHAR(50) NOT NULL,
achieved_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, milestone_key)
);
-- Challenges table
CREATE TABLE IF NOT EXISTS challenges (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT,
target_type VARCHAR(30) NOT NULL,
target_value DECIMAL(8,2) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Challenge participants
CREATE TABLE IF NOT EXISTS challenge_participants (
id SERIAL PRIMARY KEY,
challenge_id INTEGER REFERENCES challenges(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP,
UNIQUE(challenge_id, user_id)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_checkins_user_date ON checkins(user_id, checked_in_at DESC);
CREATE INDEX IF NOT EXISTS idx_milestones_user ON milestones(user_id);
-- Migrations tracking table
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT NOW()
);

106
migrations/runner.py Normal file
View File

@@ -0,0 +1,106 @@
"""
Database Migration Runner
Reads SQL migration files from the migrations/ directory, checks which
have already been applied via the schema_migrations table, and runs
any unapplied migrations in order.
Usage:
python migrations/runner.py
"""
import os
import re
import sys
import psycopg2
from dotenv import load_dotenv
def get_migration_files(migrations_dir):
"""Find all SQL migration files and return sorted by version number."""
pattern = re.compile(r"^(\d+)_.+\.sql$")
files = []
for filename in os.listdir(migrations_dir):
match = pattern.match(filename)
if match:
version = int(match.group(1))
files.append((version, filename))
return sorted(files, key=lambda x: x[0])
def ensure_migrations_table(conn):
"""Create schema_migrations table if it doesn't exist."""
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT NOW()
)
""")
conn.commit()
def get_applied_versions(conn):
"""Get set of already-applied migration versions."""
with conn.cursor() as cur:
cur.execute("SELECT version FROM schema_migrations ORDER BY version")
return {row[0] for row in cur.fetchall()}
def run_migration(conn, version, filepath):
"""Run a single migration file inside a transaction."""
print(f" Applying migration {version}: {os.path.basename(filepath)}...")
with open(filepath, "r", encoding="utf-8") as f:
sql = f.read()
with conn.cursor() as cur:
cur.execute(sql)
cur.execute(
"INSERT INTO schema_migrations (version) VALUES (%s)",
(version,),
)
conn.commit()
print(f" ✓ Migration {version} applied successfully")
def main():
load_dotenv()
database_url = os.environ.get("DATABASE_URL")
if not database_url:
print("ERROR: DATABASE_URL not set in .env")
sys.exit(1)
# Determine migrations directory
migrations_dir = os.path.dirname(os.path.abspath(__file__))
print(f"Connecting to database...")
conn = psycopg2.connect(database_url)
try:
ensure_migrations_table(conn)
applied = get_applied_versions(conn)
migrations = get_migration_files(migrations_dir)
pending = [(v, f) for v, f in migrations if v not in applied]
if not pending:
print("All migrations are up to date.")
return
print(f"Found {len(pending)} pending migration(s):")
for version, filename in pending:
filepath = os.path.join(migrations_dir, filename)
run_migration(conn, version, filepath)
print("\nAll migrations applied successfully!")
except Exception as e:
conn.rollback()
print(f"\nERROR: Migration failed: {e}")
sys.exit(1)
finally:
conn.close()
if __name__ == "__main__":
main()

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
flask==3.1.0
psycopg2-binary==2.9.10
python-dotenv==1.0.1
werkzeug==3.1.3

6
run.py Normal file
View File

@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)