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
+108
View File
@@ -0,0 +1,108 @@
"""Shared, timezone-aware ranges and bounded chart summaries."""
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from math import ceil
from flask_login import current_user
from pytz import timezone, utc
from sqlalchemy import func
from app.models import Reading
def user_timezone():
profile = current_user.profile
return timezone(profile.timezone if profile and profile.timezone else 'UTC')
@dataclass
class DateRange:
start: date
end: date
tz: object
@property
def start_utc(self):
return self.tz.localize(datetime.combine(self.start, time.min)).astimezone(utc).replace(tzinfo=None)
@property
def end_utc(self):
# Localize each midnight independently: a local day may be 23 or 25 hours.
return self.tz.localize(datetime.combine(self.end + timedelta(days=1), time.min)).astimezone(utc).replace(tzinfo=None)
@property
def params(self):
return {'start_date': self.start.isoformat(), 'end_date': self.end.isoformat()}
def parse_range(args, tz, default_days=7):
today = datetime.now(tz).date()
start, end = args.get('start_date'), args.get('end_date')
if start or end:
try:
selected = DateRange(date.fromisoformat(start), date.fromisoformat(end), tz)
except (TypeError, ValueError):
raise ValueError('Choose a valid start and end date.')
else:
try:
days = int(args.get('days', default_days))
except (TypeError, ValueError):
raise ValueError('Choose a 7, 30 or 90 day period.')
if days not in (7, 30, 90):
raise ValueError('Choose a 7, 30 or 90 day period.')
selected = DateRange(today - timedelta(days=days - 1), today, tz)
if selected.start > selected.end:
raise ValueError('Start date must be on or before end date.')
if selected.start.year < 1900 or selected.end.year > 2100:
raise ValueError('Choose dates between 1900 and 2100.')
return selected
def readings_query(user_id, selected=None):
query = Reading.query.filter_by(user_id=user_id)
if selected:
query = query.filter(Reading.timestamp >= selected.start_utc, Reading.timestamp < selected.end_utc)
return query
def summary(query):
row = query.with_entities(func.count(Reading.id), func.avg(Reading.systolic),
func.avg(Reading.diastolic), func.avg(Reading.heart_rate)).one()
return dict(count=row[0], systolic=round(row[1], 1) if row[0] else None,
diastolic=round(row[2], 1) if row[0] else None,
pulse=round(row[3], 1) if row[0] else None)
def annotate(readings, tz):
for reading in readings:
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(tz)
return readings
def chart_data(query, selected):
"""Stream raw values into <=180 buckets; never average bucket averages."""
width = max(1, ceil(((selected.end - selected.start).days + 1) / 180))
buckets = {}
rows = query.with_entities(Reading.timestamp, Reading.systolic, Reading.diastolic,
Reading.heart_rate).yield_per(500)
for ts, systolic, diastolic, pulse in rows:
day = utc.localize(ts).astimezone(selected.tz).date()
index = (day - selected.start).days // width
sums = buckets.setdefault(index, [0, 0, 0, 0])
for i, value in enumerate((systolic, diastolic, pulse, 1)):
sums[i] += value
points = []
span = (selected.end - selected.start).days
for index, (sys, dia, pulse, count) in sorted(buckets.items()):
day = selected.start + timedelta(days=index * width)
end = min(day + timedelta(days=width - 1), selected.end)
values = [round(v / count, 1) for v in (sys, dia, pulse)]
x = round(48 + (day - selected.start).days / span * 704, 1) if span else 400
points.append(dict(date=day.isoformat(), end=end.isoformat(), count=count,
systolic=values[0], diastolic=values[1], pulse=values[2], x=x))
paths = []
for key, label in [('systolic', 'Systolic'), ('diastolic', 'Diastolic'), ('pulse', 'Pulse')]:
coords = [(p['x'], round(244 - p[key] / 260 * 208, 1)) for p in points]
paths.append(dict(key=key, label=label, coordinates=coords,
path=' '.join(f'{"M" if i == 0 else "L"}{x},{y}' for i, (x, y) in enumerate(coords))))
return dict(points=points, paths=paths, bucket_days=width)