Show daily streak on dashboard
This commit is contained in:
@@ -1,10 +1,51 @@
|
|||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
from app.auth import login_required, get_current_user
|
from app.auth import login_required, get_current_user
|
||||||
from app.db import query, query_one
|
from app.db import query, query_one
|
||||||
|
from app import SYDNEY_TZ
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
bp = Blueprint("dashboard", __name__)
|
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("/")
|
@bp.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
@@ -59,6 +100,9 @@ def index():
|
|||||||
(user["id"],),
|
(user["id"],),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Streak
|
||||||
|
streak = calculate_streak(user["id"])
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
user=user,
|
user=user,
|
||||||
@@ -69,4 +113,6 @@ def index():
|
|||||||
recent_checkins=recent_checkins,
|
recent_checkins=recent_checkins,
|
||||||
activity=activity,
|
activity=activity,
|
||||||
milestones=milestones,
|
milestones=milestones,
|
||||||
|
streak=streak,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-card-streak::before {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -737,6 +742,42 @@ tr:hover td {
|
|||||||
border-color: rgba(239, 68, 68, 0.2);
|
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 ========== */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -933,20 +974,46 @@ tr:hover td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
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 {
|
.charts-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-2,
|
.grid-2,
|
||||||
.grid-3 {
|
.grid-3 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-inline {
|
.form-inline {
|
||||||
@@ -958,12 +1025,16 @@ tr:hover td {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header h1 {
|
.page-header {
|
||||||
font-size: 1.4rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.page-header h1 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -998,10 +1069,27 @@ tr:hover td {
|
|||||||
.filter-group-people {
|
.filter-group-people {
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.stats-grid {
|
.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-label">Current BMI</div>
|
||||||
<div class="stat-value">{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else '—' }}</div>
|
<div class="stat-value">{{ '%.1f' % (latest.bmi | float) if latest and latest.bmi else '—' }}</div>
|
||||||
</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-label">Check-ins</div>
|
||||||
<div class="stat-value">{{ stats.total_checkins if stats else 0 }}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user