diff --git a/app/routes/api.py b/app/routes/api.py index d415d4a..f8e83c3 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,7 +1,7 @@ -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, session from app import SYDNEY_TZ from app.auth import login_required -from app.db import query +from app.db import query, query_one from datetime import timezone bp = Blueprint("api", __name__, url_prefix="/api") @@ -11,6 +11,12 @@ bp = Blueprint("api", __name__, url_prefix="/api") @login_required def chart_data(user_id): """Return weight & BMI over time for Chart.js.""" + # Privacy guard: don't expose private user data to others + if user_id != session.get("user_id"): + target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,)) + if target and target["is_private"]: + return jsonify({"labels": [], "weights": [], "bmis": []}) + checkins = query( """SELECT weight_kg, bmi, checked_in_at FROM checkins WHERE user_id = %s @@ -43,6 +49,7 @@ def comparison(): (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 + AND u.is_private = FALSE ORDER BY u.display_name """) @@ -74,6 +81,12 @@ def comparison(): @login_required def weekly_change(user_id): """Return weekly weight changes for bar chart.""" + # Privacy guard: don't expose private user data to others + if user_id != session.get("user_id"): + target = query_one("SELECT is_private FROM users WHERE id = %s", (user_id,)) + if target and target["is_private"]: + return jsonify({"labels": [], "changes": []}) + checkins = query( """SELECT weight_kg, checked_in_at FROM checkins WHERE user_id = %s diff --git a/app/routes/auth.py b/app/routes/auth.py index 6a93d92..b22267b 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -16,6 +16,7 @@ def signup(): gender = request.form.get("gender") or None goal_weight_kg = request.form.get("goal_weight_kg") or None starting_weight_kg = request.form.get("starting_weight_kg") or None + is_private = request.form.get("is_private") == "on" # Validation if not username or not password: @@ -35,9 +36,9 @@ def signup(): # 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), + """INSERT INTO users (username, password_hash, display_name, height_cm, age, gender, goal_weight_kg, starting_weight_kg, is_private) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", + (username, password_hash, display_name or username, height_cm, age, gender, goal_weight_kg, starting_weight_kg, is_private), ) session["user_id"] = user["id"] diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 291fd1a..944c603 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -48,9 +48,10 @@ def index(): SELECT c.*, u.display_name, u.username FROM checkins c JOIN users u ON c.user_id = u.id + WHERE u.is_private = FALSE OR u.id = %s ORDER BY c.checked_in_at DESC LIMIT 10 - """) + """, (user["id"],)) # Milestones milestones = query( diff --git a/app/routes/leaderboard.py b/app/routes/leaderboard.py index 3577b14..2fce8bb 100644 --- a/app/routes/leaderboard.py +++ b/app/routes/leaderboard.py @@ -21,6 +21,7 @@ def index(): (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 + WHERE u.is_private = FALSE ORDER BY u.created_at """) diff --git a/app/routes/profile.py b/app/routes/profile.py index 44ddeb8..c00a493 100644 --- a/app/routes/profile.py +++ b/app/routes/profile.py @@ -22,14 +22,15 @@ def update(): gender = request.form.get("gender") or None goal_weight_kg = request.form.get("goal_weight_kg") or None starting_weight_kg = request.form.get("starting_weight_kg") or None + is_private = request.form.get("is_private") == "on" execute( """UPDATE users SET display_name = %s, height_cm = %s, age = %s, gender = %s, - goal_weight_kg = %s, starting_weight_kg = %s + goal_weight_kg = %s, starting_weight_kg = %s, is_private = %s WHERE id = %s""", (display_name or user["username"], height_cm, age, gender, - goal_weight_kg, starting_weight_kg, user["id"]), + goal_weight_kg, starting_weight_kg, is_private, user["id"]), ) if request.headers.get("HX-Request"): diff --git a/app/static/css/style.css b/app/static/css/style.css index 76b007d..9a73c20 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -43,7 +43,9 @@ --shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15); } -*, *::before, *::after { +*, +*::before, +*::after { margin: 0; padding: 0; box-sizing: border-box; @@ -88,7 +90,9 @@ body { font-size: 1.15rem; } -.nav-icon { font-size: 1.4rem; } +.nav-icon { + font-size: 1.4rem; +} .nav-links { display: flex; @@ -118,7 +122,9 @@ body { background: var(--accent-glow); } -.nav-link-icon { font-size: 1.1rem; } +.nav-link-icon { + font-size: 1.1rem; +} .nav-toggle { display: none; @@ -139,7 +145,10 @@ body { transition: all 0.2s; } -.nav-actions { display: flex; align-items: center; } +.nav-actions { + display: flex; + align-items: center; +} /* ========== CONTAINER ========== */ .container { @@ -149,7 +158,9 @@ body { } /* ========== FLASH MESSAGES ========== */ -.flash-messages { margin-bottom: 1rem; } +.flash-messages { + margin-bottom: 1rem; +} .flash { padding: 0.75rem 1rem; @@ -162,9 +173,23 @@ body { 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-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; @@ -175,7 +200,9 @@ body { opacity: 0.7; } -.flash-close:hover { opacity: 1; } +.flash-close:hover { + opacity: 1; +} /* ========== PAGE HEADERS ========== */ .page-header { @@ -263,7 +290,9 @@ body { transition: opacity 0.2s; } -.stat-card:hover::before { opacity: 1; } +.stat-card:hover::before { + opacity: 1; +} .stat-label { font-size: 0.75rem; @@ -287,8 +316,13 @@ body { margin-top: 0.25rem; } -.stat-change.positive { color: var(--success); } -.stat-change.negative { color: var(--danger); } +.stat-change.positive { + color: var(--success); +} + +.stat-change.negative { + color: var(--danger); +} /* ========== FORMS ========== */ .form-group { @@ -321,7 +355,9 @@ body { box-shadow: 0 0 0 3px var(--accent-glow); } -.form-input::placeholder { color: var(--text-muted); } +.form-input::placeholder { + color: var(--text-muted); +} select.form-input { appearance: none; @@ -343,7 +379,10 @@ select.form-input { align-items: flex-end; } -.form-inline .form-group { flex: 1; margin-bottom: 0; } +.form-inline .form-group { + flex: 1; + margin-bottom: 0; +} /* ========== BUTTONS ========== */ .btn { @@ -396,9 +435,19 @@ select.form-input { 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%; } +.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 { @@ -431,9 +480,13 @@ td { color: var(--text-secondary); } -tr:last-child td { border-bottom: none; } +tr:last-child td { + border-bottom: none; +} -tr:hover td { background: var(--bg-card-hover); } +tr:hover td { + background: var(--bg-card-hover); +} /* ========== LEADERBOARD ========== */ .rank-badge { @@ -447,10 +500,25 @@ tr:hover td { background: var(--bg-card-hover); } 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); } +.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%; @@ -476,7 +544,9 @@ tr:hover td { background: var(--bg-card-hover); } border-bottom: 1px solid var(--border); } -.activity-item:last-child { border-bottom: none; } +.activity-item:last-child { + border-bottom: none; +} .activity-avatar { width: 32px; @@ -492,7 +562,9 @@ tr:hover td { background: var(--bg-card-hover); } flex-shrink: 0; } -.activity-content { flex: 1; } +.activity-content { + flex: 1; +} .activity-name { font-weight: 600; @@ -594,7 +666,9 @@ tr:hover td { background: var(--bg-card-hover); } font-weight: 500; } -.auth-card .auth-footer a:hover { text-decoration: underline; } +.auth-card .auth-footer a:hover { + text-decoration: underline; +} /* ========== GRID LAYOUTS ========== */ .grid-2 { @@ -611,17 +685,34 @@ tr:hover td { background: var(--bg-card-hover); } /* ========== ANIMATIONS ========== */ @keyframes slideIn { - from { transform: translateY(-8px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } + from { + transform: translateY(-8px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + + to { + opacity: 1; + } } -.htmx-added { animation: slideIn 0.3s ease; } -.htmx-settling { animation: fadeIn 0.2s ease; } +.htmx-added { + animation: slideIn 0.3s ease; +} + +.htmx-settling { + animation: fadeIn 0.2s ease; +} /* ========== DELETE BUTTON ========== */ .btn-icon { @@ -671,6 +762,58 @@ tr:hover td { background: var(--bg-card-hover); } margin: 0 auto 1rem; } +/* ========== TOGGLE SWITCH ========== */ +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 24px; + cursor: pointer; + transition: all 0.3s ease; +} + +.toggle-slider::before { + content: ''; + position: absolute; + left: 3px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + background: var(--text-muted); + border-radius: 50%; + transition: all 0.3s ease; +} + +.toggle-switch input:checked+.toggle-slider { + background: var(--accent); + border-color: var(--accent); +} + +.toggle-switch input:checked+.toggle-slider::before { + left: 23px; + background: white; +} + +.toggle-switch input:focus+.toggle-slider { + box-shadow: 0 0 0 3px var(--accent-glow); +} + /* ========== RESPONSIVE ========== */ @media (max-width: 768px) { .navbar { @@ -689,13 +832,21 @@ tr:hover td { background: var(--bg-card-hover); } padding: 0.5rem; } - .nav-links.open { display: flex; } + .nav-links.open { + display: flex; + } - .nav-toggle { display: flex; } + .nav-toggle { + display: flex; + } - .nav-actions { display: none; } + .nav-actions { + display: none; + } - .container { padding: 1rem; } + .container { + padding: 1rem; + } .stats-grid { grid-template-columns: repeat(2, 1fr); @@ -705,7 +856,8 @@ tr:hover td { background: var(--bg-card-hover); } grid-template-columns: 1fr; } - .grid-2, .grid-3 { + .grid-2, + .grid-3 { grid-template-columns: 1fr; } @@ -714,23 +866,35 @@ tr:hover td { background: var(--bg-card-hover); } align-items: stretch; } - .form-inline .form-group { margin-bottom: 0.75rem; } + .form-inline .form-group { + margin-bottom: 0.75rem; + } - .page-header h1 { font-size: 1.4rem; } + .page-header h1 { + font-size: 1.4rem; + } - .stat-value { font-size: 1.4rem; } + .stat-value { + font-size: 1.4rem; + } - .auth-card { margin: 1rem; padding: 1.5rem; } + .auth-card { + margin: 1rem; + padding: 1.5rem; + } .table-wrap { font-size: 0.8rem; } - th, td { padding: 0.5rem 0.65rem; } + th, + td { + padding: 0.5rem 0.65rem; + } } @media (max-width: 480px) { .stats-grid { grid-template-columns: 1fr; } -} +} \ No newline at end of file diff --git a/app/templates/profile.html b/app/templates/profile.html index 5c002e0..5bbc214 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -50,6 +50,21 @@ +
+ +
+ diff --git a/app/templates/signup.html b/app/templates/signup.html index d41e562..1877aef 100644 --- a/app/templates/signup.html +++ b/app/templates/signup.html @@ -61,6 +61,22 @@ step="0.1"> +
+ +
+ diff --git a/migrations/002_add_private_flag.sql b/migrations/002_add_private_flag.sql new file mode 100644 index 0000000..3d868cc --- /dev/null +++ b/migrations/002_add_private_flag.sql @@ -0,0 +1,4 @@ +-- Migration 002: Add private account flag +-- Allows users to hide their check-ins from other users + +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT FALSE;