diff --git a/app/__init__.py b/app/__init__.py index 0e51264..52aee80 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -33,7 +33,7 @@ def create_app(): # Set up the user_loader function @login_manager.user_loader def load_user(user_id): - return User.query.get(int(user_id)) # Query the User model by ID + return db.session.get(User, int(user_id)) # Register blueprints from app.routes.auth import auth diff --git a/app/models.py b/app/models.py index e787c3e..e948f8c 100644 --- a/app/models.py +++ b/app/models.py @@ -7,7 +7,7 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(255), nullable=False, unique=True) password_hash = db.Column(db.Text, nullable=False) - profile = db.relationship('Profile', backref='user', uselist=False) + profile = db.relationship('Profile', backref='user', uselist=False, lazy='joined') class Profile(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -19,8 +19,13 @@ class Profile(db.Model): diastolic_threshold = db.Column(db.Integer, default=90) dark_mode = db.Column(db.Boolean, default=False) timezone = db.Column(db.String(50), default='UTC') # e.g., 'Australia/Sydney' + updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now()) class Reading(db.Model): + __table_args__ = ( + db.Index('ix_reading_user_timestamp', 'user_id', 'timestamp'), + ) + id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) timestamp = db.Column(db.DateTime, nullable=False) diff --git a/app/routes/data.py b/app/routes/data.py index 4784e05..52f832d 100644 --- a/app/routes/data.py +++ b/app/routes/data.py @@ -41,31 +41,36 @@ def manage_data(): @login_required def export_data(): import io + from flask import Response - output = io.StringIO() - writer = csv.writer(output) + def generate_csv(): + """Stream CSV rows to avoid loading all readings into memory.""" + # Write header + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate']) + yield output.getvalue() + output.seek(0) + output.truncate(0) - # Write CSV header - writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate']) + # Stream readings in chunks using yield_per + readings = Reading.query.filter_by(user_id=current_user.id).order_by( + Reading.timestamp + ).yield_per(500) - # Write user readings to the CSV - readings = Reading.query.filter_by(user_id=current_user.id).all() - for reading in readings: - writer.writerow([ - reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'), - reading.systolic, - reading.diastolic, - reading.heart_rate, - ]) + for reading in readings: + writer.writerow([ + reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'), + reading.systolic, + reading.diastolic, + reading.heart_rate, + ]) + yield output.getvalue() + output.seek(0) + output.truncate(0) - # Convert text to bytes for `send_file` - output.seek(0) - response = io.BytesIO(output.getvalue().encode('utf-8')) - output.close() - - return send_file( - response, + return Response( + generate_csv(), mimetype='text/csv', - as_attachment=True, - download_name='readings.csv' + headers={'Content-Disposition': 'attachment; filename=readings.csv'} ) \ No newline at end of file diff --git a/app/routes/main.py b/app/routes/main.py index 6da64b8..faf1026 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -10,6 +10,9 @@ from datetime import date, datetime, timedelta main = Blueprint('main', __name__) +# Number of readings to show per page in list view +PAGE_SIZE = 50 + @main.route('/', methods=['GET']) def landing(): return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html') @@ -29,26 +32,42 @@ def dashboard(): start_date = request.form.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d')) end_date = request.form.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d')) - # Fetch filtered readings - readings = fetch_readings(current_user.id, start_date, end_date, user_tz) + # Pagination for list view + page = request.args.get('page', 1, type=int) - # Annotate readings with relative and localized timestamps - annotate_readings(readings, user_tz) + # Fetch paginated readings for the list view + paginated = fetch_readings_paginated(current_user.id, start_date, end_date, user_tz, page, PAGE_SIZE) - # Generate calendar view - week_view = generate_weekly_calendar(readings, datetime.now(user_tz), user_tz) - month_view = generate_monthly_calendar(readings, datetime.now(user_tz), user_tz) + # For calendar/graph/badges, fetch only current month + week readings (much smaller set) + 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) - # Calculate stats and badges - systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary(readings) - badges = calculate_progress_badges(readings) + # Annotate all readings with relative and localized timestamps + annotate_readings(paginated.items, user_tz) + annotate_readings(calendar_readings, user_tz) - # Prepare graph data - graph_data = prepare_graph_data(readings) + # Build shared lookup for calendar views (single pass) + readings_by_day = build_readings_by_day(calendar_readings, user_tz) + + # Generate calendar views from the shared lookup + week_view = generate_weekly_calendar(readings_by_day, now, user_tz) + month_view = generate_monthly_calendar(readings_by_day, now, user_tz) + + # Calculate weekly averages via SQL (much faster than Python) + systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary_sql(current_user.id) + + # Badges from the paginated readings (or full set if needed) + badges = calculate_progress_badges(paginated.items) + + # Prepare graph data from calendar readings (current month) + graph_data = prepare_graph_data(calendar_readings) return render_template( 'dashboard.html', - readings=readings, + readings=paginated.items, + pagination=paginated, profile=current_user.profile, badges=badges, systolic_avg=systolic_avg, @@ -58,7 +77,7 @@ def dashboard(): start_date=start_date, end_date=end_date, month=month_view, - week = week_view, + week=week_view, date=date, **graph_data ) @@ -75,22 +94,32 @@ def get_reading_date_range(user_id, user_tz): return first, last -def fetch_readings(user_id, start_date, end_date, user_tz): - """Retrieve readings filtered by date range.""" +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: - # Convert dates to the user's timezone 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() @@ -98,30 +127,40 @@ def annotate_readings(readings, user_tz): reading.relative_timestamp = humanize.naturaltime(now - reading.timestamp) reading.local_timestamp = utc.localize(reading.timestamp).astimezone(user_tz) -def calculate_weekly_summary(readings): - """Calculate averages for the past week.""" + +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) - weekly_readings = [r for r in readings if r.timestamp >= one_week_ago] - if weekly_readings: - return ( - round(sum(r.systolic for r in weekly_readings) / len(weekly_readings), 1), - round(sum(r.diastolic for r in weekly_readings) / len(weekly_readings), 1), - round(sum(r.heart_rate for r in weekly_readings) / len(weekly_readings), 1), - ) + 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, selected_date, local_tz): - """Generate a monthly calendar view.""" + +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) - readings_by_day = defaultdict(list) - for reading in readings: - local_date = reading.timestamp.astimezone(local_tz).date() - readings_by_day[local_date].append(reading) - return [ { 'day': current_date.day, @@ -132,23 +171,14 @@ def generate_monthly_calendar(readings, selected_date, local_tz): for current_date in (start_date + timedelta(days=i) for i in range((end_date - start_date).days + 1)) ] -def generate_weekly_calendar(readings, selected_date, local_tz): - """Generate a weekly calendar view.""" - # Get the start of the week (Monday) and the end of the week (Sunday) +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()) - end_of_week = start_of_week + timedelta(days=6) - # Group readings by day - readings_by_day = defaultdict(list) - for reading in readings: - local_date = reading.timestamp.astimezone(local_tz).date() - readings_by_day[local_date].append(reading) - - # Build the weekly calendar view return [ { - 'date': current_date.strftime('%a, %b %d') , + 'date': current_date.strftime('%a, %b %d'), 'is_today': current_date == today, 'readings': readings_by_day.get(current_date.date(), []), } @@ -167,22 +197,21 @@ def prepare_graph_data(readings): def calculate_progress_badges(readings): """Generate badges based on user activity and milestones.""" now = datetime.utcnow().date() - streak_count, badges = 1, [] + badges = [] if not readings: return badges - - sorted_readings = sorted(readings, key=lambda r: r.timestamp.date()) + + # Use reversed() instead of re-sorting — readings come in desc order from DB + sorted_readings = list(reversed(readings)) streak_count = 1 daily_streak = True - # Start with the first reading previous_date = sorted_readings[0].timestamp.date() for reading in sorted_readings[1:]: current_date = reading.timestamp.date() - # Check for consecutive daily streaks if (current_date - previous_date).days == 1: streak_count += 1 elif (current_date - previous_date).days > 1: @@ -190,7 +219,6 @@ def calculate_progress_badges(readings): previous_date = current_date - # Add streak badges if daily_streak and streak_count >= 1: badges.append(f"Current Streak: {streak_count} Days") if daily_streak and streak_count >= 7: diff --git a/app/routes/user.py b/app/routes/user.py index f2c374c..3189c7d 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -67,7 +67,9 @@ def profile_image(user_id): response.headers.set('Content-Type', 'image/jpeg') response.headers.set('Cache-Control', 'public, max-age=86400') # Cache for 1 day response.headers.set('ETag', str(hash(profile.profile_pic))) # Unique ETag for the image - response.headers.set('Last-Modified', http_date(datetime.utcnow().timestamp())) + # Use actual profile update time instead of utcnow() which defeats caching + last_modified = profile.updated_at or datetime.utcnow() + response.headers.set('Last-Modified', http_date(last_modified.timestamp())) return response else: diff --git a/app/static/css/tailwind.css b/app/static/css/tailwind.css index 6452d5a..05de110 100644 --- a/app/static/css/tailwind.css +++ b/app/static/css/tailwind.css @@ -657,6 +657,10 @@ video { margin-right: auto; } +.-mb-px { + margin-bottom: -1px; +} + .mb-2 { margin-bottom: 0.5rem; } @@ -681,10 +685,6 @@ video { margin-right: 0.25rem; } -.mr-2 { - margin-right: 0.5rem; -} - .mr-3 { margin-right: 0.75rem; } @@ -717,14 +717,6 @@ video { margin-top: 1.5rem; } -.-mb-px { - margin-bottom: -1px; -} - -.ml-0 { - margin-left: 0px; -} - .block { display: block; } @@ -750,10 +742,18 @@ video { height: 1.5rem; } +.h-10 { + height: 2.5rem; +} + .h-16 { height: 4rem; } +.h-36 { + height: 9rem; +} + .h-4 { height: 1rem; } @@ -774,14 +774,6 @@ video { height: 2rem; } -.h-10 { - height: 2.5rem; -} - -.h-36 { - height: 9rem; -} - .w-16 { width: 4rem; } @@ -862,10 +854,6 @@ video { flex-wrap: wrap; } -.items-start { - align-items: flex-start; -} - .items-center { align-items: center; } @@ -932,12 +920,6 @@ video { overflow: hidden; } -.truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .rounded { border-radius: 0.25rem; } @@ -979,21 +961,26 @@ video { border-color: rgb(37 99 235 / var(--tw-border-opacity, 1)); } +.border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); +} + .border-gray-300 { --tw-border-opacity: 1; border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); } -.border-white { - --tw-border-opacity: 1; - border-color: rgb(255 255 255 / var(--tw-border-opacity, 1)); -} - .border-green-50 { --tw-border-opacity: 1; border-color: rgb(240 253 244 / var(--tw-border-opacity, 1)); } +.border-white { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity, 1)); +} + .bg-blue-600 { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); @@ -1009,6 +996,11 @@ video { background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); +} + .bg-gray-300 { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); @@ -1105,6 +1097,11 @@ video { padding-right: 0.5rem; } +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + .px-4 { padding-left: 1rem; padding-right: 1rem; @@ -1120,6 +1117,11 @@ video { padding-right: 2rem; } +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + .py-16 { padding-top: 4rem; padding-bottom: 4rem; @@ -1145,28 +1147,14 @@ video { padding-bottom: 1rem; } -.px-0 { - padding-left: 0px; - padding-right: 0px; -} - -.py-1 { - padding-top: 0.25rem; - padding-bottom: 0.25rem; +.pl-1 { + padding-left: 0.25rem; } .pl-2 { padding-left: 0.5rem; } -.pt-6 { - padding-top: 1.5rem; -} - -.pl-1 { - padding-left: 0.25rem; -} - .pr-2 { padding-right: 0.5rem; } @@ -1175,8 +1163,8 @@ video { padding-top: 0.5rem; } -.text-left { - text-align: left; +.pt-6 { + padding-top: 1.5rem; } .text-center { @@ -1235,14 +1223,6 @@ video { font-weight: 600; } -.font-light { - font-weight: 300; -} - -.leading-none { - line-height: 1; -} - .text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity, 1)); @@ -1355,6 +1335,11 @@ video { background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); } +.hover\:bg-gray-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); +} + .hover\:bg-gray-400:hover { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); @@ -1405,10 +1390,6 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } -.hover\:underline:hover { - text-decoration-line: underline; -} - .hover\:no-underline:hover { text-decoration-line: none; } @@ -1461,10 +1442,6 @@ video { } @media (min-width: 640px) { - .sm\:ml-0\.5 { - margin-left: 0.125rem; - } - .sm\:block { display: block; } @@ -1476,18 +1453,9 @@ video { .sm\:h-40 { height: 10rem; } - - .sm\:px-0\.5 { - padding-left: 0.125rem; - padding-right: 0.125rem; - } } @media (min-width: 768px) { - .md\:ml-2 { - margin-left: 0.5rem; - } - .md\:block { display: block; } @@ -1496,8 +1464,8 @@ video { display: none; } - .md\:w-auto { - width: auto; + .md\:w-1\/3 { + width: 33.333333%; } .md\:grid-cols-2 { @@ -1515,18 +1483,9 @@ video { .md\:p-4 { padding: 1rem; } - - .md\:px-0\.5 { - padding-left: 0.125rem; - padding-right: 0.125rem; - } } @media (min-width: 1024px) { - .lg\:ml-2 { - margin-left: 0.5rem; - } - .lg\:block { display: block; } @@ -1551,11 +1510,6 @@ video { align-items: center; } - .lg\:px-0\.5 { - padding-left: 0.125rem; - padding-right: 0.125rem; - } - .lg\:pt-0 { padding-top: 0px; } @@ -1569,8 +1523,4 @@ video { .xl\:hidden { display: none; } - - .xl\:flex-row { - flex-direction: row; - } } \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 81bc0ca..9f5ab96 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,7 +1,6 @@ {% extends "_layout.html" %} {% block content %}