Initial commit

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

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

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}WeightTracker{% endblock %}</title>
<meta name="description" content="Track your weight loss competition with friends">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
</head>
<body>
{% if session.get('user_id') %}
<nav class="navbar">
<div class="nav-brand">
<a href="{{ url_for('dashboard.index') }}">
<span class="nav-icon">⚖️</span>
<span class="nav-title">WeightTracker</span>
</a>
</div>
<div class="nav-links">
<a href="{{ url_for('dashboard.index') }}" class="{{ 'active' if request.endpoint == 'dashboard.index' }}">
<span class="nav-link-icon">📊</span>
<span>Dashboard</span>
</a>
<a href="{{ url_for('checkin.index') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('checkin') }}">
<span class="nav-link-icon">✏️</span>
<span>Check-in</span>
</a>
<a href="{{ url_for('leaderboard.index') }}" class="{{ 'active' if request.endpoint == 'leaderboard.index' }}">
<span class="nav-link-icon">🏆</span>
<span>Leaderboard</span>
</a>
<a href="{{ url_for('profile.index') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('profile') }}">
<span class="nav-link-icon">👤</span>
<span>Profile</span>
</a>
</div>
<div class="nav-actions">
<a href="{{ url_for('auth.logout') }}" class="btn btn-ghost btn-sm">Logout</a>
</div>
<button class="nav-toggle" onclick="document.querySelector('.nav-links').classList.toggle('open')">
<span></span><span></span><span></span>
</button>
</nav>
{% endif %}
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">
{{ message }}
<button class="flash-close" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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