Initial commit
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
29
app/__init__.py
Normal 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
21
app/auth.py
Normal 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
9
app/config.py
Normal 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
64
app/db.py
Normal 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
0
app/routes/__init__.py
Normal file
95
app/routes/api.py
Normal file
95
app/routes/api.py
Normal 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
72
app/routes/auth.py
Normal 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
115
app/routes/checkin.py
Normal 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
71
app/routes/dashboard.py
Normal 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
56
app/routes/leaderboard.py
Normal 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
40
app/routes/profile.py
Normal 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
736
app/static/css/style.css
Normal 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
146
app/static/js/charts.js
Normal 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
70
app/templates/base.html
Normal 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>
|
||||||
64
app/templates/checkin.html
Normal file
64
app/templates/checkin.html
Normal 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 %}
|
||||||
172
app/templates/dashboard.html
Normal file
172
app/templates/dashboard.html
Normal 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 %}
|
||||||
103
app/templates/leaderboard.html
Normal file
103
app/templates/leaderboard.html
Normal 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
29
app/templates/login.html
Normal 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 %}
|
||||||
10
app/templates/partials/checkin_row.html
Normal file
10
app/templates/partials/checkin_row.html
Normal 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>
|
||||||
56
app/templates/profile.html
Normal file
56
app/templates/profile.html
Normal 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
71
app/templates/signup.html
Normal 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 %}
|
||||||
67
migrations/001_initial_schema.sql
Normal file
67
migrations/001_initial_schema.sql
Normal 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
106
migrations/runner.py
Normal 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
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
flask==3.1.0
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
werkzeug==3.1.3
|
||||||
Reference in New Issue
Block a user