Redesign BP tracker with lightweight views and unobtrusive save confirmations
This commit is contained in:
+108
@@ -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)
|
||||
Reference in New Issue
Block a user