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