Redesign BP tracker with lightweight views and unobtrusive save confirmations

This commit is contained in:
Peter Stockings
2026-09-08 15:07:57 +10:00
parent 25d1774e53
commit 87b60aac9b
40 changed files with 2693 additions and 2553 deletions
+176 -347
View File
@@ -1,347 +1,176 @@
from collections import defaultdict
from flask import Blueprint, render_template, redirect, request, url_for
import humanize
from pytz import timezone, utc
from sqlalchemy import func
from app.models import Reading, db
from app.forms import DeleteForm
from flask_login import login_required, current_user
from datetime import date, datetime, timedelta
main = Blueprint('main', __name__)
# Number of readings to show per page in list view
PAGE_SIZE = 25
@main.route('/', methods=['GET'])
def landing():
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
@main.route('/health')
def health():
return "OK", 200
@main.route('/dashboard', methods=['GET', 'POST'])
@login_required
def dashboard():
"""Render the dashboard shell and default list view."""
user_tz = timezone(current_user.profile.timezone or 'UTC')
# Calculate weekly averages via SQL
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary_sql(current_user.id)
badges = calculate_progress_badges(current_user.id, user_tz)
return render_template(
'dashboard.html',
profile=current_user.profile,
badges=badges,
systolic_avg=systolic_avg,
diastolic_avg=diastolic_avg,
heart_rate_avg=heart_rate_avg,
delete_form=DeleteForm(),
active_view='list',
)
@main.route('/dashboard/list', methods=['GET'])
@login_required
def dashboard_list():
user_tz = timezone(current_user.profile.timezone or 'UTC')
page = request.args.get('page', 1, type=int)
# List view is no longer constrained by date filter
paginated = fetch_readings_paginated(current_user.id, None, None, user_tz, page, PAGE_SIZE)
annotate_readings(paginated.items, user_tz)
return render_template('partials/dashboard_list.html', readings=paginated.items, pagination=paginated)
@main.route('/dashboard/table', methods=['GET'])
@login_required
def dashboard_table():
user_tz = timezone(current_user.profile.timezone or 'UTC')
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz)
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
end_date = request.args.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
page = request.args.get('page', 1, type=int)
paginated = fetch_readings_paginated(current_user.id, start_date, end_date, user_tz, page, PAGE_SIZE)
annotate_readings(paginated.items, user_tz)
return render_template('partials/dashboard_table.html', readings=paginated.items, pagination=paginated, start_date=start_date, end_date=end_date)
@main.route('/dashboard/weekly', methods=['GET'])
@login_required
def dashboard_weekly():
user_tz = timezone(current_user.profile.timezone or 'UTC')
week_offset = request.args.get('week_offset', 0, type=int)
now = datetime.now(user_tz)
target_week_date = now + timedelta(weeks=week_offset)
target_week_start = target_week_date - timedelta(days=target_week_date.weekday())
target_week_start_utc = target_week_start.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
target_week_end_utc = (target_week_start + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
calendar_readings = fetch_readings_for_range(current_user.id, target_week_start_utc, target_week_end_utc)
annotate_readings(calendar_readings, user_tz)
readings_by_day = build_readings_by_day(calendar_readings, user_tz)
week_view = generate_weekly_calendar(readings_by_day, target_week_date, user_tz)
return render_template('partials/dashboard_weekly.html', week=week_view, week_offset=week_offset)
@main.route('/dashboard/monthly', methods=['GET'])
@login_required
def dashboard_monthly():
user_tz = timezone(current_user.profile.timezone or 'UTC')
month_offset = request.args.get('month_offset', 0, type=int)
now = datetime.now(user_tz)
target_month_year = now.year + (now.month + month_offset - 1) // 12
target_month_month = (now.month + month_offset - 1) % 12 + 1
target_month_date = now.replace(year=target_month_year, month=target_month_month, day=1, hour=0, minute=0, second=0, microsecond=0)
first_day = target_month_date
start_date = first_day - timedelta(days=(first_day.weekday() + 1) % 7)
end_date = start_date + timedelta(days=42)
start_utc = start_date.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
end_utc = end_date.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
calendar_readings = fetch_readings_for_range(current_user.id, start_utc, end_utc)
annotate_readings(calendar_readings, user_tz)
readings_by_day = build_readings_by_day(calendar_readings, user_tz)
month_view = generate_monthly_calendar(readings_by_day, target_month_date, user_tz)
return render_template('partials/dashboard_monthly.html', month=month_view, target_month_date=target_month_date, month_offset=month_offset)
@main.route('/dashboard/graph', methods=['GET'])
@login_required
def dashboard_graph():
user_tz = timezone(current_user.profile.timezone or 'UTC')
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz)
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
end_date = request.args.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
if start_date and end_date:
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc)
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1)
calendar_readings = fetch_readings_for_range(current_user.id, start_dt, end_dt)
else:
now = datetime.now(user_tz)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_start_utc = month_start.astimezone(utc)
calendar_readings = fetch_readings_for_range(current_user.id, month_start_utc)
annotate_readings(calendar_readings, user_tz)
graph_data = prepare_graph_data(calendar_readings)
graph_data['start_date'] = start_date
graph_data['end_date'] = end_date
return render_template('partials/dashboard_graph.html', **graph_data)
def get_reading_date_range(user_id, user_tz):
"""Fetch the earliest and latest reading timestamps for a user."""
result = db.session.query(
func.min(Reading.timestamp).label('first'),
func.max(Reading.timestamp).label('last')
).filter(Reading.user_id == user_id).first()
first = utc.localize(result.first).astimezone(user_tz) if result.first else None
last = utc.localize(result.last).astimezone(user_tz) if result.last else None
return first, last
def fetch_readings_paginated(user_id, start_date, end_date, user_tz, page, per_page):
"""Retrieve paginated readings filtered by date range."""
query = Reading.query.filter_by(user_id=user_id)
if start_date and end_date:
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc)
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1)
query = query.filter(
Reading.timestamp >= start_dt,
Reading.timestamp <= end_dt
)
return query.order_by(Reading.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
def fetch_readings_for_range(user_id, start_utc, end_utc=None):
"""Fetch readings from a UTC start time onwards (for calendar/graph views)."""
query = Reading.query.filter(
Reading.user_id == user_id,
Reading.timestamp >= start_utc
)
if end_utc:
query = query.filter(Reading.timestamp <= end_utc)
return query.order_by(Reading.timestamp.desc()).all()
def annotate_readings(readings, user_tz):
"""Add relative and localized timestamps to readings."""
now = datetime.utcnow()
for reading in readings:
reading.relative_timestamp = humanize.naturaltime(now - reading.timestamp)
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(user_tz)
def build_readings_by_day(readings, user_tz):
"""Build a dict mapping dates to readings (single pass, shared by calendar views)."""
readings_by_day = defaultdict(list)
for reading in readings:
local_date = reading.local_timestamp.date() if hasattr(reading, 'local_timestamp') else utc.localize(reading.timestamp).astimezone(user_tz).date()
readings_by_day[local_date].append(reading)
return readings_by_day
def calculate_weekly_summary_sql(user_id):
"""Calculate weekly averages using SQL aggregation (single DB query)."""
one_week_ago = datetime.utcnow() - timedelta(days=7)
result = db.session.query(
func.round(func.avg(Reading.systolic), 1).label('sys_avg'),
func.round(func.avg(Reading.diastolic), 1).label('dia_avg'),
func.round(func.avg(Reading.heart_rate), 1).label('hr_avg'),
).filter(
Reading.user_id == user_id,
Reading.timestamp >= one_week_ago
).first()
if result and result.sys_avg is not None:
return float(result.sys_avg), float(result.dia_avg), float(result.hr_avg)
return 0, 0, 0
def generate_monthly_calendar(readings_by_day, selected_date, local_tz):
"""Generate a monthly calendar view from pre-built readings_by_day."""
today = datetime.now(local_tz).date()
first_day = selected_date.replace(day=1)
start_date = first_day - timedelta(days=(first_day.weekday() + 1) % 7)
end_date = start_date + timedelta(days=41)
return [
{
'day': current_date.day,
'is_today': current_date == today,
'is_in_current_month': current_date.month == selected_date.month,
'readings': readings_by_day.get(current_date.date(), []),
}
for current_date in (start_date + timedelta(days=i) for i in range((end_date - start_date).days + 1))
]
def generate_weekly_calendar(readings_by_day, selected_date, local_tz):
"""Generate a weekly calendar view from pre-built readings_by_day."""
today = datetime.now(local_tz).date()
start_of_week = selected_date - timedelta(days=selected_date.weekday())
return [
{
'date': current_date.strftime('%a, %b %d'),
'is_today': current_date == today,
'readings': readings_by_day.get(current_date.date(), []),
}
for current_date in (start_of_week + timedelta(days=i) for i in range(7))
]
def prepare_graph_data(readings):
"""Prepare data for graph rendering, reversing so chronological order is left-to-right."""
chronological_readings = list(reversed(readings))
n = len(chronological_readings)
time_percentages = []
systolic_vals = [r.systolic for r in chronological_readings]
diastolic_vals = [r.diastolic for r in chronological_readings]
hr_vals = [r.heart_rate for r in chronological_readings]
sys_avg = round(sum(systolic_vals) / n, 1) if n > 0 else 0
dia_avg = round(sum(diastolic_vals) / n, 1) if n > 0 else 0
hr_avg = round(sum(hr_vals) / n, 1) if n > 0 else 0
if n == 0:
pass
elif n == 1:
time_percentages = [0.5]
else:
first_time = chronological_readings[0].timestamp.timestamp()
last_time = chronological_readings[-1].timestamp.timestamp()
time_span = last_time - first_time
for r in chronological_readings:
if time_span == 0:
time_percentages.append(0.5)
else:
p = (r.timestamp.timestamp() - first_time) / time_span
time_percentages.append(p)
return {
'timestamps': [r.timestamp.strftime('%b %d\n%H:%M') for r in chronological_readings],
'systolic': systolic_vals,
'diastolic': diastolic_vals,
'heart_rate': hr_vals,
'time_percentages': time_percentages,
'sys_avg': sys_avg,
'dia_avg': dia_avg,
'hr_avg': hr_avg,
}
def calculate_progress_badges(user_id, user_tz):
"""Generate badges based on user activity and milestones using optimized queries."""
# Fetch only timestamps (index-only scan on the composite index)
timestamps = db.session.query(Reading.timestamp).filter(Reading.user_id == user_id).order_by(Reading.timestamp.desc()).all()
total_readings = len(timestamps)
return _compute_badges(total_readings, timestamps, user_tz)
def _compute_badges(total_readings, timestamps, user_tz, now_local=None):
if now_local is None:
now_local = datetime.now(user_tz).date()
badges = []
if total_readings == 0:
return badges
streak_count = 0
if timestamps:
distinct_dates = []
last_date = None
for (ts,) in timestamps:
local_date = utc.localize(ts).astimezone(user_tz).date()
if local_date != last_date:
distinct_dates.append(local_date)
last_date = local_date
if distinct_dates:
most_recent_date = distinct_dates[0]
if (now_local - most_recent_date).days <= 1:
streak_count = 1
current_check_date = most_recent_date
for d in distinct_dates[1:]:
if (current_check_date - d).days == 1:
streak_count += 1
current_check_date = d
else:
break
if streak_count >= 1:
badges.append(f"Current Streak: {streak_count} Days")
if streak_count >= 7:
badges.append("Logged Every Day for a Week")
if streak_count >= 30:
badges.append("Monthly Streak")
last_7_readings = timestamps[:7]
if len(last_7_readings) == 7:
if all(5 <= utc.localize(ts).astimezone(user_tz).hour < 12 for (ts,) in last_7_readings):
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
if all(18 <= utc.localize(ts).astimezone(user_tz).hour <= 23 for (ts,) in last_7_readings):
badges.append("Night Owl: Logged Readings Every Night for a Week")
milestones = [10, 50, 100, 500, 1000, 5000, 10000]
highest_milestone = max((m for m in milestones if total_readings >= m), default=None)
if highest_milestone:
badges.append(f"{highest_milestone} Readings Logged")
return badges
from datetime import date, datetime, timedelta
from flask import Blueprint, render_template, redirect, request, url_for
from flask_login import login_required, current_user
from pytz import utc
from app.models import Reading
from app.insights import DateRange, user_timezone, parse_range, readings_query, summary, annotate, chart_data
main = Blueprint('main', __name__)
PAGE_SIZE = 25
@main.route('/')
def landing():
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
@main.route('/health')
def health():
return 'OK'
@main.route('/dashboard')
@login_required
def dashboard():
view = request.args.get('view', 'overview')
if view not in ('overview', 'history', 'trends'):
view = 'overview'
tz = user_timezone()
mode = request.args.get('mode', 'readings') if view == 'history' else 'readings'
calendar = None
try:
selected = parse_range(request.args, tz, 7 if view == 'overview' else 30)
if mode == 'calendar':
unit = request.args.get('unit', 'month')
if unit not in ('week', 'month'):
raise ValueError('Choose a week or month calendar.')
anchor = date.fromisoformat(request.args.get('anchor', datetime.now(tz).date().isoformat()))
if not 1900 <= anchor.year <= 2099:
raise ValueError('Choose a calendar date between 1900 and 2099.')
if unit == 'week':
start = anchor - timedelta(days=anchor.weekday())
end = start + timedelta(days=6)
prev, following = start - timedelta(days=7), start + timedelta(days=7)
else:
start = anchor.replace(day=1)
following = (start + timedelta(days=32)).replace(day=1)
end = following - timedelta(days=1)
prev = (start - timedelta(days=1)).replace(day=1)
selected = DateRange(start, end, tz)
calendar = dict(unit=unit, anchor=anchor.isoformat(), prev=prev.isoformat(), next=following.isoformat())
except ValueError as error:
return render_template('error.html', message=str(error)), 400
query = readings_query(current_user.id, selected)
stats = summary(query)
pagination = None
readings = []
latest = None
chart = None
if view == 'overview':
latest = readings_query(current_user.id).order_by(Reading.timestamp.desc(), Reading.id.desc()).first()
if latest:
annotate([latest], tz)
readings = annotate(query.order_by(Reading.timestamp.desc(), Reading.id.desc()).limit(5).all(), tz)
elif mode != 'calendar':
if view == 'history':
page = max(1, request.args.get('page', 1, type=int))
pagination = query.order_by(Reading.timestamp.desc(), Reading.id.desc()).paginate(page=page, per_page=PAGE_SIZE, error_out=False)
readings = annotate(pagination.items, tz)
if view in ('overview', 'trends') or calendar:
chart = chart_data(query, selected)
if calendar:
by_day = {p['date']: p for p in chart['points']}
calendar['days'] = [dict(date=(selected.start + timedelta(days=i)), data=by_day.get((selected.start + timedelta(days=i)).isoformat()))
for i in range((selected.end - selected.start).days + 1)]
profile = current_user.profile
active_days = (selected.end - selected.start).days + 1 if selected.end == datetime.now(tz).date() else None
return render_template('dashboard.html', view=view, mode=mode, selected=selected, stats=stats,
readings=readings, latest=latest, chart=chart, pagination=pagination,
calendar=calendar, timezone_name=str(tz), active_days=active_days,
sys_threshold=(profile.systolic_threshold or 140) if profile else 140,
dia_threshold=(profile.diastolic_threshold or 90) if profile else 90)
@main.route('/dashboard/report')
@login_required
def report():
tz = user_timezone()
try:
selected = parse_range(request.args, tz, 30)
except ValueError as error:
return render_template('error.html', message=str(error)), 400
query = readings_query(current_user.id, selected)
readings = annotate(query.order_by(Reading.timestamp, Reading.id).all(), tz)
return render_template('report.html', selected=selected, stats=summary(query), readings=readings, timezone_name=str(tz))
# Preserve bookmarks to the former separate views.
@main.route('/dashboard/list')
@main.route('/dashboard/table')
@login_required
def dashboard_table():
params = request.args.to_dict()
params['view'] = 'history'
return redirect(url_for('main.dashboard', **params))
@main.route('/dashboard/graph')
@login_required
def dashboard_graph():
params = request.args.to_dict()
params['view'] = 'trends'
return redirect(url_for('main.dashboard', **params))
@main.route('/dashboard/weekly')
@main.route('/dashboard/monthly')
@login_required
def dashboard_calendar():
unit = 'week' if request.path.endswith('weekly') else 'month'
anchor = datetime.now(user_timezone()).date()
offset = request.args.get('week_offset' if unit == 'week' else 'month_offset', 0, type=int)
try:
if unit == 'week':
anchor += timedelta(weeks=offset)
else:
month_index = anchor.year * 12 + anchor.month - 1 + offset
anchor = date(month_index // 12, month_index % 12 + 1, 1)
except (OverflowError, ValueError):
return render_template('error.html', message='Choose a valid calendar date.'), 400
return redirect(url_for('main.dashboard', view='history', mode='calendar', unit=unit, anchor=anchor.isoformat()))
def _compute_badges(total_readings, timestamps, user_tz, now_local=None):
if now_local is None:
now_local = datetime.now(user_tz).date()
badges = []
if total_readings == 0:
return badges
streak_count = 0
if timestamps:
distinct_dates = []
last_date = None
for (ts,) in timestamps:
local_date = utc.localize(ts).astimezone(user_tz).date()
if local_date != last_date:
distinct_dates.append(local_date)
last_date = local_date
if distinct_dates:
most_recent_date = distinct_dates[0]
if (now_local - most_recent_date).days <= 1:
streak_count = 1
current_check_date = most_recent_date
for d in distinct_dates[1:]:
if (current_check_date - d).days == 1:
streak_count += 1
current_check_date = d
else:
break
if streak_count >= 1:
badges.append(f"Current Streak: {streak_count} Days")
if streak_count >= 7:
badges.append("Logged Every Day for a Week")
if streak_count >= 30:
badges.append("Monthly Streak")
last_7_readings = timestamps[:7]
if len(last_7_readings) == 7:
if all(5 <= utc.localize(ts).astimezone(user_tz).hour < 12 for (ts,) in last_7_readings):
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
if all(18 <= utc.localize(ts).astimezone(user_tz).hour <= 23 for (ts,) in last_7_readings):
badges.append("Night Owl: Logged Readings Every Night for a Week")
milestones = [10, 50, 100, 500, 1000, 5000, 10000]
highest_milestone = max((m for m in milestones if total_readings >= m), default=None)
if highest_milestone:
badges.append(f"{highest_milestone} Readings Logged")
return badges