Show daily streak on dashboard
This commit is contained in:
@@ -1,10 +1,51 @@
|
||||
from flask import Blueprint, render_template
|
||||
from app.auth import login_required, get_current_user
|
||||
from app.db import query, query_one
|
||||
from app import SYDNEY_TZ
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
def calculate_streak(user_id):
|
||||
"""Calculate current and best consecutive-day check-in streaks."""
|
||||
rows = query(
|
||||
"""SELECT DISTINCT (checked_in_at AT TIME ZONE 'UTC' AT TIME ZONE 'Australia/Sydney')::date AS d
|
||||
FROM checkins WHERE user_id = %s ORDER BY d DESC""",
|
||||
(user_id,),
|
||||
)
|
||||
if not rows:
|
||||
return {"current": 0, "best": 0}
|
||||
|
||||
days = [r["d"] for r in rows]
|
||||
today = datetime.now(SYDNEY_TZ).date()
|
||||
|
||||
# Current streak: must include today or yesterday to count
|
||||
current = 0
|
||||
expected = today
|
||||
if days[0] == today or days[0] == today - timedelta(days=1):
|
||||
expected = days[0]
|
||||
for d in days:
|
||||
if d == expected:
|
||||
current += 1
|
||||
expected -= timedelta(days=1)
|
||||
else:
|
||||
break
|
||||
|
||||
# Best streak
|
||||
best = 1
|
||||
run = 1
|
||||
for i in range(1, len(days)):
|
||||
if days[i] == days[i - 1] - timedelta(days=1):
|
||||
run += 1
|
||||
best = max(best, run)
|
||||
else:
|
||||
run = 1
|
||||
|
||||
best = max(best, current)
|
||||
return {"current": current, "best": best}
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
@@ -59,6 +100,9 @@ def index():
|
||||
(user["id"],),
|
||||
)
|
||||
|
||||
# Streak
|
||||
streak = calculate_streak(user["id"])
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
user=user,
|
||||
@@ -69,4 +113,6 @@ def index():
|
||||
recent_checkins=recent_checkins,
|
||||
activity=activity,
|
||||
milestones=milestones,
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
|
||||
@@ -294,6 +294,11 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card-streak::before {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -737,6 +742,42 @@ tr:hover td {
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.btn-icon-success:hover {
|
||||
color: var(--success) !important;
|
||||
background: var(--success-bg) !important;
|
||||
border-color: rgba(16, 185, 129, 0.2) !important;
|
||||
}
|
||||
|
||||
/* ========== INLINE EDIT ========== */
|
||||
.checkin-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
max-width: 120px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
|
||||
.editing-row td {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
/* ========== EMPTY STATE ========== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -933,20 +974,46 @@ tr:hover td {
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.65rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.grid-2,
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-inline {
|
||||
@@ -958,12 +1025,16 @@ tr:hover td {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.4rem;
|
||||
.page-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.4rem;
|
||||
.page-header h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
@@ -998,10 +1069,27 @@ tr:hover td {
|
||||
.filter-group-people {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,11 @@
|
||||
<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-card {{ 'stat-card-streak' if streak.current >= 3 }}">
|
||||
<div class="stat-label">Check-ins</div>
|
||||
<div class="stat-value">{{ stats.total_checkins if stats else 0 }}</div>
|
||||
<div class="stat-change" style="color: var(--warning);">🔥 {{ streak.current }}-day streak{% if streak.best >
|
||||
streak.current %} · Best: {{ streak.best }}{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user