From ccdb3d8dc7aa382170a5150bdc1d15bd450df74b Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 22 Feb 2026 22:53:22 +1100 Subject: [PATCH] Initial commit --- .gitignore | 26 + app/__init__.py | 29 + app/auth.py | 21 + app/config.py | 9 + app/db.py | 64 +++ app/routes/__init__.py | 0 app/routes/api.py | 95 +++ app/routes/auth.py | 72 +++ app/routes/checkin.py | 115 ++++ app/routes/dashboard.py | 71 +++ app/routes/leaderboard.py | 56 ++ app/routes/profile.py | 40 ++ app/static/css/style.css | 736 ++++++++++++++++++++++++ app/static/js/charts.js | 146 +++++ app/templates/base.html | 70 +++ app/templates/checkin.html | 64 +++ app/templates/dashboard.html | 172 ++++++ app/templates/leaderboard.html | 103 ++++ app/templates/login.html | 29 + app/templates/partials/checkin_row.html | 10 + app/templates/profile.html | 56 ++ app/templates/signup.html | 71 +++ migrations/001_initial_schema.sql | 67 +++ migrations/runner.py | 106 ++++ requirements.txt | 4 + run.py | 6 + 26 files changed, 2238 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/api.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/checkin.py create mode 100644 app/routes/dashboard.py create mode 100644 app/routes/leaderboard.py create mode 100644 app/routes/profile.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/charts.js create mode 100644 app/templates/base.html create mode 100644 app/templates/checkin.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/leaderboard.html create mode 100644 app/templates/login.html create mode 100644 app/templates/partials/checkin_row.html create mode 100644 app/templates/profile.html create mode 100644 app/templates/signup.html create mode 100644 migrations/001_initial_schema.sql create mode 100644 migrations/runner.py create mode 100644 requirements.txt create mode 100644 run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c409da5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..9c2aa28 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..4a22259 --- /dev/null +++ b/app/auth.py @@ -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,)) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9dc2dfc --- /dev/null +++ b/app/config.py @@ -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") diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..848bc15 --- /dev/null +++ b/app/db.py @@ -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 diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..a4436e5 --- /dev/null +++ b/app/routes/api.py @@ -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/") +@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/") +@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}) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..6a93d92 --- /dev/null +++ b/app/routes/auth.py @@ -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")) diff --git a/app/routes/checkin.py b/app/routes/checkin.py new file mode 100644 index 0000000..0928f6a --- /dev/null +++ b/app/routes/checkin.py @@ -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/", 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")) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..291fd1a --- /dev/null +++ b/app/routes/dashboard.py @@ -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, + ) diff --git a/app/routes/leaderboard.py b/app/routes/leaderboard.py new file mode 100644 index 0000000..3577b14 --- /dev/null +++ b/app/routes/leaderboard.py @@ -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) diff --git a/app/routes/profile.py b/app/routes/profile.py new file mode 100644 index 0000000..44ddeb8 --- /dev/null +++ b/app/routes/profile.py @@ -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")) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..76b007d --- /dev/null +++ b/app/static/css/style.css @@ -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; + } +} diff --git a/app/static/js/charts.js b/app/static/js/charts.js new file mode 100644 index 0000000..777a1ed --- /dev/null +++ b/app/static/js/charts.js @@ -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`, + }, + }, + }, + }, + }); +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..50ab092 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,70 @@ + + + + + + {% block title %}WeightTracker{% endblock %} + + + + + + + + + + {% if session.get('user_id') %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/checkin.html b/app/templates/checkin.html new file mode 100644 index 0000000..af62307 --- /dev/null +++ b/app/templates/checkin.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Check-in โ€” WeightTracker{% endblock %} + +{% block content %} + + + +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+

๐Ÿ“‹ History

+ {{ checkins | length }} entries +
+ + {% if checkins %} +
+ + + + + + + + + + + + {% for c in checkins %} + {% include "partials/checkin_row.html" %} + {% endfor %} + +
DateWeightBMINotes
+
+ {% else %} +
+
+
โš–๏ธ
+

No check-ins yet

+

Enter your weight above to start tracking.

+
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..9316909 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,172 @@ +{% extends "base.html" %} +{% block title %}Dashboard โ€” WeightTracker{% endblock %} + +{% block content %} + + + +
+
+
Current Weight
+
{{ '%.1f' % (latest.weight_kg | float) if latest else 'โ€”' }} kg
+
+
+
Weight Change
+
+ {% if weight_change is not none %} + {{ '%+.1f' % weight_change }} kg + {% else %} + โ€” + {% endif %} +
+ {% if weight_change_pct is not none %} +
+ {{ '%+.1f' % weight_change_pct }}% from start +
+ {% endif %} +
+
+
Current BMI
+
{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else 'โ€”' }}
+
+
+
Check-ins
+
{{ stats.total_checkins if stats else 0 }}
+
+
+ + +
+
+
+

๐Ÿ“ˆ Weight Over Time

+
+
+ +
+
+
+
+

๐Ÿ“Š Weekly Change

+
+
+ +
+
+
+ +
+ +
+
+

๐Ÿ• Recent Check-ins

+ View All +
+ {% if recent_checkins %} +
+ {% for c in recent_checkins %} +
+
+
{{ '%.1f' % (c.weight_kg | float) }} kg
+
+ {% if c.bmi %}BMI {{ '%.1f' % (c.bmi | float) }} ยท {% endif %} + {{ c.checked_in_at.strftime('%d %b %Y, %H:%M') }} +
+
+
+ {% endfor %} +
+ {% else %} +
+
๐Ÿ“
+

No check-ins yet

+

Log your first weigh-in to start tracking!

+ Check In Now +
+ {% endif %} +
+ + +
+ {% if milestones %} +
+
+

๐Ÿ… Milestones

+
+
+ {% for m in milestones %} + + {% 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 %} + + {% endfor %} +
+
+ {% endif %} + +
+
+

๐Ÿ”” Activity Feed

+
+ {% if activity %} +
+ {% for a in activity %} +
+
{{ (a.display_name or a.username)[:1] | upper }}
+
+
{{ a.display_name or a.username }}
+
Logged {{ '%.1f' % (a.weight_kg | float) }} kg ยท {{ + a.checked_in_at.strftime('%d %b, %H:%M') }}
+
+
+ {% endfor %} +
+ {% else %} +
+
๐Ÿ“ข
+

No activity yet. Be the first to check in!

+
+ {% endif %} +
+
+
+ +{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/leaderboard.html b/app/templates/leaderboard.html new file mode 100644 index 0000000..1bb4098 --- /dev/null +++ b/app/templates/leaderboard.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block title %}Leaderboard โ€” WeightTracker{% endblock %} + +{% block content %} + + + +
+
+

๐Ÿ“Š % Weight Lost Comparison

+
+
+ +
+
+ + +
+ {% if ranked %} +
+ + + + + + + + + + + + + + + {% for u in ranked %} + + + + + + + + + + + {% endfor %} + +
RankNameStartCurrentLost (kg)Lost (%)Goal ProgressCheck-ins
+ + {{ loop.index }} + + {{ u.display_name or u.username }}{{ '%.1f' % (u.starting_weight_kg | float) if u.starting_weight_kg else 'โ€”' }}{{ '%.1f' % (u.current_weight | float) if u.current_weight else 'โ€”' }} + + {{ '%+.1f' % (-u.weight_lost) if u.weight_lost != 0 else '0.0' }} + + + + {{ '%.1f' % u.pct_lost }}% + + + {% if u.goal_progress is not none %} +
+
+
+
+ {{ '%.0f' % + u.goal_progress }}% +
+ {% else %} + โ€” + {% endif %} +
{{ u.total_checkins }}
+
+ {% else %} +
+
๐Ÿ†
+

No competitors yet

+

Start checking in to appear on the leaderboard!

+
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..b433df2 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Log In โ€” WeightTracker{% endblock %} + +{% block content %} +
+
+

โš–๏ธ WeightTracker

+

Welcome back. Let's check your progress.

+ +
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/partials/checkin_row.html b/app/templates/partials/checkin_row.html new file mode 100644 index 0000000..66af818 --- /dev/null +++ b/app/templates/partials/checkin_row.html @@ -0,0 +1,10 @@ + + {{ c.checked_in_at.strftime('%d %b %Y, %H:%M') }} + {{ '%.1f' % (c.weight_kg | float) }} kg + {{ '%.1f' % (c.bmi | float) if c.bmi else 'โ€”' }} + {{ c.notes or 'โ€”' }} + + + + \ No newline at end of file diff --git a/app/templates/profile.html b/app/templates/profile.html new file mode 100644 index 0000000..5c002e0 --- /dev/null +++ b/app/templates/profile.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Profile โ€” WeightTracker{% endblock %} + +{% block content %} + + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/signup.html b/app/templates/signup.html new file mode 100644 index 0000000..d41e562 --- /dev/null +++ b/app/templates/signup.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Sign Up โ€” WeightTracker{% endblock %} + +{% block content %} +
+
+

โš–๏ธ WeightTracker

+

Join the competition. Track your progress.

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..fcace97 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -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() +); diff --git a/migrations/runner.py b/migrations/runner.py new file mode 100644 index 0000000..0dbc39a --- /dev/null +++ b/migrations/runner.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d97274d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==3.1.0 +psycopg2-binary==2.9.10 +python-dotenv==1.0.1 +werkzeug==3.1.3 diff --git a/run.py b/run.py new file mode 100644 index 0000000..4fb72fc --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=5000)