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
+2
View File
@@ -8,6 +8,8 @@ WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY ./app/templates ./app/templates COPY ./app/templates ./app/templates
COPY ./app/static/js ./app/static/js COPY ./app/static/js ./app/static/js
COPY ./app/static/css/input.css ./app/static/css/input.css
COPY ./scripts/check-assets.cjs ./scripts/check-assets.cjs
COPY tailwind.config.js ./ COPY tailwind.config.js ./
# Install Node.js dependencies # Install Node.js dependencies
+114
View File
@@ -0,0 +1,114 @@
# Lightweight UI redesign implementation plan
## Objective
Make recording and reviewing blood pressure clearer on mobile and desktop while keeping browser downloads small. Keep Flask/Jinja, build-time Tailwind, native controls, SVG charts, and a small amount of JavaScript.
This plan is based on repository inspection, not a live browser audit. Size budgets below are proposed acceptance criteria; actual compressed page transfers still need measuring.
## Current baseline
- Compiled CSS: 26,156 bytes uncompressed (26.2 kB).
- Minified navigation JavaScript: 1,384 bytes uncompressed (1.4 kB). Additional inline scripts in templates are not included in this figure.
- Flask compression and HTML minification are already configured; verify their production response headers and effects.
- Five dashboard views: list, table, weekly, monthly, graph. List/table pagination already limits pages to 25 readings.
- Graph defaults to the entire history and emits repeated SVG tooltip markup per reading. This can dominate page size for established accounts.
- Table view renders separate mobile and desktop copies of readings.
- Dashboard initially renders a spinner and then fetches the default content separately.
- CSV import/export, personal thresholds, timezone, and a stored dark-mode preference already exist.
- Table highlighting uses fixed thresholds, and the shared layout does not apply the saved dark-mode preference.
- Custom navigation replaces body HTML; inserted inline scripts do not execute. Interaction initialisation needs checking when entering pages from elsewhere.
## Proposed design
Use a quiet off-white background, white surfaces, dark slate text, and the existing teal accent. Establish consistent spacing, small corner radii, restrained borders, system fonts, and tabular numerals. Use colour sparingly and accompany status colours with text.
Navigation: Overview, History, Trends, with Data and Settings secondary. Preserve calendar access under History with a week/month switch. Keep Add reading prominent; on narrow screens use a compact sticky action area that does not obscure content or focused controls.
Overview layout:
1. Page heading and Add reading action.
2. Latest reading, with local date/time and clearly labelled units.
3. Selected-period average and reading count, using one shared range selector.
4. Compact blood pressure trend, with pulse available separately.
5. Five recent readings and a link to full history.
Default to the last 7 days on Overview and last 30 days on Trends. Offer 7/30/90-day and custom ranges. Summary numbers and charts must describe the same period. Empty states say No readings yet instead of showing zero averages.
History uses one responsive representation of the data, retaining 25-row pagination. Filters and view selection live in query parameters so refresh, sharing a URL within the authenticated account, and browser Back behave predictably.
## Functionality priorities
| Priority | Feature | Implementation / weight |
| --- | --- | --- |
| First release | Faster reading entry | Large labelled numeric fields, mobile numeric keyboard, local timestamp default, clear validation, Save and Save & add another. Never prefill previous measurement values. |
| First release | Consistent date filtering | Shared server-side range parsing and SQL aggregates; preserve range across relevant views and export. |
| First release | Finish existing preferences | Apply saved dark mode with CSS variables and saved thresholds consistently. Describe thresholds as personal settings; avoid inventing clinical categories. |
| First release | Better trend review | SVG chart, clear units/legend, local timestamps, accessible text summary, keyboard/touch access to details. |
| First release | Printable summary | Dedicated HTML report with print CSS, selected dates, averages, count and readings; browser Print/Save as PDF. Extend existing CSV export with date filtering. |
| Follow-up | Optional notes | Nullable, length-limited reading note; migration, form support, escaped display, and backward-compatible CSV handling. |
| Follow-up | Import preview | Server-rendered preview with row errors and possible duplicates before commit; explicit timezone handling. |
Keep reminders, offline sync, device integrations, and medication tracking out of the initial release; each requires a separate product and implementation decision.
## Download-size budgets
Use decimal kB (1,000 bytes). Measure response bodies after gzip or Brotli, and record raw sizes separately. These budgets cover browser transfers, not Python packages or the Docker image.
| Resource | Proposed maximum |
| --- | ---: |
| Shared CSS | 8 kB compressed |
| All first-load JavaScript, including inline code | 5 kB compressed |
| Default Overview HTML, including inline SVG | 20 kB compressed |
| Complete cold Overview load, including images/icons and any automatic requests | 40 kB compressed |
| Any interactive History/Trends response | 20 kB compressed |
Reports and explicit exports are measured separately because their size necessarily depends on the requested data. Large uploaded avatars must not silently defeat the normal-page budget; use a bounded thumbnail or initials.
Controls:
- Keep Tailwind as a build tool; add no runtime UI framework, chart library, font download, or icon pack.
- Consolidate template scripts into one cacheable minified file with handlers registered once.
- Render useful dashboard content in the initial response and progressively enhance ordinary links/forms.
- Use one responsive history DOM instead of sending mobile and desktop copies.
- Limit chart output to at most 180 time buckets per series; choose daily/weekly/monthly aggregation for the range and label it clearly. Calculate averages/counts from original readings, not bucket averages. Keep raw values in History/export and disclose aggregation.
- Avoid per-point tooltip panels; use one shared details region and compact SVG paths.
- Render calendar day counts/averages with a drill-down for raw readings so dense months remain bounded.
- Fingerprint static asset URLs and cache them; keep authenticated health-data responses out of shared caches.
- Add a reproducible size check using deterministic fixtures and fail the build when agreed budgets are exceeded.
## Delivery sequence
### 1. Baseline and design specification
Run locally against an isolated fixture database. Capture existing mobile/desktop screens and response sizes for empty, typical, and large histories. Check ancestor repository instructions before implementation. Create desktop and mobile mockups of Overview and Add reading, and settle tokens and component states. Verify production compression separately when deployment access is available.
Acceptance: measured size report and a reviewable visual direction. No production data is needed.
### 2. Shared shell and forms
Update `_layout.html`, Tailwind configuration, and shared Jinja form/navigation components. Apply theme variables, mobile navigation, accessible focus styles, and consistent buttons/errors across login, signup, settings, and reading forms. Consolidate JavaScript initialisation and verify navigation between pages.
Acceptance: responsive at 360/768/1440 px, usable by keyboard and at 200% zoom, with core navigation and form submission working without JavaScript.
### 3. Overview, History, and Trends
Refactor `app/routes/main.py` around shared range parsing and summary queries. Build the new Overview, merge list/table presentation, retain calendar access, and bound graph/calendar output. Preserve existing route access through appropriate redirects or compatible handlers. Ensure rapid filter changes cannot let a stale response overwrite the latest view.
Acceptance: correct date filtering, local timestamps, personal threshold display, empty states, pagination, Back/Forward, and bounded output with 10,000 fixture readings.
### 4. Useful additions
Ship Save & add another, filtered CSV export, and printable report. Treat notes/import preview as a separate follow-up so the initial redesign does not depend on a schema migration.
Acceptance: save/edit/delete round trips, validation errors preserve entered data, and exports/reports match the selected period and documented timezone.
### 5. Verification and release preparation
Run the existing tests plus targeted integration tests for changed behaviour: user isolation, date boundaries including daylight-saving transitions, summaries, export streaming/download navigation, form validation, and empty/large histories. Check chart operation with touch and keyboard, dark-mode contrast, print layout, and repeated navigation. Measure all size budgets against built assets and rendered responses.
Acceptance: a short before/after report containing screenshots, transferred kB, request counts, test results, and any remaining limitations. Deploy only within the separately authorised deployment scope.
## Recommended first implementation slice
Deliver phases 13 first: the shared visual system, fast Add reading, a useful Overview, and simplified History/Trends. Complete preferences as part of that work. Add filtered export and printing next, then decide whether notes and import preview are worth expanding the scope.
+58
View File
@@ -0,0 +1,58 @@
# UI redesign delivery notes
Implemented locally on 8 September 2026. No production database or deployment was used.
## What changed
- Overview, History and Trends replace the five separate dashboard tabs. Week/month calendars remain available in History, with daily summaries and links to the original readings.
- Shared teal/slate visual system, responsive layouts, system fonts, saved dark mode, consistent form controls, and personal threshold labels.
- Save confirmations now occupy an inline strip below navigation. They cannot overlay Add reading or Save & add another, and remain readable until dismissed or the next navigation.
- Add reading supports Save & add another, numeric mobile inputs, and clear validation. Invalid submissions retain the entered timestamp, and edits correctly save timestamp changes.
- Shared date filtering uses local calendar dates and exclusive next-midnight UTC boundaries, including daylight-saving changes. Ambiguous/skipped entry times show a validation message.
- SVG trends contain at most 180 buckets per series. Longer ranges use labelled multi-day averages; raw readings remain in History and exports. Pulse has its own expandable chart, and a native disclosure exposes chart data for touch/keyboard users.
- Filtered CSV export and a printable HTML summary are available for the selected period. Existing full-history export remains available. Exports retain UTC timestamps for re-imports.
- Import validates headers, measurement ranges and a 2 MB file limit before committing. Import and delete now validate their form CSRF tokens; export streaming retains its request context.
- Ordinary links/forms handle navigation, filtering and downloads. The dashboard arrives complete in one HTML response. Small optional JavaScript handles notification dismissal, account-menu dismissal and the print button.
- Shared assets use fingerprinted URLs. Authenticated page responses use private/no-store caching. No runtime framework, chart package, font or icon library was added.
## Size results
Measured using `scripts/check-pages.py` with deterministic, synthetic SQLite fixtures. Decimal kB; gzip-compressed response bodies, excluding HTTP headers/TLS overhead. A live production transfer capture has not been performed.
| Resource | Raw | Gzip |
| --- | ---: | ---: |
| Shared CSS | 21,686 B | 5,507 B |
| All JavaScript | 607 B | 315 B |
| Favicon | 276 B | 217 B |
| Typical Overview HTML | 10,643 B | 2,723 B |
| Typical History HTML (25 rows) | 20,222 B | about 2,449 B |
| Large Trends HTML (10,000 readings, 20202026 range) | 56,299 B | 8,866 B |
Typical cold Overview total: **8,762 B / 8.8 kB gzip**, across four resources and no automatic follow-up fetch. This is comfortably below the proposed 40 kB budget. Compression implementations can differ by a few bytes.
Before this work, shared CSS was 26,156 B raw and the navigation script was 1,384 B raw, plus inline template scripts. CSS is now about 17% smaller; the shared script is about 56% smaller and replaces the inline scripts too. No comparable pre-redesign full-page network capture was taken.
`npm run build` now fails if compressed CSS exceeds 8 kB or JavaScript exceeds 5 kB. Integration tests and the page measurement script enforce 20 kB page and 40 kB cold Overview limits.
## Verification
- `npm run build`: passed, including asset-size budgets. The existing Browserslist data-age warning remains; dependencies were not upgraded for this redesign.
- `venv/Scripts/python.exe -m pytest -q`: **42 passed**.
- `venv/Scripts/python.exe scripts/check-pages.py`: passed for empty, typical and 10,000-reading accounts.
- Integration coverage includes date validation, 23/25-hour local days, form validation and timestamp preservation, add/edit/delete, account isolation, CSRF, saved preferences, streaming export, import atomicity, legacy bookmarks, pagination, compression and caching.
- Browser checks used disposable data: Overview at 360/1440 px, calendar at 768 px, saved dark mode across navigation, native Back/Forward pagination, and keyboard activation of chart disclosures.
- Mobile Save & add another was exercised through the browser. The notification's computed position is static, and its bounding rectangle does not intersect the button. Measurement fields reset after a successful save.
- The printable HTML report is covered by the route/filter tests. Actual browser PDF pagination and 200% browser zoom still require a manual check; the available browser controls did not support zoom adjustment.
## Run the local preview
From the repository root:
```powershell
npm run build
venv/Scripts/python.exe scripts/preview.py
```
Open `http://127.0.0.1:5055`, then log in with **demo / preview-only**. The preview uses an in-memory SQLite database with generated readings, binds only to localhost, and loses its data when stopped. It never uses the configured production database.
No schema migration is needed. Optional reading notes, an import preview/duplicate-resolution workflow, and reminders remain follow-up scope, as set out in the implementation plan.
+22
View File
@@ -25,6 +25,28 @@ def create_app():
config_name = os.getenv('FLASK_CONFIG', 'DevelopmentConfig') # Default to DevelopmentConfig config_name = os.getenv('FLASK_CONFIG', 'DevelopmentConfig') # Default to DevelopmentConfig
app.config.from_object(f'app.config.{config_name}') app.config.from_object(f'app.config.{config_name}')
# Fingerprinted URLs allow long-lived static caching across releases.
import hashlib
from pathlib import Path
from flask import url_for
asset_versions = {}
for filename in ('css/tailwind.css', 'js/diy-turbo.min.js', 'images/favicon.svg'):
asset_versions[filename] = hashlib.sha256((Path(app.static_folder) / filename).read_bytes()).hexdigest()[:12]
@app.context_processor
def asset_helpers():
return {'asset_url': lambda filename: url_for('static', filename=filename, v=asset_versions.get(filename, ''))}
@app.after_request
def cache_policy(response):
from flask import request
from flask_login import current_user
if request.endpoint == 'static' and request.args.get('v') == asset_versions.get(request.view_args.get('filename')):
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
elif current_user.is_authenticated:
response.headers['Cache-Control'] = 'private, no-store'
return response
# Initialize extensions # Initialize extensions
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
+14 -3
View File
@@ -79,6 +79,20 @@ class ReadingForm(FlaskForm):
) )
submit = SubmitField('Save Reading') submit = SubmitField('Save Reading')
def validate_timestamp(self, field):
from flask_login import current_user
from pytz import timezone, AmbiguousTimeError, NonExistentTimeError
if not field.data:
return
if not 1900 <= field.data.year <= 2100:
raise ValidationError('Choose a date between 1900 and 2100.')
profile = current_user.profile
tz = timezone(profile.timezone if profile and profile.timezone else 'UTC')
try:
tz.localize(field.data.replace(tzinfo=None), is_dst=None)
except (AmbiguousTimeError, NonExistentTimeError):
raise ValidationError('This local time is ambiguous or skipped by daylight saving. Choose an unambiguous local time.')
class DeleteForm(FlaskForm): class DeleteForm(FlaskForm):
submit = SubmitField('Delete') submit = SubmitField('Delete')
@@ -97,6 +111,3 @@ class ProfileForm(FlaskForm):
) )
dark_mode = BooleanField('Enable Dark Mode') dark_mode = BooleanField('Enable Dark Mode')
submit = SubmitField('Save Settings') submit = SubmitField('Save Settings')
+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)
+28 -8
View File
@@ -1,25 +1,37 @@
import csv import csv
from io import StringIO from io import StringIO
from flask import Blueprint, render_template, redirect, request, send_file, url_for, flash from flask import Blueprint, render_template, redirect, request, url_for, flash, stream_with_context
from app.models import Reading, db from app.models import Reading, db
from flask_login import login_required, current_user from flask_login import login_required, current_user
from datetime import datetime from datetime import datetime
from app.forms import DeleteForm
from app.insights import parse_range, readings_query, user_timezone
data = Blueprint('data', __name__) data = Blueprint('data', __name__)
@data.route('/', methods=['GET', 'POST']) @data.route('/', methods=['GET', 'POST'])
@login_required @login_required
def manage_data(): def manage_data():
form = DeleteForm()
if request.method == 'POST': if request.method == 'POST':
if not form.validate_on_submit():
flash('Your form expired. Please try again.', 'danger')
return redirect(url_for('data.manage_data'))
# Handle CSV file upload # Handle CSV file upload
file = request.files.get('file') file = request.files.get('file')
if file and file.filename.endswith('.csv'): if file and file.filename.endswith('.csv'):
try: try:
csv_data = csv.reader(StringIO(file.read().decode('utf-8'))) payload = file.read(2_000_001)
next(csv_data) # Skip the header row if len(payload) > 2_000_000:
raise ValueError('CSV must be 2 MB or smaller.')
csv_data = csv.reader(StringIO(payload.decode('utf-8-sig')))
if next(csv_data, None) != ['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate']:
raise ValueError('Use the column headings from a BP Tracker export.')
readings_to_add = [] readings_to_add = []
for row in csv_data: for line, row in enumerate(csv_data, 2):
timestamp, systolic, diastolic, heart_rate = row timestamp, systolic, diastolic, heart_rate = row
if not (50 <= int(systolic) <= 250 and 30 <= int(diastolic) <= 150 and 30 <= int(heart_rate) <= 200):
raise ValueError(f'Check the measurement values on row {line}.')
readings_to_add.append(Reading( readings_to_add.append(Reading(
user_id=current_user.id, user_id=current_user.id,
timestamp=datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S'), timestamp=datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S'),
@@ -31,18 +43,26 @@ def manage_data():
db.session.commit() db.session.commit()
flash('Data imported successfully!', 'success') flash('Data imported successfully!', 'success')
except Exception as e: except Exception as e:
db.session.rollback()
flash(f'Error importing data: {str(e)}', 'danger') flash(f'Error importing data: {str(e)}', 'danger')
else: else:
flash('Please upload a valid CSV file.', 'danger') flash('Please upload a valid CSV file.', 'danger')
return redirect(url_for('data.manage_data')) return redirect(url_for('data.manage_data'))
return render_template('data.html') return render_template('data.html', form=form)
@data.route('/export', methods=['GET']) @data.route('/export', methods=['GET'])
@login_required @login_required
def export_data(): def export_data():
import io import io
from flask import Response from flask import Response
user_id = current_user.id
selected = None
if any(request.args.get(key) for key in ('start_date', 'end_date', 'days')):
try:
selected = parse_range(request.args, user_timezone(), 30)
except ValueError as error:
return render_template('error.html', message=str(error)), 400
def generate_csv(): def generate_csv():
"""Stream CSV rows to avoid loading all readings into memory.""" """Stream CSV rows to avoid loading all readings into memory."""
@@ -55,8 +75,8 @@ def export_data():
output.truncate(0) output.truncate(0)
# Stream readings in chunks using yield_per # Stream readings in chunks using yield_per
readings = Reading.query.filter_by(user_id=current_user.id).order_by( readings = readings_query(user_id, selected).order_by(
Reading.timestamp Reading.timestamp, Reading.id
).yield_per(500) ).yield_per(500)
for reading in readings: for reading in readings:
@@ -71,7 +91,7 @@ def export_data():
output.truncate(0) output.truncate(0)
return Response( return Response(
generate_csv(), stream_with_context(generate_csv()),
mimetype='text/csv', mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=readings.csv'} headers={'Content-Disposition': 'attachment; filename=readings.csv'}
) )
+100 -271
View File
@@ -1,297 +1,126 @@
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 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__) main = Blueprint('main', __name__)
# Number of readings to show per page in list view
PAGE_SIZE = 25 PAGE_SIZE = 25
@main.route('/', methods=['GET']) @main.route('/')
def landing(): def landing():
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html') return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
@main.route('/health') @main.route('/health')
def health(): def health():
return "OK", 200 return 'OK'
@main.route('/dashboard', methods=['GET', 'POST']) @main.route('/dashboard')
@login_required @login_required
def dashboard(): def dashboard():
"""Render the dashboard shell and default list view.""" view = request.args.get('view', 'overview')
user_tz = timezone(current_user.profile.timezone or 'UTC') 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)
# Calculate weekly averages via SQL @main.route('/dashboard/report')
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 @login_required
def dashboard_list(): def report():
user_tz = timezone(current_user.profile.timezone or 'UTC') tz = user_timezone()
page = request.args.get('page', 1, type=int) 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))
# List view is no longer constrained by date filter # Preserve bookmarks to the former separate views.
paginated = fetch_readings_paginated(current_user.id, None, None, user_tz, page, PAGE_SIZE) @main.route('/dashboard/list')
annotate_readings(paginated.items, user_tz) @main.route('/dashboard/table')
return render_template('partials/dashboard_list.html', readings=paginated.items, pagination=paginated)
@main.route('/dashboard/table', methods=['GET'])
@login_required @login_required
def dashboard_table(): def dashboard_table():
user_tz = timezone(current_user.profile.timezone or 'UTC') params = request.args.to_dict()
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz) params['view'] = 'history'
return redirect(url_for('main.dashboard', **params))
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d')) @main.route('/dashboard/graph')
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 @login_required
def dashboard_graph(): def dashboard_graph():
user_tz = timezone(current_user.profile.timezone or 'UTC') params = request.args.to_dict()
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz) params['view'] = 'trends'
return redirect(url_for('main.dashboard', **params))
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d')) @main.route('/dashboard/weekly')
end_date = request.args.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d')) @main.route('/dashboard/monthly')
@login_required
if start_date and end_date: def dashboard_calendar():
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc) unit = 'week' if request.path.endswith('weekly') else 'month'
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1) anchor = datetime.now(user_timezone()).date()
calendar_readings = fetch_readings_for_range(current_user.id, start_dt, end_dt) offset = request.args.get('week_offset' if unit == 'week' else 'month_offset', 0, type=int)
try:
if unit == 'week':
anchor += timedelta(weeks=offset)
else: else:
now = datetime.now(user_tz) month_index = anchor.year * 12 + anchor.month - 1 + offset
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) anchor = date(month_index // 12, month_index % 12 + 1, 1)
month_start_utc = month_start.astimezone(utc) except (OverflowError, ValueError):
calendar_readings = fetch_readings_for_range(current_user.id, month_start_utc) 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()))
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): def _compute_badges(total_readings, timestamps, user_tz, now_local=None):
if now_local is None: if now_local is None:
+10 -3
View File
@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, redirect, request, url_for, flash from flask import Blueprint, render_template, redirect, request, url_for, flash
from pytz import timezone, utc from pytz import timezone, utc
from app.models import Reading, db from app.models import Reading, db
from app.forms import ReadingForm from app.forms import ReadingForm, DeleteForm
from flask_login import login_required, current_user from flask_login import login_required, current_user
from datetime import datetime from datetime import datetime
@@ -41,8 +41,10 @@ def add_reading():
db.session.add(new_reading) db.session.add(new_reading)
db.session.commit() db.session.commit()
flash("Reading added successfully.", "success") flash("Reading added successfully.", "success")
return redirect(url_for('main.dashboard')) destination = 'reading.add_reading' if request.form.get('action') == 'another' else 'main.dashboard'
return redirect(url_for(destination))
if request.method == 'GET':
form.timestamp.data = localize_timestamp(datetime.utcnow(), user_tz) form.timestamp.data = localize_timestamp(datetime.utcnow(), user_tz)
return render_template('reading/add_reading.html', form=form) return render_template('reading/add_reading.html', form=form)
@@ -53,6 +55,7 @@ def edit_reading(reading_id):
user_tz = get_user_timezone() user_tz = get_user_timezone()
form = ReadingForm(obj=reading) form = ReadingForm(obj=reading)
if request.method == 'GET':
form.timestamp.data = localize_timestamp(reading.timestamp, user_tz) form.timestamp.data = localize_timestamp(reading.timestamp, user_tz)
if form.validate_on_submit(): if form.validate_on_submit():
@@ -67,12 +70,16 @@ def edit_reading(reading_id):
def confirm_delete(reading_id): def confirm_delete(reading_id):
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404() reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
return render_template('reading/confirm_delete.html', reading=reading) reading.local_timestamp = localize_timestamp(reading.timestamp, get_user_timezone())
return render_template('reading/confirm_delete.html', reading=reading, form=DeleteForm())
@reading.route('/<int:reading_id>/delete', methods=['POST']) @reading.route('/<int:reading_id>/delete', methods=['POST'])
@login_required @login_required
def delete_reading(reading_id): def delete_reading(reading_id):
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404() reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
if not DeleteForm().validate_on_submit():
flash('Please try deleting the reading again.', 'danger')
return redirect(url_for('reading.confirm_delete', reading_id=reading.id))
db.session.delete(reading) db.session.delete(reading)
db.session.commit() db.session.commit()
flash('Reading deleted successfully!', 'success') flash('Reading deleted successfully!', 'success')
+4
View File
@@ -7,11 +7,13 @@ from flask_login import login_required, current_user
import base64 import base64
from datetime import datetime from datetime import datetime
from PIL import Image from PIL import Image
from flask_login import login_required, current_user
user = Blueprint('user', __name__) user = Blueprint('user', __name__)
@user.route('/profile', methods=['GET', 'POST']) @user.route('/profile', methods=['GET', 'POST'])
@login_required @login_required
@login_required
def profile(): def profile():
profile = current_user.profile or Profile(user_id=current_user.id) profile = current_user.profile or Profile(user_id=current_user.id)
form = ProfileForm(obj=profile) form = ProfileForm(obj=profile)
@@ -54,6 +56,7 @@ def profile():
return render_template('profile.html', form=form, profile=profile) return render_template('profile.html', form=form, profile=profile)
@user.route('/profile/image/<int:user_id>') @user.route('/profile/image/<int:user_id>')
@login_required
def profile_image(user_id): def profile_image(user_id):
# Ensure the reading belongs to the logged-in user # Ensure the reading belongs to the logged-in user
if user_id != current_user.id: if user_id != current_user.id:
@@ -79,3 +82,4 @@ def profile_image(user_id):
response = make_response(default_image) response = make_response(default_image)
response.headers.set('Content-Type', 'image/svg+xml') response.headers.set('Content-Type', 'image/svg+xml')
return response
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+19 -64
View File
@@ -1,68 +1,23 @@
document.addEventListener('click', async (e) => { // Core flows use native links and forms. These enhancements are optional.
const link = e.target.closest('a'); document.addEventListener('click', (event) => {
if (!link) return; const close = event.target.closest('[data-dismiss]');
if (close) {
const url = link.getAttribute('href'); const notice = close.closest('.notice');
if (!url || url.startsWith('#') || url.startsWith('javascript:')) return; const next = document.querySelector('.add-action, .entry-form input:not([type=hidden]), #main');
notice.remove();
// Only intercept same-origin links next?.focus();
if (link.origin !== window.location.origin) return;
// Ignore links that open in a new tab, download, or modifier keys
if (link.target === '_blank' || link.hasAttribute('download')) return;
if (e.ctrlKey || e.shiftKey || e.metaKey || e.altKey) return;
// Optional: add a "data-turbo='false'" attribute check to disable it on specific links
if (link.getAttribute('data-turbo') === 'false') return;
e.preventDefault();
document.body.style.cursor = 'wait';
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.title = doc.title;
document.body.innerHTML = doc.body.innerHTML;
// Carry over classes on the body if they changed
document.body.className = doc.body.className;
document.body.style.cursor = 'default';
window.history.pushState({}, '', url);
window.scrollTo(0, 0);
// Dispatch a custom event so other scripts can re-initialize if necessary
document.dispatchEvent(new Event('diy-turbo:load'));
} catch (error) {
console.error('DIY Turbo navigation error:', error);
window.location.href = url; // fallback
} }
if (event.target.closest('[data-print]')) window.print();
document.querySelectorAll('.account-menu[open]').forEach((menu) => {
if (!menu.contains(event.target)) menu.open = false;
});
}); });
document.addEventListener('keydown', (event) => {
window.addEventListener('popstate', async () => { if (event.key === 'Escape') {
document.body.style.cursor = 'wait'; document.querySelectorAll('.account-menu[open]').forEach((menu) => {
try { menu.open = false;
const response = await fetch(window.location.href); menu.querySelector('summary').focus();
if (!response.ok) throw new Error('Fetch failed'); });
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.title = doc.title;
document.body.innerHTML = doc.body.innerHTML;
document.body.className = doc.body.className;
document.body.style.cursor = 'default';
document.dispatchEvent(new Event('diy-turbo:load'));
} catch (error) {
console.error('DIY Turbo popstate error:', error);
window.location.reload(); // fallback
} }
}); });
document.querySelectorAll('[data-print]').forEach((button) => { button.hidden = false; });
+1 -1
View File
@@ -1 +1 @@
document.addEventListener("click",async t=>{const e=t.target.closest("a");if(!e)return;const o=e.getAttribute("href");if(o&&!o.startsWith("#")&&!o.startsWith("javascript:")&&e.origin===window.location.origin&&!("_blank"===e.target||e.hasAttribute("download")||t.ctrlKey||t.shiftKey||t.metaKey||t.altKey||"false"===e.getAttribute("data-turbo"))){t.preventDefault(),document.body.style.cursor="wait";try{const t=await fetch(o);if(!t.ok)throw new Error("Fetch failed");const e=await t.text(),r=(new DOMParser).parseFromString(e,"text/html");document.title=r.title,document.body.innerHTML=r.body.innerHTML,document.body.className=r.body.className,document.body.style.cursor="default",window.history.pushState({},"",o),window.scrollTo(0,0),document.dispatchEvent(new Event("diy-turbo:load"))}catch(t){console.error("DIY Turbo navigation error:",t),window.location.href=o}}}),window.addEventListener("popstate",async()=>{document.body.style.cursor="wait";try{const t=await fetch(window.location.href);if(!t.ok)throw new Error("Fetch failed");const e=await t.text(),o=(new DOMParser).parseFromString(e,"text/html");document.title=o.title,document.body.innerHTML=o.body.innerHTML,document.body.className=o.body.className,document.body.style.cursor="default",document.dispatchEvent(new Event("diy-turbo:load"))}catch(t){console.error("DIY Turbo popstate error:",t),window.location.reload()}}); document.addEventListener("click",e=>{const t=e.target.closest("[data-dismiss]");if(t){const e=t.closest(".notice"),n=document.querySelector(".add-action, .entry-form input:not([type=hidden]), #main");e.remove(),n?.focus()}e.target.closest("[data-print]")&&window.print(),document.querySelectorAll(".account-menu[open]").forEach(t=>{t.contains(e.target)||(t.open=!1)})}),document.addEventListener("keydown",e=>{"Escape"===e.key&&document.querySelectorAll(".account-menu[open]").forEach(e=>{e.open=!1,e.querySelector("summary").focus()})}),document.querySelectorAll("[data-print]").forEach(e=>{e.hidden=!1});
+33 -281
View File
@@ -1,295 +1,47 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="{{ 'dark' if current_user.is_authenticated and current_user.profile and current_user.profile.dark_mode else 'light' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% block title %}BP Tracker{% endblock %}</title> <title>{% block title %}BP Tracker{% endblock %}</title>
<link rel="icon" type="image/svg+xml" sizes="any" href="{{ url_for('static', filename='images/favicon.svg') }}"> <link rel="icon" type="image/svg+xml" href="{{ asset_url('images/favicon.svg') }}">
<link href="/static/css/tailwind.css" rel="stylesheet"> <link rel="stylesheet" href="{{ asset_url('css/tailwind.css') }}">
<script src="{{ url_for('static', filename='js/diy-turbo.min.js') }}"></script> <script defer src="{{ asset_url('js/diy-turbo.min.js') }}"></script>
</head> </head>
<body>
<body class="bg-gray-50 text-gray-800 font-sans antialiased"> <a class="skip-link" href="#main">Skip to content</a>
<nav id="mobile-nav" <header class="site-header">
class="flex items-center justify-between flex-wrap p-6 fixed w-full z-10 top-0 transition-colors duration-300 shadow-md bg-primary-800"> <div class="shell header-inner">
<!--Logo etc--> <a class="brand" href="{{ url_for('main.landing') }}" aria-label="BP Tracker home"><span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none"><path d="M4 17h6l3-8 6 16 3-8h6" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>BP <span class="brand-light">Tracker</span></a>
<div class="flex items-center flex-shrink-0 text-white mr-6">
<a class="text-white no-underline hover:text-white hover:no-underline" href="/">
<span class="text-2xl pl-2"><i class="em em-grinning"></i> Blood Pressure</span>
</a>
</div>
<!--Toggle button (hidden on large screens)-->
<button id="mobile-menu-btn" type="button"
class="block lg:hidden px-2 text-gray-500 hover:text-white focus:outline-none focus:text-white transition">
<svg class="h-6 w-6 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path id="icon-close" class="hidden" fill-rule="evenodd" clip-rule="evenodd"
d="M18.278 16.864a1 1 0 0 1-1.414 1.414l-4.829-4.828-4.828 4.828a1 1 0 0 1-1.414-1.414l4.828-4.829-4.828-4.828a1 1 0 0 1 1.414-1.414l4.829 4.828 4.828-4.828a1 1 0 1 1 1.414 1.414l-4.828 4.829 4.828 4.828z" />
<path id="icon-menu" fill-rule="evenodd"
d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z" />
</svg>
</button>
<!--Menu-->
<div id="mobile-menu" class="w-full flex-grow lg:flex lg:items-center lg:w-auto hidden shadow-3xl">
<ul class="pt-6 lg:pt-0 list-reset lg:flex justify-end flex-1 items-center">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="mr-3"> <nav class="main-nav" aria-label="Main navigation">
<a class="inline-block py-2 px-4 no-underline {% for key, label in [('overview', 'Overview'), ('history', 'History'), ('trends', 'Trends')] %}
{% if request.path == url_for('main.dashboard') %} <a href="{{ url_for('main.dashboard', view=key, **(selected.params if selected is defined and not calendar else {})) }}" {% if view is defined and view == key %}aria-current="page"{% endif %}>{{ label }}</a>
text-white {% endfor %}
{% else %}
text-primary-200 hover:text-white font-medium transition-colors
{% endif %}" href="{{ url_for('main.dashboard') }}">Dashboard
</a>
</li>
<li class="mr-3">
<a class="inline-block py-2 px-4 no-underline
{% if request.path == url_for('data.manage_data') %}
text-white
{% else %}
text-primary-200 hover:text-white font-medium transition-colors
{% endif %}" href="{{ url_for('data.manage_data') }}">Data
</a>
</li>
<li class="mr-3">
<a class="flex items-center gap-2 text-primary-200 no-underline hover:text-white font-medium transition-colors py-2 px-4"
href="{{ url_for('user.profile') }}">
<img src="{{ url_for('user.profile_image', user_id=current_user.id) }}" alt="Profile Picture"
class="w-8 h-8 rounded-full border-2 border-white object-cover group-hover:scale-105 transition">
<span class=" text-sm font-medium group-hover:underline
{% if request.path == url_for('user.profile') %}
text-white
{% else %}
text-primary-200 hover:text-white font-medium transition-colors
{% endif %}">Profile</span>
</a>
</li>
<li class="mr-3">
<a class="inline-block py-2 px-4 no-underline
{% if request.path == url_for('auth.logout') %}
text-white
{% else %}
text-primary-200 hover:text-white font-medium transition-colors
{% endif %}" href="{{ url_for('auth.logout') }}">Logout
</a>
</li>
{% else %}
<li class="mr-3">
<a class="inline-block py-2 px-4 no-underline
{% if request.path == url_for('auth.login') %}
text-white
{% else %}
text-primary-200 hover:text-white font-medium transition-colors
{% endif %}" href="{{ url_for('auth.login') }}">Login
</a>
</li>
<li class="mr-3">
<a class="inline-block py-2 px-4 no-underline
{% if request.path == url_for('auth.signup') %}
text-white
{% else %}
text-primary-200 hover:text-white font-medium transition-colors
{% endif %}" href="{{ url_for('auth.signup') }}">Signup
</a>
</li>
{% endif %}
</ul>
</div>
</nav> </nav>
<details class="account-menu">
<summary aria-label="Account menu"><span class="avatar">{{ current_user.username[0]|upper }}</span><span class="account-label">Account</span><span aria-hidden="true"></span></summary>
<nav aria-label="Account"><a href="{{ url_for('data.manage_data') }}">Import & export</a><a href="{{ url_for('user.profile') }}">Settings</a><a href="{{ url_for('auth.logout') }}">Log out</a></nav>
</details>
{% else %}
<nav class="actions" aria-label="Account"><a href="{{ url_for('auth.login') }}">Log in</a><a class="button primary" href="{{ url_for('auth.signup') }}">Get started</a></nav>
{% endif %}
</div>
</header>
<main id="main" class="shell main-content" tabindex="-1">
{% with messages = get_flashed_messages(with_categories=True) %} {% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %} {% if messages %}<div class="notifications" aria-label="Notifications">
<div class="fixed top-24 right-4 z-50 space-y-4">
{% for category, message in messages %} {% for category, message in messages %}
<div <div class="notice {{ 'notice-error' if category == 'danger' else '' }}" role="{{ 'alert' if category == 'danger' else 'status' }}">
class="flash-message flex items-center justify-between p-4 rounded-xl shadow-xl text-white bg-{{ 'red' if category == 'danger' else 'primary' }}-500 min-w-[300px] transition-all duration-300"> <span><strong>{{ 'Please check' if category == 'danger' else 'Done' }}</strong> · {{ message }}</span>
<span class="font-medium">{{ message }}</span> <button type="button" class="notice-close" aria-label="Dismiss notification" data-dismiss>×</button>
<button
class="flash-close-btn text-2xl font-bold ml-4 hover:text-gray-200 transition-colors">&times;</button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>{% endif %}
{% endif %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %}
<!-- Main Content --> </main>
<main class="container mx-auto mt-24"> <footer class="shell site-footer"><span>BP Tracker</span><span>A little clarity, every day.</span></footer>
{% block content %}
<!-- Content goes here -->
{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-4 mt-10">
<div class="container mx-auto text-center">
<p>&copy; 2024 BP Tracker. All rights reserved.</p>
</div>
</footer>
<script>
// Use document for event delegation since body is replaced by diy-turbo
document.addEventListener('click', async (e) => {
// Flash messages close button
const flashCloseBtn = e.target.closest('.flash-close-btn');
if (flashCloseBtn) {
const el = flashCloseBtn.closest('.flash-message');
if (el) {
el.style.opacity = '0';
setTimeout(() => el.remove(), 300);
}
return;
}
// Micro-HTMX implementation
const htmxTrigger = e.target.closest('[hx-get]');
if (htmxTrigger) {
if (htmxTrigger.tagName === 'FORM') return; // Let submit handler deal with forms
e.preventDefault();
const url = htmxTrigger.getAttribute('hx-get');
const targetSelector = htmxTrigger.getAttribute('hx-target');
if (!url || !targetSelector) return;
const targetEl = document.querySelector(targetSelector);
if (!targetEl) return;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
targetEl.innerHTML = await response.text();
} catch (err) {
console.error('Micro-HTMX error:', err);
}
return;
}
// Mobile Menu Toggle Button
const menuBtn = e.target.closest('#mobile-menu-btn');
if (menuBtn) {
const menu = document.getElementById('mobile-menu');
const nav = document.getElementById('mobile-nav');
const iconMenu = document.getElementById('icon-menu');
const iconClose = document.getElementById('icon-close');
if (menu) {
const isHidden = menu.classList.contains('hidden');
if (!isHidden) {
menu.classList.add('hidden');
nav.classList.replace('bg-primary-900', 'bg-primary-800');
iconMenu.classList.remove('hidden');
iconClose.classList.add('hidden');
menuBtn.classList.remove('rotate-180', 'transform');
} else {
menu.classList.remove('hidden');
nav.classList.replace('bg-primary-800', 'bg-primary-900');
iconMenu.classList.add('hidden');
iconClose.classList.remove('hidden');
menuBtn.classList.add('rotate-180', 'transform');
}
}
return;
}
// Handle clicks outside of nav/menu to close mobile menu
const nav = document.getElementById('mobile-nav');
const menu = document.getElementById('mobile-menu');
if (nav && menu && !nav.contains(e.target) && !menu.classList.contains('hidden')) {
menu.classList.add('hidden');
nav.classList.replace('bg-primary-900', 'bg-primary-800');
const iconMenu = document.getElementById('icon-menu');
const iconClose = document.getElementById('icon-close');
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
if (iconMenu) iconMenu.classList.remove('hidden');
if (iconClose) iconClose.classList.add('hidden');
if (mobileMenuBtn) mobileMenuBtn.classList.remove('rotate-180', 'transform');
}
});
// Handle Escape key to close mobile menu
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const menu = document.getElementById('mobile-menu');
if (menu && !menu.classList.contains('hidden')) {
const nav = document.getElementById('mobile-nav');
menu.classList.add('hidden');
if (nav) nav.classList.replace('bg-primary-900', 'bg-primary-800');
const iconMenu = document.getElementById('icon-menu');
const iconClose = document.getElementById('icon-close');
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
if (iconMenu) iconMenu.classList.remove('hidden');
if (iconClose) iconClose.classList.add('hidden');
if (mobileMenuBtn) mobileMenuBtn.classList.remove('rotate-180', 'transform');
}
}
});
// Auto-submit forms when inputs change
document.addEventListener('change', (e) => {
const htmxForm = e.target.closest('form[hx-get], form[hx-post]');
if (htmxForm) {
htmxForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}
});
// Handle form submissions for Micro-HTMX
document.addEventListener('submit', async (e) => {
const htmxForm = e.target.closest('form[hx-get], form[hx-post]');
if (htmxForm) {
e.preventDefault();
const method = htmxForm.hasAttribute('hx-post') ? 'POST' : 'GET';
let url = htmxForm.getAttribute('hx-get') || htmxForm.getAttribute('hx-post');
const targetSelector = htmxForm.getAttribute('hx-target');
if (!url || !targetSelector) return;
const targetEl = document.querySelector(targetSelector);
if (!targetEl) return;
try {
let fetchOpts = { method };
if (method === 'GET') {
const formData = new FormData(htmxForm);
const params = new URLSearchParams(formData).toString();
if (params) {
url += (url.includes('?') ? '&' : '?') + params;
}
} else {
fetchOpts.body = new FormData(htmxForm);
}
const response = await fetch(url, fetchOpts);
if (!response.ok) throw new Error('Fetch failed');
targetEl.innerHTML = await response.text();
} catch (err) {
console.error('Micro-HTMX submit error:', err);
}
}
});
// Handle auto-loading elements
const handleAutoLoads = async () => {
const elements = document.querySelectorAll('[hx-trigger="load"][hx-get]');
for (const el of elements) {
const url = el.getAttribute('hx-get');
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Auto-fetch failed');
el.innerHTML = await response.text();
el.removeAttribute('hx-trigger'); // ensure it only loads once per page view
} catch (err) {
console.error('Micro-HTMX autoload error:', err);
}
}
};
document.addEventListener('DOMContentLoaded', handleAutoLoads);
document.addEventListener('diy-turbo:load', handleAutoLoads);
</script>
</body> </body>
</html> </html>
+4 -88
View File
@@ -1,88 +1,4 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% block content %} {% from 'components.html' import field %}
<div class="max-w-md mx-auto mt-12 mb-20"> {% block title %}Log in · BP Tracker{% endblock %}
<div class="bg-white p-10 rounded-3xl shadow-2xl border border-gray-100 relative overflow-hidden"> {% block content %}<div class="form-page auth-page"><div class="page-heading"><div><p class="eyebrow">A LITTLE CLARITY, EVERY DAY</p><h1>Welcome back</h1><p class="muted">Log in to see your readings.</p></div></div><form method="post" class="card entry-form" novalidate>{{ form.hidden_tag() }}{{ field(form.username, autocomplete='username') }}{{ field(form.password, autocomplete='current-password') }}{% if form.csrf_token is defined and form.csrf_token.errors %}<p class="field-error" role="alert">Your form expired. Please try again.</p>{% endif %}<button class="button primary full-width" type="submit">Log in</button><p class="muted small">New here? <a href="{{ url_for('auth.signup') }}">Create an account</a></p></form></div>{% endblock %}
<!-- Decorative background element -->
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-primary-400 to-primary-600"></div>
<h1 class="text-3xl font-extrabold text-center mb-2 text-gray-900">Welcome Back</h1>
<p class="text-center text-gray-500 mb-8">Please enter your details to sign in.</p>
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
{{ form.hidden_tag() }}
<!-- Username Field -->
<div class="mb-5">
{{ form.username.label(class="block text-sm font-semibold text-gray-700 mb-2") }}
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd" />
</svg>
</div>
{{ form.username(class="w-full pl-11 p-3 border border-gray-300 rounded-xl focus:outline-none
focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all shadow-sm bg-gray-50
focus:bg-white") }}
</div>
{% for error in form.username.errors %}
<p class="text-sm text-red-500 mt-2 font-medium flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
{{ error }}
</p>
{% endfor %}
</div>
<!-- Password Field -->
<div class="mb-5">
<div class="flex justify-between items-center mb-2">
{{ form.password.label(class="block text-sm font-semibold text-gray-700") }}
<a href="#"
class="text-sm text-primary-600 hover:text-primary-800 hover:underline font-medium">Forgot
Password?</a>
</div>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M3 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1zm7.707 3.293a1 1 0 010 1.414L9.414 9H17a1 1 0 110 2H9.414l1.293 1.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</div>
{{ form.password(class="w-full pl-11 p-3 border border-gray-300 rounded-xl focus:outline-none
focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all shadow-sm bg-gray-50
focus:bg-white") }}
</div>
{% for error in form.password.errors %}
<p class="text-sm text-red-500 mt-2 font-medium flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
{{ error }}
</p>
{% endfor %}
</div>
<!-- Submit Button -->
<div class="mt-8">
{{ form.submit(class="w-full bg-primary-600 hover:bg-primary-700 text-white font-bold py-3 rounded-xl
shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-0.5 cursor-pointer")
}}
</div>
<p class="text-center mt-6 text-gray-600">
Don't have an account? <a href="{{ url_for('auth.signup') }}"
class="text-primary-600 hover:text-primary-800 font-bold hover:underline">Sign up</a>
</p>
</form>
</div>
</div>
{% endblock %}
+4 -111
View File
@@ -1,111 +1,4 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% block content %} {% from 'components.html' import field %}
<div class="max-w-md mx-auto mt-12 mb-20"> {% block title %}Create account · BP Tracker{% endblock %}
<div class="bg-white p-10 rounded-3xl shadow-2xl border border-gray-100 relative overflow-hidden"> {% block content %}<div class="form-page auth-page"><div class="page-heading"><div><p class="eyebrow">START YOUR RECORD</p><h1>Create an account</h1><p class="muted">Keep your blood pressure readings together.</p></div></div><form method="post" class="card entry-form" novalidate>{{ form.hidden_tag() }}{{ field(form.username, autocomplete='username') }}{{ field(form.password, autocomplete='new-password') }}{{ field(form.confirm_password, autocomplete='new-password') }}{% if form.csrf_token is defined and form.csrf_token.errors %}<p class="field-error" role="alert">Your form expired. Please try again.</p>{% endif %}<button class="button primary full-width" type="submit">Create account</button><p class="muted small">Already tracking? <a href="{{ url_for('auth.login') }}">Log in</a></p></form></div>{% endblock %}
<!-- Decorative background element -->
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-primary-400 to-primary-600"></div>
<h1 class="text-3xl font-extrabold text-center mb-2 text-gray-900">Create Account</h1>
<p class="text-center text-gray-500 mb-8">Join us to start tracking your health.</p>
<form method="POST" action="{{ url_for('auth.signup') }}" novalidate>
{{ form.hidden_tag() }}
<!-- Username Field -->
<div class="mb-5">
{{ form.username.label(class="block text-sm font-semibold text-gray-700 mb-2") }}
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd" />
</svg>
</div>
{{ form.username(class="w-full pl-11 p-3 border border-gray-300 rounded-xl focus:outline-none
focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all shadow-sm bg-gray-50
focus:bg-white") }}
</div>
{% for error in form.username.errors %}
<p class="text-sm text-red-500 mt-2 font-medium flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
{{ error }}
</p>
{% endfor %}
</div>
<!-- Password Field -->
<div class="mb-5">
{{ form.password.label(class="block text-sm font-semibold text-gray-700 mb-2") }}
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M3 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1zm7.707 3.293a1 1 0 010 1.414L9.414 9H17a1 1 0 110 2H9.414l1.293 1.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</div>
{{ form.password(class="w-full pl-11 p-3 border border-gray-300 rounded-xl focus:outline-none
focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all shadow-sm bg-gray-50
focus:bg-white") }}
</div>
{% for error in form.password.errors %}
<p class="text-sm text-red-500 mt-2 font-medium flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
{{ error }}
</p>
{% endfor %}
</div>
<!-- Confirm Password Field -->
<div class="mb-6">
{{ form.confirm_password.label(class="block text-sm font-semibold text-gray-700 mb-2") }}
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M3 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1zm7.707 3.293a1 1 0 010 1.414L9.414 9H17a1 1 0 110 2H9.414l1.293 1.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</div>
{{ form.confirm_password(class="w-full pl-11 p-3 border border-gray-300 rounded-xl
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all
shadow-sm bg-gray-50 focus:bg-white") }}
</div>
{% for error in form.confirm_password.errors %}
<p class="text-sm text-red-500 mt-2 font-medium flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
{{ error }}
</p>
{% endfor %}
</div>
<!-- Submit Button -->
<div class="mt-8">
{{ form.submit(class="w-full bg-primary-600 hover:bg-primary-700 text-white font-bold py-3 rounded-xl
shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-0.5 cursor-pointer")
}}
</div>
<p class="text-center mt-6 text-gray-600">
Already have an account? <a href="{{ url_for('auth.login') }}"
class="text-primary-600 hover:text-primary-800 font-bold hover:underline">Log in</a>
</p>
</form>
</div>
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% macro field(input, numeric=false, autocomplete='off') %}
<div class="field">
{{ input.label }}
{% if numeric %}
{{ input(inputmode='numeric', autocomplete=autocomplete, aria_invalid='true' if input.errors else 'false', aria_describedby=input.id ~ '-error' if input.errors else '') }}
{% else %}
{{ input(autocomplete=autocomplete, aria_invalid='true' if input.errors else 'false', aria_describedby=input.id ~ '-error' if input.errors else '') }}
{% endif %}
{% if input.errors %}<div class="field-error" id="{{ input.id }}-error">{% for error in input.errors %}<p>{{ error }}</p>{% endfor %}</div>{% endif %}
</div>
{% endmacro %}
{% macro reading_rows(readings, sys_threshold, dia_threshold) %}
{% if readings %}
<div class="reading-table" role="table" aria-label="Blood pressure readings">
<div class="reading-row table-heading" role="row"><span role="columnheader">Date & time</span><span role="columnheader">Blood pressure</span><span role="columnheader">Pulse</span><span role="columnheader">Personal threshold</span><span role="columnheader">Action</span></div>
{% for reading in readings %}
{% set above = reading.systolic >= sys_threshold or reading.diastolic >= dia_threshold %}
<div class="reading-row" role="row">
<div role="cell"><time datetime="{{ reading.local_timestamp.isoformat() }}">{{ reading.local_timestamp.strftime('%d %b %Y') }}<small>{{ reading.local_timestamp.strftime('%I:%M %p') }}</small></time></div>
<div role="cell" class="reading-value"><span class="mobile-label">Blood pressure</span>{{ reading.systolic }}<span class="slash"> / </span>{{ reading.diastolic }}<small>mmHg</small></div>
<div role="cell"><span class="mobile-label">Pulse</span><strong>{{ reading.heart_rate }}</strong> <small class="inline">bpm</small></div>
<div role="cell"><span class="status {{ 'status-above' if above else '' }}">{{ 'At / above' if above else 'Below' }} threshold</span></div>
<div role="cell"><a class="edit-link" aria-label="Edit reading from {{ reading.local_timestamp.strftime('%d %b %Y at %I:%M %p') }}" href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}">Edit <span aria-hidden="true"></span></a></div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state"><span class="empty-symbol" aria-hidden="true"></span><h3>No readings in this period</h3><p>Add a reading or choose another date range.</p><a class="button" href="{{ url_for('reading.add_reading') }}">Add reading</a></div>
{% endif %}
{% endmacro %}
+32 -112
View File
@@ -1,114 +1,34 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% from 'components.html' import reading_rows %}
{% block title %}{{ view|title }} · BP Tracker{% endblock %}
{% block content %} {% block content %}
<div class="max-w-5xl mx-auto p-4 space-y-6"> <div class="page-heading"><div><p class="eyebrow">YOUR DAILY PICTURE</p><h1>{{ view|title }}</h1><p class="muted">{{ 'A clearer view of your blood pressure.' if view == 'overview' else 'Every reading, in one place.' if view == 'history' else 'See how your readings change over time.' }}</p></div><a class="button primary add-action" href="{{ url_for('reading.add_reading') }}"><span aria-hidden="true"></span> Add reading</a></div>
<!-- Header Section with "Add New Reading" Button --> {% if not calendar %}{% include 'partials/range_filter.html' %}{% endif %}
<div class="flex justify-between items-center"> {% if view == 'overview' %}
<h1 class="text-2xl font-bold text-gray-800">Dashboard</h1> <section class="summary-grid" aria-label="Summary">
<a rel="prefetch" href="{{ url_for('reading.add_reading') }}" <article class="card latest-card"><div class="card-label"><h2>Latest reading</h2><span class="dot" aria-hidden="true"></span></div>
class="bg-primary-600 text-white px-4 py-2 rounded shadow hover:bg-primary-700"> {% if latest %}<p class="big-number">{{ latest.systolic }}<span class="slash"> / </span>{{ latest.diastolic }} <span class="unit">mmHg</span></p><p class="latest-meta">{{ latest.heart_rate }} bpm <span>·</span> {{ latest.local_timestamp.strftime('%d %b, %I:%M %p') }}</p><p class="card-foot">Most recent, across all dates</p>
+ Add New Reading {% else %}<p class="empty-title">Your first reading starts here</p><p class="muted">Record your blood pressure and pulse to get started.</p>{% endif %}</article>
</a> <article class="card"><h2>Period average</h2>{% if stats.count %}<p class="big-number">{{ stats.systolic }}<span class="slash"> / </span>{{ stats.diastolic }}</p><p class="muted">mmHg · {{ stats.pulse }} bpm average pulse</p>{% else %}<p class="empty-title">No readings yet</p><p class="muted">Averages appear when you add a reading.</p>{% endif %}<p class="card-foot">{{ selected.start.strftime('%d %b') }} {{ selected.end.strftime('%d %b %Y') }}</p></article>
</div> <article class="card count-card"><h2>Readings recorded</h2><p class="big-number">{{ stats.count }}</p><p class="muted">In the selected period</p><p class="card-foot">Times shown in {{ timezone_name }}</p></article>
</section>
<!-- Weekly Summary --> {% endif %}
<div class="bg-gradient-to-r from-primary-500 to-primary-700 text-white p-6 rounded-xl shadow-md"> {% if view in ['overview', 'trends'] %}{% include 'partials/trend.html' %}{% endif %}
<h3 class="text-lg font-bold">Weekly Summary</h3> {% if view == 'history' %}
<div class="flex justify-between mt-4"> <div class="section-heading"><nav class="segmented" aria-label="History display"><a href="{{ url_for('main.dashboard', view='history', **selected.params) }}" {% if not calendar %}aria-current="page"{% endif %}>Readings</a><a href="{{ url_for('main.dashboard', view='history', mode='calendar') }}" {% if calendar %}aria-current="page"{% endif %}>Calendar</a></nav><span class="muted small">{{ stats.count }} readings · {{ timezone_name }}</span></div>
<div> {% endif %}
<p class="text-sm font-semibold">Systolic Average</p> {% if calendar %}
<p class="text-2xl">{{ systolic_avg }} <span class="text-base">mmHg</span></p> <section class="card calendar-card">
</div> <div class="section-heading"><h2>{{ selected.start.strftime('%d %b') }} {{ selected.end.strftime('%d %b %Y') }}</h2><div class="actions"><a class="button" aria-label="Previous {{ calendar.unit }}" href="{{ url_for('main.dashboard', view='history', mode='calendar', unit=calendar.unit, anchor=calendar.prev) }}"></a><a class="button" aria-label="Next {{ calendar.unit }}" href="{{ url_for('main.dashboard', view='history', mode='calendar', unit=calendar.unit, anchor=calendar.next) }}"></a></div></div>
<div> <form class="calendar-controls" action="{{ url_for('main.dashboard') }}" method="get"><input type="hidden" name="view" value="history"><input type="hidden" name="mode" value="calendar"><div class="field"><label for="unit">Display</label><select name="unit" id="unit"><option value="month" {% if calendar.unit == 'month' %}selected{% endif %}>Month</option><option value="week" {% if calendar.unit == 'week' %}selected{% endif %}>Week</option></select></div><div class="field"><label for="anchor">Containing date</label><input id="anchor" type="date" name="anchor" value="{{ calendar.anchor }}" required></div><button class="button" type="submit">Go</button></form>
<p class="text-sm font-semibold">Diastolic Average</p> <p class="muted small">Daily averages in mmHg. Select a day to see its readings.</p>
<p class="text-2xl">{{ diastolic_avg }} <span class="text-base">mmHg</span></p> <div class="calendar-grid" style="--start-day:{{ selected.start.weekday() + 1 }}">{% for day in calendar.days %}<a class="calendar-day {{ 'has-readings' if day.data else '' }}" href="{{ url_for('main.dashboard', view='history', start_date=day.date.isoformat(), end_date=day.date.isoformat()) }}"><span class="muted small">{{ day.date.strftime('%a') }}</span><strong>{{ day.date.day }}</strong>{% if day.data %}<span>{{ day.data.systolic }} / {{ day.data.diastolic }}</span><small>{{ day.data.count }} reading{{ 's' if day.data.count != 1 else '' }}</small>{% else %}<small>No readings</small>{% endif %}</a>{% endfor %}</div>
</div> </section>
<div> {% elif view in ['overview', 'history'] %}
<p class="text-sm font-semibold">Heart Rate Average</p> <section class="card history-card"><div class="section-heading"><div><h2>{{ 'Recent readings' if view == 'overview' else 'Reading history' }}</h2><p class="muted small">Personal thresholds: {{ sys_threshold }}/{{ dia_threshold }} mmHg · <a href="{{ url_for('user.profile') }}">Adjust in Settings</a></p></div>{% if view == 'overview' %}<a class="text-link" href="{{ url_for('main.dashboard', view='history', **selected.params) }}">View all <span aria-hidden="true"></span></a>{% endif %}</div>
<p class="text-2xl">{{ heart_rate_avg }} <span class="text-base">bpm</span></p> {{ reading_rows(readings, sys_threshold, dia_threshold) }}
</div> {% if pagination and pagination.pages > 1 %}<nav class="pagination" aria-label="History pages">{% if pagination.has_prev %}<a class="button" href="{{ url_for('main.dashboard', view='history', page=pagination.prev_num, **selected.params) }}">← Previous</a>{% endif %}<span>Page {{ pagination.page }} of {{ pagination.pages }}</span>{% if pagination.has_next %}<a class="button" href="{{ url_for('main.dashboard', view='history', page=pagination.next_num, **selected.params) }}">Next →</a>{% endif %}</nav>{% endif %}
</div> </section>
</div> {% endif %}
<div class="report-links"><span class="muted small">Keep a copy of this period</span><a href="{{ url_for('data.export_data', **selected.params) }}" download>Download CSV</a><a href="{{ url_for('main.report', **selected.params) }}">Printable summary ↗</a></div>
<!-- Progress Badges -->
<div>
<div class="flex flex-wrap gap-4">
{% for badge in badges %}
<div class="bg-green-100 text-green-800 px-4 py-2 rounded shadow text-sm font-medium">
{{ badge }}
</div>
{% endfor %}
</div>
</div>
<div class="max-w-5xl mx-auto">
<!-- Tabs -->
<div class="flex border-b mb-4 overflow-x-auto" id="dashboard-tabs">
<button hx-get="{{ url_for('main.dashboard_list') }}" hx-target="#dashboard-content"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap {{ 'border-primary-600 text-primary-600' if active_view == 'list' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">List
View</button>
<button hx-get="{{ url_for('main.dashboard_table') }}" hx-target="#dashboard-content"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap {{ 'border-primary-600 text-primary-600' if active_view == 'table' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">Table
View</button>
<button hx-get="{{ url_for('main.dashboard_weekly') }}" hx-target="#dashboard-content"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap {{ 'border-primary-600 text-primary-600' if active_view == 'weekly' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">Weekly
View</button>
<button hx-get="{{ url_for('main.dashboard_monthly') }}" hx-target="#dashboard-content"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap {{ 'border-primary-600 text-primary-600' if active_view == 'monthly' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">Monthly
View</button>
<button hx-get="{{ url_for('main.dashboard_graph') }}" hx-target="#dashboard-content"
class="tab-btn px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap {{ 'border-primary-600 text-primary-600' if active_view == 'graph' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">Graph
View</button>
</div>
<!-- Dashboard Content Target Area for HTMX -->
<div id="dashboard-content" hx-get="{{ url_for('main.dashboard_list') }}" hx-trigger="load">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
</div>
</div>
</div>
<script>
// Use event delegation for dashboard interactions to survive DIY Turbo page replacements
document.addEventListener('click', (e) => {
// Filter Form Toggle
const filterBtn = e.target.closest('#filter-btn');
if (filterBtn) {
const filterForm = document.getElementById('filter-form');
const iconClosed = document.getElementById('filter-icon-closed');
const iconOpen = document.getElementById('filter-icon-open');
if (filterForm) {
const isHidden = filterForm.classList.contains('hidden');
if (isHidden) {
filterForm.classList.remove('hidden');
if (iconClosed) iconClosed.classList.add('hidden');
if (iconOpen) iconOpen.classList.remove('hidden');
} else {
filterForm.classList.add('hidden');
if (iconClosed) iconClosed.classList.remove('hidden');
if (iconOpen) iconOpen.classList.add('hidden');
}
}
return;
}
// Tabs
const tabBtn = e.target.closest('.tab-btn');
if (tabBtn) {
const tabBtns = document.querySelectorAll('.tab-btn');
tabBtns.forEach(b => {
b.classList.remove('border-primary-600', 'text-primary-600');
});
tabBtn.classList.add('border-primary-600', 'text-primary-600');
// The click will still bubble up and be caught by the Micro-HTMX logic in _layout.html
return;
}
});
</script>
{% endblock %} {% endblock %}
+3 -39
View File
@@ -1,39 +1,3 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% block title %}Import & export · BP Tracker{% endblock %}
{% block content %} {% block content %}<div class="page-heading"><div><p class="eyebrow">YOUR DATA</p><h1>Import & export</h1><p class="muted">Bring your readings together, or keep a copy.</p></div></div><div class="two-columns data-columns"><section class="card"><h2>Import readings</h2><p class="muted">Upload a CSV with Timestamp, Systolic, Diastolic and Heart Rate columns. Timestamps must be UTC, formatted YYYY-MM-DD HH:MM:SS.</p><form method="post" enctype="multipart/form-data" class="entry-form">{{ form.hidden_tag() }}<div class="field"><label for="file">CSV file</label><input type="file" id="file" name="file" accept=".csv,text/csv" required></div><p class="muted small">Imports add rows to your history, including duplicates. Maximum file size: 2 MB.</p><button class="button primary" type="submit">Import readings</button></form></section><section class="card"><h2>Export readings</h2><p class="muted">Download your full history as CSV. Exported timestamps use UTC for consistent re-imports.</p><a class="button" href="{{ url_for('data.export_data') }}" download>Download all readings</a><hr><h3>Need a specific period?</h3><p class="muted">Choose dates in History to download a filtered CSV or open a printable summary.</p><a class="text-link" href="{{ url_for('main.dashboard', view='history') }}">Go to History →</a></section></div>{% endblock %}
<div class="max-w-4xl mx-auto p-4 relative">
<!-- Cancel Button (Top-Left) -->
<a href="{{ request.referrer if request.referrer else url_for('main.dashboard') }}"
class="absolute top-5 left-4 flex items-center text-gray-600 hover:text-gray-800">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
<span>Back</span>
</a>
<h1 class="text-2xl font-bold mb-4 text-center">Import/Export Data</h1>
<!-- Import Data Section -->
<div class="bg-white p-6 rounded-lg shadow-md mb-6">
<h2 class="text-lg font-semibold mb-4">Import Data</h2>
<form method="POST" action="{{ url_for('data.manage_data') }}" enctype="multipart/form-data">
<label for="file" class="block text-sm font-medium text-gray-700 mb-2">Upload CSV File</label>
<input type="file" name="file" id="file"
class="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="submit"
class="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
Import Data
</button>
</form>
</div>
<!-- Export Data Section -->
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-lg font-semibold mb-4">Export Data</h2>
<a href="{{ url_for('data.export_data') }}"
class="bg-green-600 text-white px-6 py-2 rounded-lg shadow-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500">
Download CSV
</a>
</div>
</div>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends '_layout.html' %}
{% block title %}Choose a valid period · BP Tracker{% endblock %}
{% block content %}<section class="card form-page"><h1>Check your dates</h1><p class="field-error" role="alert">{{ message }}</p><a class="button" href="{{ url_for('main.dashboard') }}">Back to overview</a></section>{% endblock %}
+2 -84
View File
@@ -1,84 +1,2 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% block content %} {% block content %}<section class="landing-hero"><p class="eyebrow">A LITTLE CLARITY, EVERY DAY</p><h1>Your readings.<br>A clearer picture.</h1><p>Keep a simple record of your blood pressure and pulse.<br>See the trends. Bring your history to your next appointment.</p><div class="actions"><a class="button primary" href="{{ url_for('auth.signup') }}">Start tracking →</a><a class="button" href="{{ url_for('auth.login') }}">Log in</a></div></section><section class="summary-grid"><article class="card"><p class="eyebrow">01 / RECORD</p><h2>Quick to add</h2><p class="muted">Blood pressure, pulse and time. The essentials in one simple form.</p></article><article class="card"><p class="eyebrow">02 / UNDERSTAND</p><h2>See your patterns</h2><p class="muted">Explore your history with period averages and clear trends.</p></article><article class="card"><p class="eyebrow">03 / KEEP</p><h2>Take it with you</h2><p class="muted">Download your readings or print a summary for your records.</p></article></section>{% endblock %}
<div class="bg-gray-50 pb-20">
<!-- Hero Section -->
<section
class="bg-gradient-to-br from-primary-600 to-primary-800 text-white rounded-b-3xl shadow-xl overflow-hidden relative">
<div class="absolute inset-0 bg-white/5 pattern-dots pointer-events-none"></div>
<div class="container mx-auto px-4 py-24 text-center relative z-10">
<h1 class="text-5xl md:text-6xl font-extrabold mb-6 tracking-tight">
Welcome to BP Tracker
</h1>
<p class="text-xl md:text-2xl mb-12 max-w-2xl mx-auto font-medium text-primary-50 leading-relaxed">
Track your blood pressure and heart rate effortlessly. Take control of your health today!
</p>
<div class="flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-6">
<a href="{{ url_for('auth.signup') }}"
class="px-8 py-4 bg-white text-primary-700 font-bold text-lg rounded-xl shadow-xl hover:shadow-2xl hover:bg-gray-50 transition-all duration-300 transform hover:-translate-y-1">
Get Started
</a>
<a href="{{ url_for('auth.login') }}"
class="px-8 py-4 bg-primary-700 text-white border border-primary-500 font-bold text-lg rounded-xl shadow-lg hover:shadow-xl hover:bg-primary-600 transition-all duration-300 transform hover:-translate-y-1">
Login
</a>
</div>
</div>
</section>
<!-- Features Section -->
<section class="container mx-auto px-4 pt-24">
<div class="text-center mb-16">
<span class="text-primary-600 font-bold tracking-wider uppercase text-sm mb-2 block">Why Track With
Us</span>
<h2 class="text-4xl font-extrabold text-gray-900 tracking-tight">Why Choose BP Tracker?</h2>
</div>
<div class="grid md:grid-cols-3 gap-10">
<div
class="text-center bg-white p-10 rounded-3xl shadow-lg border border-gray-100 hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div
class="w-20 h-20 mx-auto bg-primary-50 rounded-2xl flex items-center justify-center mb-6 shadow-inner text-primary-600">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8c2.21 0 4 1.79 4 4s-1.79 4-4 4-4-1.79-4-4 1.79-4 4-4z"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-3">Accurate Tracking</h3>
<p class="text-gray-600 leading-relaxed">Keep a detailed log of your blood pressure and heart rate over
time to share with your doctor.</p>
</div>
<div
class="text-center bg-white p-10 rounded-3xl shadow-lg border border-gray-100 hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div
class="w-20 h-20 mx-auto bg-primary-50 rounded-2xl flex items-center justify-center mb-6 shadow-inner text-primary-600">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 16.5L15 12m0 0l-5.25-4.5m5.25 4.5H3"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-3">Insightful Graphs</h3>
<p class="text-gray-600 leading-relaxed">Visualize your progress and identify health trends with our
intuitive, easy-to-read charts.</p>
</div>
<div
class="text-center bg-white p-10 rounded-3xl shadow-lg border border-gray-100 hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div
class="w-20 h-20 mx-auto bg-primary-50 rounded-2xl flex items-center justify-center mb-6 shadow-inner text-primary-600">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 20l-5.5-5.5M9 20V9m0 11h11"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-3">Secure and Private</h3>
<p class="text-gray-600 leading-relaxed">Your medical data is protected with state-of-the-art security
measures. Your privacy is paramount.</p>
</div>
</div>
</section>
</div>
{% endblock %}
-346
View File
@@ -1,346 +0,0 @@
<style>
.svg-tooltip-group .svg-tooltip-content {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease-in-out;
pointer-events: none;
}
.svg-tooltip-group:hover .svg-tooltip-content {
opacity: 1;
visibility: visible;
}
</style>
<div class="space-y-8">
<!-- Graph Date Filter -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-4 mb-2">
<form hx-get="{{ url_for('main.dashboard_graph') }}" hx-target="#dashboard-content"
class="flex flex-col sm:flex-row gap-4">
<!-- Start Date -->
<div class="flex-1">
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full p-2.5 border border-gray-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-700">
</div>
<!-- End Date -->
<div class="flex-1">
<label for="end_date" class="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full p-2.5 border border-gray-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-700">
</div>
</form>
</div>
<!-- Blood Pressure Graph Card -->
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 transition-all hover:shadow-xl">
<div class="flex flex-col md:flex-row md:items-center justify-between mb-6">
<div>
<h3 class="text-xl font-bold text-gray-800">Blood Pressure Trends</h3>
<p class="text-sm text-gray-500 mt-1">Systolic vs Diastolic over time (mmHg)</p>
</div>
<div class="flex space-x-6 text-sm mt-4 md:mt-0 bg-gray-50 px-4 py-2 rounded-full">
<div class="flex items-center"><span
class="w-3 h-3 rounded-full bg-blue-500 mr-2 shadow-sm"></span><span
class="font-medium text-gray-700">Systolic</span></div>
<div class="flex items-center"><span
class="w-3 h-3 rounded-full bg-pink-500 mr-2 shadow-sm"></span><span
class="font-medium text-gray-700">Diastolic</span></div>
</div>
</div>
<div class="w-full overflow-x-auto pb-4">
<svg viewBox="0 0 800 350" class="w-full min-w-[600px] h-auto drop-shadow-sm font-sans"
style="max-height: 350px;">
<defs>
<linearGradient id="sysGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3" />
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.0" />
</linearGradient>
<linearGradient id="diaGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#ec4899" stop-opacity="0.3" />
<stop offset="100%" stop-color="#ec4899" stop-opacity="0.0" />
</linearGradient>
</defs>
{% if timestamps %}
{% set n = timestamps|length %}
{% set spacing = 700 / (n - 1) if n > 1 else 0 %}
<!-- Horizontal Grid Lines & Y-Axis Labels -->
{% for value in range(50, 201, 50) %}
{% set y = 280 - (value / 200 * 250) %}
<line x1="50" y1="{{ y }}" x2="750" y2="{{ y }}" stroke="#e5e7eb" stroke-width="1"
stroke-dasharray="4 4" />
<text x="40" y="{{ y + 4 }}" font-size="12" fill="#9ca3af" text-anchor="end" font-weight="500">{{ value
}}</text>
{% endfor %}
<!-- X-Axis Base Line -->
<line x1="50" y1="280" x2="750" y2="280" stroke="#d1d5db" stroke-width="1" />
<!-- X-Axis Labels -->
{% set ns = namespace(last_x=-100) %}
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
<!-- Optimization: only draw label if it has enough horizontal space from the last drawn label -->
{% set is_last = (i == n - 1) %}
{% if x - ns.last_x > 45 or is_last %}
<!-- If it's the last label but too close to the previous, maybe we draw it anyway but it will overlap. We prevent overlap by aggressively spacing. -->
{% if not is_last or x - ns.last_x > 40 or ns.last_x == -100 %}
<text x="{{ x }}" y="310" font-size="11" fill="#6b7280" text-anchor="middle" font-weight="500"
transform="rotate(-25 {{ x }} 310)">{{
timestamps[i] }}</text>
{% set ns.last_x = x %}
{% endif %}
{% endif %}
{% endfor %}
<!-- Systolic Filled Area -->
<polygon fill="url(#sysGrad)" points="{% if n>1 %}50{% else %}400{% endif %},280
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (systolic[i] / 200 * 250) %}
{{ x }},{{ y }}
{% endfor %}
{% if n>1 %}{{ 50 + (n - 1) * spacing }}{% else %}400{% endif %},280" />
<!-- Systolic Line -->
<polyline fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
points="{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (systolic[i] / 200 * 250) %}
{{ x }},{{ y }}
{% endfor %}" />
<!-- Systolic Points -->
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (systolic[i] / 200 * 250) %}
<circle cx="{{ x }}" cy="{{ y }}" r="5" fill="#ffffff" stroke="#3b82f6" stroke-width="2.5"></circle>
{% endfor %}
<!-- Diastolic Filled Area -->
<polygon fill="url(#diaGrad)" points="{% if n>1 %}50{% else %}400{% endif %},280
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (diastolic[i] / 200 * 250) %}
{{ x }},{{ y }}
{% endfor %}
{% if n>1 %}{{ 50 + (n - 1) * spacing }}{% else %}400{% endif %},280" />
<!-- Diastolic Line -->
<polyline fill="none" stroke="#ec4899" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
points="{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (diastolic[i] / 200 * 250) %}
{{ x }},{{ y }}
{% endfor %}" />
<!-- Diastolic Points -->
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (diastolic[i] / 200 * 250) %}
<circle cx="{{ x }}" cy="{{ y }}" r="5" fill="#ffffff" stroke="#ec4899" stroke-width="2.5"></circle>
{% endfor %}
<!-- Tooltips (Rendered last to stay on top) -->
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set ySys = 280 - (systolic[i] / 200 * 250) %}
<g class="svg-tooltip-group" style="cursor: pointer;">
<circle cx="{{ x }}" cy="{{ ySys }}" r="15" fill="transparent" />
<circle cx="{{ x }}" cy="{{ ySys }}" r="7" fill="#ffffff" stroke="#3b82f6" stroke-width="2.5"
class="svg-tooltip-content" />
<g class="svg-tooltip-content">
<rect x="{{ x - 64 }}" y="{{ ySys - 54 }}" width="128" height="40" fill="#1f2937" rx="6" />
<polygon points="{{ x - 6 }},{{ ySys - 14 }} {{ x + 6 }},{{ ySys - 14 }} {{ x }},{{ ySys - 6 }}"
fill="#1f2937" />
<text x="{{ x }}" y="{{ ySys - 36 }}" font-size="11" fill="#f3f4f6" text-anchor="middle"
font-weight="600" class="font-sans">{{ timestamps[i] }}</text>
<text x="{{ x }}" y="{{ ySys - 22 }}" font-size="11" fill="#93c5fd" text-anchor="middle"
font-weight="500" class="font-sans">Systolic: {{ systolic[i] }}</text>
</g>
</g>
{% set yDia = 280 - (diastolic[i] / 200 * 250) %}
<g class="svg-tooltip-group" style="cursor: pointer;">
<circle cx="{{ x }}" cy="{{ yDia }}" r="15" fill="transparent" />
<circle cx="{{ x }}" cy="{{ yDia }}" r="7" fill="#ffffff" stroke="#ec4899" stroke-width="2.5"
class="svg-tooltip-content" />
<g class="svg-tooltip-content">
<rect x="{{ x - 64 }}" y="{{ yDia - 54 }}" width="128" height="40" fill="#1f2937" rx="6" />
<polygon points="{{ x - 6 }},{{ yDia - 14 }} {{ x + 6 }},{{ yDia - 14 }} {{ x }},{{ yDia - 6 }}"
fill="#1f2937" />
<text x="{{ x }}" y="{{ yDia - 36 }}" font-size="11" fill="#f3f4f6" text-anchor="middle"
font-weight="600" class="font-sans">{{ timestamps[i] }}</text>
<text x="{{ x }}" y="{{ yDia - 22 }}" font-size="11" fill="#f472b6" text-anchor="middle"
font-weight="500" class="font-sans">Diastolic: {{ diastolic[i] }}</text>
</g>
</g>
{% endfor %}
<!-- Average Lines -->
{% set ySysAvg = 280 - (sys_avg / 200 * 250) %}
<g class="svg-tooltip-group" style="cursor: default;">
<line x1="50" y1="{{ ySysAvg }}" x2="750" y2="{{ ySysAvg }}" stroke="#3b82f6" stroke-width="2"
stroke-dasharray="8 6" opacity="0.8" />
<line x1="50" y1="{{ ySysAvg }}" x2="750" y2="{{ ySysAvg }}" stroke="transparent"
stroke-width="15" /> <!-- Invisible hover anchor -->
<g class="svg-tooltip-content">
<rect x="336" y="{{ ySysAvg - 34 }}" width="128" height="26" fill="#1f2937" rx="6" />
<polygon points="394,{{ ySysAvg - 8 }} 406,{{ ySysAvg - 8 }} 400,{{ ySysAvg - 2 }}"
fill="#1f2937" />
<text x="400" y="{{ ySysAvg - 17 }}" font-size="11" fill="#93c5fd" text-anchor="middle"
font-weight="600" class="font-sans">Avg Systolic: {{ sys_avg }}</text>
</g>
</g>
{% set yDiaAvg = 280 - (dia_avg / 200 * 250) %}
<g class="svg-tooltip-group" style="cursor: default;">
<line x1="50" y1="{{ yDiaAvg }}" x2="750" y2="{{ yDiaAvg }}" stroke="#ec4899" stroke-width="2"
stroke-dasharray="8 6" opacity="0.8" />
<line x1="50" y1="{{ yDiaAvg }}" x2="750" y2="{{ yDiaAvg }}" stroke="transparent"
stroke-width="15" /> <!-- Invisible hover anchor -->
<g class="svg-tooltip-content">
<rect x="336" y="{{ yDiaAvg - 34 }}" width="128" height="26" fill="#1f2937" rx="6" />
<polygon points="394,{{ yDiaAvg - 8 }} 406,{{ yDiaAvg - 8 }} 400,{{ yDiaAvg - 2 }}"
fill="#1f2937" />
<text x="400" y="{{ yDiaAvg - 17 }}" font-size="11" fill="#f472b6" text-anchor="middle"
font-weight="600" class="font-sans">Avg Diastolic: {{ dia_avg }}</text>
</g>
</g>
{% else %}
<rect x="50" y="30" width="700" height="250" fill="#f9fafb" rx="10" />
<text x="400" y="155" font-size="16" fill="#9ca3af" text-anchor="middle" font-weight="500">No data
available for the selected period.</text>
{% endif %}
</svg>
</div>
</div>
<!-- Heart Rate Graph Card -->
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 transition-all hover:shadow-xl">
<div class="flex flex-col md:flex-row md:items-center justify-between mb-6">
<div>
<h3 class="text-xl font-bold text-gray-800">Heart Rate</h3>
<p class="text-sm text-gray-500 mt-1">Beats per minute over time (bpm)</p>
</div>
<div class="flex space-x-6 text-sm mt-4 md:mt-0 bg-gray-50 px-4 py-2 rounded-full">
<div class="flex items-center"><span
class="w-3 h-3 rounded-full bg-emerald-500 mr-2 shadow-sm"></span><span
class="font-medium text-gray-700">Heart Rate</span></div>
</div>
</div>
<div class="w-full overflow-x-auto pb-4">
<svg viewBox="0 0 800 350" class="w-full min-w-[600px] h-auto drop-shadow-sm font-sans"
style="max-height: 350px;">
<defs>
<linearGradient id="hrGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#10b981" stop-opacity="0.3" />
<stop offset="100%" stop-color="#10b981" stop-opacity="0.0" />
</linearGradient>
</defs>
{% if timestamps %}
{% set n = timestamps|length %}
{% set spacing = 700 / (n - 1) if n > 1 else 0 %}
<!-- Horizontal Grid Lines & Y-Axis Labels -->
{% for value in range(50, 201, 50) %}
{% set y = 280 - (value / 200 * 250) %}
<line x1="50" y1="{{ y }}" x2="750" y2="{{ y }}" stroke="#e5e7eb" stroke-width="1"
stroke-dasharray="4 4" />
<text x="40" y="{{ y + 4 }}" font-size="12" fill="#9ca3af" text-anchor="end" font-weight="500">{{ value
}}</text>
{% endfor %}
<!-- X-Axis Base Line -->
<line x1="50" y1="280" x2="750" y2="280" stroke="#d1d5db" stroke-width="1" />
<!-- X-Axis Labels -->
{% set ns = namespace(last_x=-100) %}
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set is_last = (i == n - 1) %}
{% if x - ns.last_x > 45 or is_last %}
{% if not is_last or x - ns.last_x > 40 or ns.last_x == -100 %}
<text x="{{ x }}" y="310" font-size="11" fill="#6b7280" text-anchor="middle" font-weight="500"
transform="rotate(-25 {{ x }} 310)">{{
timestamps[i] }}</text>
{% set ns.last_x = x %}
{% endif %}
{% endif %}
{% endfor %}
<!-- Heart Rate Filled Area -->
<polygon fill="url(#hrGrad)" points="{% if n>1 %}50{% else %}400{% endif %},280
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (heart_rate[i] / 200 * 250) %}
{{ x }},{{ y }}
{% endfor %}
{% if n>1 %}{{ 50 + (n - 1) * spacing }}{% else %}400{% endif %},280" />
<!-- Heart Rate Line -->
<polyline fill="none" stroke="#10b981" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
points="{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (heart_rate[i] / 200 * 250) %}
{{ x }},{{ y }}
{% endfor %}" />
<!-- Heart Rate Points -->
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set y = 280 - (heart_rate[i] / 200 * 250) %}
<circle cx="{{ x }}" cy="{{ y }}" r="5" fill="#ffffff" stroke="#10b981" stroke-width="2.5"></circle>
{% endfor %}
<!-- Tooltips (Rendered last to stay on top) -->
{% for i in range(n) %}
{% set x = 50 + time_percentages[i] * 700 if n > 1 else 400 %}
{% set yHR = 280 - (heart_rate[i] / 200 * 250) %}
<g class="svg-tooltip-group" style="cursor: pointer;">
<circle cx="{{ x }}" cy="{{ yHR }}" r="15" fill="transparent" />
<circle cx="{{ x }}" cy="{{ yHR }}" r="7" fill="#ffffff" stroke="#10b981" stroke-width="2.5"
class="svg-tooltip-content" />
<g class="svg-tooltip-content">
<rect x="{{ x - 64 }}" y="{{ yHR - 54 }}" width="128" height="40" fill="#1f2937" rx="6" />
<polygon points="{{ x - 6 }},{{ yHR - 14 }} {{ x + 6 }},{{ yHR - 14 }} {{ x }},{{ yHR - 6 }}"
fill="#1f2937" />
<text x="{{ x }}" y="{{ yHR - 36 }}" font-size="11" fill="#f3f4f6" text-anchor="middle"
font-weight="600" class="font-sans">{{ timestamps[i] }}</text>
<text x="{{ x }}" y="{{ yHR - 22 }}" font-size="11" fill="#34d399" text-anchor="middle"
font-weight="500" class="font-sans">Heart Rate: {{ heart_rate[i] }}</text>
</g>
</g>
{% endfor %}
<!-- Average Line -->
{% set yHrAvg = 280 - (hr_avg / 200 * 250) %}
<g class="svg-tooltip-group" style="cursor: default;">
<line x1="50" y1="{{ yHrAvg }}" x2="750" y2="{{ yHrAvg }}" stroke="#10b981" stroke-width="2"
stroke-dasharray="8 6" opacity="0.8" />
<line x1="50" y1="{{ yHrAvg }}" x2="750" y2="{{ yHrAvg }}" stroke="transparent" stroke-width="15" />
<!-- Invisible hover anchor -->
<g class="svg-tooltip-content">
<rect x="336" y="{{ yHrAvg - 34 }}" width="128" height="26" fill="#1f2937" rx="6" />
<polygon points="394,{{ yHrAvg - 8 }} 406,{{ yHrAvg - 8 }} 400,{{ yHrAvg - 2 }}"
fill="#1f2937" />
<text x="400" y="{{ yHrAvg - 17 }}" font-size="11" fill="#34d399" text-anchor="middle"
font-weight="600" class="font-sans">Avg Heart Rate: {{ hr_avg }}</text>
</g>
</g>
{% else %}
<rect x="50" y="30" width="700" height="250" fill="#f9fafb" rx="10" />
<text x="400" y="155" font-size="16" fill="#9ca3af" text-anchor="middle" font-weight="500">No data
available for the selected period.</text>
{% endif %}
</svg>
</div>
</div>
</div>
@@ -1,79 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<defs>
<path id="icon-clock" stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m9-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
<path id="icon-chevron" stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</defs>
</svg>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{% for reading in readings %}
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
class="bg-white shadow-sm hover:shadow-md rounded-xl p-3 flex justify-between items-center border border-gray-100 transition-all">
<!-- Left side: Timestamp & BP -->
<div>
<div class="flex items-center text-gray-400 text-xs mb-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<use href="#icon-clock"></use>
</svg>
<span title="{{ reading.local_timestamp.strftime('%d %b %Y, %I:%M %p') }}">
{{ reading.relative_timestamp }}
</span>
</div>
<div class="flex items-baseline">
<span class="text-xl font-bold text-gray-800">{{ reading.systolic }}<span
class="text-gray-400 font-normal mx-0.5">/</span>{{ reading.diastolic }}</span>
<span class="text-xs text-gray-500 ml-1">mmHg</span>
</div>
</div>
<!-- Right side: Heart Rate & Icon -->
<div class="flex items-center space-x-4">
<div class="text-right">
<span class="text-xs text-gray-500 block mb-0.5">HR</span>
<div class="flex items-baseline justify-end">
<span class="text-lg font-bold text-gray-700">{{ reading.heart_rate }}</span>
<span class="text-xs text-gray-400 ml-1">bpm</span>
</div>
</div>
<div class="text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="h-4 w-4">
<use href="#icon-chevron"></use>
</svg>
</div>
</div>
</a>
{% else %}
<div class="col-span-full text-center text-sm text-gray-500">
No readings found.
</div>
{% endfor %}
</div>
<!-- Pagination Controls -->
{% if pagination.pages > 1 %}
<div class="flex justify-center items-center gap-2 mt-6">
{% if pagination.has_prev %}
<button hx-get="{{ url_for('main.dashboard_list', page=pagination.prev_num) }}" hx-target="#dashboard-content"
class="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm">&laquo; Prev</button>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
<button hx-get="{{ url_for('main.dashboard_list', page=page_num) }}" hx-target="#dashboard-content"
class="px-3 py-1 rounded text-sm {% if page_num == pagination.page %}bg-primary-600 text-white{% else %}bg-gray-200 hover:bg-gray-300{% endif %}">
{{ page_num }}
</button>
{% else %}
<span class="text-gray-400"></span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<button hx-get="{{ url_for('main.dashboard_list', page=pagination.next_num) }}" hx-target="#dashboard-content"
class="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm">Next &raquo;</button>
{% endif %}
</div>
{% endif %}

Before

Width:  |  Height:  |  Size: 3.7 KiB

@@ -1,88 +0,0 @@
<div class="flex flex-col px-2 py-2 -mb-px">
<!-- Monthly Navigation -->
<div class="flex justify-between items-center mb-4 px-2 mt-2">
<button hx-get="{{ url_for('main.dashboard_monthly', month_offset=month_offset - 1) }}"
hx-target="#dashboard-content"
class="flex items-center text-primary-600 hover:text-primary-800 font-medium transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Previous Month
</button>
<h2 class="text-xl font-bold text-gray-800">{{ target_month_date.strftime('%B %Y') }}</h2>
<button hx-get="{{ url_for('main.dashboard_monthly', month_offset=month_offset + 1) }}"
hx-target="#dashboard-content"
class="flex items-center text-primary-600 hover:text-primary-800 font-medium transition-colors {% if month_offset >= 0 %}opacity-50 pointer-events-none{% endif %}">
Next Month
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div class="grid grid-cols-7 pl-2 pr-2">
<div class="p-2 h-10 text-center font-bold">
<span class="xl:block lg:block md:block sm:block hidden">Sunday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sun</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<span class="xl:block lg:block md:block sm:block hidden">Monday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Mon</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<span class="xl:block lg:block md:block sm:block hidden">Tuesday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Tue</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<span class="xl:block lg:block md:block sm:block hidden">Wednesday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Wed</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<span class="xl:block lg:block md:block sm:block hidden">Thursday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Thu</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<span class="xl:block lg:block md:block sm:block hidden">Friday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Fri</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<span class="xl:block lg:block md:block sm:block hidden">Saturday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sat</span>
</div>
</div>
<div class="grid grid-cols-7 overflow-hidden flex-1 pl-2 pr-2 w-full">
{% for day in month %}
<div
class="{% if day.is_today %}border-2 border-primary-400 bg-primary-50{% else %}border bg-white{% endif %} flex flex-col min-h-[120px] p-1.5 transition-colors {% if not day.is_in_current_month %}bg-gray-50 opacity-50{% endif %}">
<div class="text-right w-full mb-1">
<span
class="text-xs font-semibold {% if day.is_today %}text-primary-600{% else %}text-gray-500{% endif %}">{{
day.day }}</span>
</div>
<div class="space-y-1 flex-grow">
{% for reading in day.readings %}
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
class="flex flex-col xl:flex-row justify-between items-center px-1 py-1 bg-white border border-gray-100 rounded shadow-sm hover:border-primary-300 hover:bg-primary-50 transition-colors">
<span class="text-[10px] sm:text-xs font-bold text-gray-800 leading-none mb-0.5 xl:mb-0">{{
reading.systolic }}/{{ reading.diastolic }}</span>
<div class="flex items-center gap-0.5">
<span
class="text-[9px] sm:text-[10px] font-medium bg-red-50 text-red-600 px-1 rounded leading-none py-0.5"
title="Heart Rate">{{ reading.heart_rate }}</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
-172
View File
@@ -1,172 +0,0 @@
<div class="space-y-6">
<!-- Table Date Filter -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-4 mb-2">
<form hx-get="{{ url_for('main.dashboard_table') }}" hx-target="#dashboard-content"
class="flex flex-col sm:flex-row gap-4">
<!-- Start Date -->
<div class="flex-1">
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full p-2.5 border border-gray-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-700">
</div>
<!-- End Date -->
<div class="flex-1">
<label for="end_date" class="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full p-2.5 border border-gray-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-700">
</div>
</form>
</div>
<!-- Data Container -->
<div class="bg-transparent md:bg-white md:rounded-2xl md:shadow-sm md:border md:border-gray-100 overflow-hidden">
<!-- Mobile Card View (Hidden on medium screens and up) -->
<div class="md:hidden space-y-4">
{% for reading in readings %}
<div class="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex flex-col gap-3">
<div class="flex justify-between items-center border-b border-gray-50 pb-2">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-sm font-bold text-gray-800">{{ reading.local_timestamp.strftime('%Y-%m-%d')
}}</span>
</div>
<span class="text-sm font-medium text-gray-500">{{ reading.local_timestamp.strftime('%I:%M %p')
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Blood Pressure</span>
<div class="flex items-baseline gap-1">
<span
class="font-bold text-lg {{ 'text-red-500' if reading.systolic >= 130 else 'text-gray-900' }}">{{
reading.systolic }}</span>
<span class="font-medium text-gray-400">/</span>
<span
class="font-bold text-lg {{ 'text-red-500' if reading.diastolic >= 80 else 'text-gray-900' }}">{{
reading.diastolic }}</span>
<span class="text-xs text-gray-500 font-medium ml-1">mmHg</span>
</div>
</div>
<div class="flex justify-between items-center">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Heart Rate</span>
<div class="flex items-baseline">
<span class="font-bold text-lg text-gray-900">{{ reading.heart_rate }}</span>
<span class="text-xs text-gray-500 font-medium ml-1">bpm</span>
</div>
</div>
<div class="flex justify-end pt-3 mt-1 border-t border-gray-50">
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-800 text-sm font-bold transition-colors">
Edit
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
{% else %}
<div
class="bg-white p-8 text-center rounded-xl shadow-sm border border-gray-100 flex flex-col items-center gap-3">
<svg class="w-12 h-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<span class="text-sm font-medium text-gray-500">No readings found for this date range.</span>
</div>
{% endfor %}
</div>
<!-- Desktop Table View (Hidden on small screens) -->
<div class="hidden md:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
Date</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
Time</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
Systolic</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
Diastolic</th>
<th scope="col"
class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
Heart Rate</th>
<th scope="col"
class="px-6 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">
Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for reading in readings %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{
reading.local_timestamp.strftime('%Y-%m-%d') }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{
reading.local_timestamp.strftime('%I:%M %p') }}</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm font-semibold {{ 'text-red-500' if reading.systolic >= 130 else 'text-gray-900' }}">
{{ reading.systolic }}</td>
<td
class="px-6 py-4 whitespace-nowrap text-sm font-semibold {{ 'text-red-500' if reading.diastolic >= 80 else 'text-gray-900' }}">
{{ reading.diastolic }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ reading.heart_rate }} bpm</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
class="text-primary-600 hover:text-primary-900">Edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-6 py-12 text-center text-sm text-gray-500">
No readings found for this date range.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination Controls -->
{% if pagination.pages > 1 %}
<div class="flex justify-center items-center gap-2 mt-6">
{% if pagination.has_prev %}
<button
hx-get="{{ url_for('main.dashboard_table', page=pagination.prev_num, start_date=start_date, end_date=end_date) }}"
hx-target="#dashboard-content" class="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm">&laquo;
Prev</button>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
<button hx-get="{{ url_for('main.dashboard_table', page=page_num, start_date=start_date, end_date=end_date) }}"
hx-target="#dashboard-content"
class="px-3 py-1 rounded text-sm {% if page_num == pagination.page %}bg-primary-600 text-white{% else %}bg-gray-200 hover:bg-gray-300{% endif %}">
{{ page_num }}
</button>
{% else %}
<span class="text-gray-400"></span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<button
hx-get="{{ url_for('main.dashboard_table', page=pagination.next_num, start_date=start_date, end_date=end_date) }}"
hx-target="#dashboard-content" class="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm">Next
&raquo;</button>
{% endif %}
</div>
{% endif %}
</div>
@@ -1,56 +0,0 @@
<div>
<!-- Weekly Navigation -->
<div class="flex justify-between items-center mb-4 px-2">
<button hx-get="{{ url_for('main.dashboard_weekly', week_offset=week_offset - 1) }}"
hx-target="#dashboard-content"
class="flex items-center text-primary-600 hover:text-primary-800 font-medium transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Previous Week
</button>
<span class="text-gray-600 font-semibold {% if week_offset == 0 %}text-primary-600{% endif %}">
{% if week_offset == 0 %}This Week{% elif week_offset == -1 %}Last Week{% elif week_offset == 1 %}Next
Week{% else %}{{ week_offset|abs }} weeks {% if week_offset < 0 %}ago{% else %}from now{% endif %}{% endif
%} </span>
<button hx-get="{{ url_for('main.dashboard_weekly', week_offset=week_offset + 1) }}"
hx-target="#dashboard-content"
class="flex items-center text-primary-600 hover:text-primary-800 font-medium transition-colors {% if week_offset >= 0 %}opacity-50 pointer-events-none{% endif %}">
Next Week
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div class="grid grid-cols-7 text-center">
{% for day in week %}
<div class="border p-2 bg-gray-50 flex flex-col min-h-[140px]">
<div class="text-sm font-bold text-gray-500 mb-2">{{ day.date }}</div>
{% if day.readings %}
<div class="space-y-1.5 flex-grow">
{% for reading in day.readings %}
<a href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}"
class="flex flex-col 2xl:flex-row justify-between items-center px-1.5 py-1 bg-white border border-gray-200 rounded shadow-sm hover:border-primary-400 hover:bg-primary-50 transition-colors">
<span class="text-xs font-bold text-gray-800">{{ reading.systolic }}/{{ reading.diastolic
}}</span>
<div class="flex items-center gap-1 2xl:mt-0 mt-0.5">
<span class="text-[10px] font-medium bg-red-50 text-red-600 px-1 rounded" title="Heart Rate">{{
reading.heart_rate }}</span>
<span class="text-[10px] text-gray-400 font-medium whitespace-nowrap">{{
reading.local_timestamp.strftime('%H:%M') }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="flex-grow"></div> <!-- Spacer -->
{% endif %}
</div>
{% endfor %}
</div>
</div>
+4
View File
@@ -0,0 +1,4 @@
<div class="range-bar">
<nav class="segmented" aria-label="Quick date ranges">{% for days in [7, 30, 90] %}<a href="{{ url_for('main.dashboard', view=view, days=days) }}" {% if active_days == days %}aria-current="page"{% endif %}>{{ days }} days</a>{% endfor %}</nav>
<form method="get" action="{{ url_for('main.dashboard') }}" class="date-form"><input type="hidden" name="view" value="{{ view }}"><div class="field"><label for="start_date">From</label><input id="start_date" name="start_date" type="date" value="{{ selected.start }}" required></div><div class="field"><label for="end_date">To</label><input id="end_date" name="end_date" type="date" value="{{ selected.end }}" required></div><button type="submit" class="button">Apply</button></form>
</div>
+15
View File
@@ -0,0 +1,15 @@
<section class="card trend-card">
<div class="section-heading"><div><p class="eyebrow">THE BIGGER PICTURE</p><h2>Blood pressure trend</h2></div><span class="chart-unit">mmHg</span></div>
<div class="chart-legend"><span><i class="legend-line systolic"></i>Systolic</span><span><i class="legend-line diastolic"></i>Diastolic</span>{% if stats.count %}<span class="muted small">Average {{ stats.systolic }} / {{ stats.diastolic }} · {{ stats.count }} readings</span>{% endif %}</div>
{% if chart.points %}
<svg class="trend-chart" viewBox="0 0 800 280" role="img" aria-labelledby="trend-title trend-desc"><title id="trend-title">Blood pressure over the selected period</title><desc id="trend-desc">{{ chart.bucket_days }}-day averages. Systolic average {{ stats.systolic }}, diastolic average {{ stats.diastolic }} mmHg. Exact averages and counts are available {{ 'in the expandable chart data below' if view == 'trends' else 'on the Trends page' }}.</desc>
{% for tick in [50, 100, 150, 200, 250] %}{% set y = 244 - tick / 260 * 208 %}<line class="grid-line" x1="48" x2="752" y1="{{ y }}" y2="{{ y }}"/><text x="34" y="{{ y + 4 }}" text-anchor="end">{{ tick }}</text>{% endfor %}
{% for series in chart.paths[:2] %}<path class="chart-line {{ series.key }}" d="{{ series.path }}"/>{% for x,y in series.coordinates %}<circle class="chart-point {{ series.key }}" cx="{{ x }}" cy="{{ y }}" r="3"/>{% endfor %}{% endfor %}
<text x="48" y="270">{{ selected.start.strftime('%d %b %Y') }}</text><text x="752" y="270" text-anchor="end">{{ selected.end.strftime('%d %b %Y') }}</text></svg>
<div class="chart-caption"><p>{{ 'Daily' if chart.bucket_days == 1 else chart.bucket_days|string ~ '-day' }} averages · {{ timezone_name }}. Gaps without readings are joined.</p>{% if view == 'overview' %}<a class="text-link" href="{{ url_for('main.dashboard', view='trends', **selected.params) }}">Explore trends →</a>{% endif %}</div>
{% if view == 'trends' %}
<details class="chart-details"><summary>View pulse trend · average {{ stats.pulse }} bpm</summary><svg class="trend-chart" viewBox="0 0 800 280" role="img" aria-labelledby="pulse-title"><title id="pulse-title">Pulse in bpm, {{ chart.bucket_days }}-day averages. Average {{ stats.pulse }} bpm; values available in the chart data table.</title>{% for tick in [50, 100, 150, 200] %}{% set y = 244 - tick / 260 * 208 %}<line class="grid-line" x1="48" x2="752" y1="{{ y }}" y2="{{ y }}"/><text x="34" y="{{ y + 4 }}" text-anchor="end">{{ tick }}</text>{% endfor %}<path class="chart-line" d="{{ chart.paths[2].path }}"/>{% for x,y in chart.paths[2].coordinates %}<circle class="chart-point" cx="{{ x }}" cy="{{ y }}" r="3"/>{% endfor %}<text x="48" y="270">{{ selected.start.strftime('%d %b %Y') }}</text><text x="752" y="270" text-anchor="end">{{ selected.end.strftime('%d %b %Y') }}</text></svg></details>
<details class="chart-details"><summary>View chart data & pulse</summary><p class="muted small">Averages are calculated from original readings. Each row covers up to {{ chart.bucket_days }} day(s); pulse is shown in bpm.</p><div class="table-scroll" tabindex="0" role="region" aria-label="Chart data"><table><thead><tr><th scope="col">Period</th><th scope="col">Systolic</th><th scope="col">Diastolic</th><th scope="col">Pulse</th><th scope="col">Readings</th></tr></thead><tbody>{% for point in chart.points %}<tr><th scope="row">{{ point.date }}{% if point.date != point.end %} {{ point.end }}{% endif %}</th><td>{{ point.systolic }}</td><td>{{ point.diastolic }}</td><td>{{ point.pulse }}</td><td>{{ point.count }}</td></tr>{% endfor %}</tbody></table></div></details>
{% endif %}
{% else %}<div class="empty-state"><h3>A trend begins with a reading</h3><p>No readings in this period. Try another range or add your first reading.</p></div>{% endif %}
</section>
+9 -72
View File
@@ -1,72 +1,9 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% block content %} {% from 'components.html' import field %}
<div class="max-w-4xl mx-auto p-4 relative"> {% block title %}Settings · BP Tracker{% endblock %}
<!-- Cancel Button (Top-Left) --> {% block content %}<div class="form-page settings-page"><div class="page-heading"><div><p class="eyebrow">MAKE IT YOURS</p><h1>Settings</h1><p class="muted">Your profile, display and personal thresholds.</p></div></div><form method="post" enctype="multipart/form-data" class="card entry-form" novalidate>{{ form.hidden_tag() }}
<a href="{{ request.referrer if request.referrer else url_for('main.dashboard') }}" {% if form.errors %}<div class="notice notice-error" role="alert">Please check the highlighted fields.</div>{% endif %}
class="absolute top-5 left-4 flex items-center text-gray-600 hover:text-gray-800"> <fieldset><legend>Profile</legend>{% if profile.profile_pic %}<img class="profile-photo" src="{{ url_for('user.profile_image', user_id=current_user.id) }}" width="64" height="64" alt="Your profile picture">{% endif %}{{ field(form.name, autocomplete='name') }}{{ field(form.email, autocomplete='email') }}{{ field(form.profile_pic) }}</fieldset>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" <fieldset><legend>Personal thresholds</legend><p class="muted small">Readings at or above either value are labelled in History. These are your saved reference values.</p><div class="two-columns">{{ field(form.systolic_threshold, true) }}{{ field(form.diastolic_threshold, true) }}</div></fieldset>
stroke="currentColor" stroke-width="2"> <fieldset><legend>Display</legend>{{ field(form.timezone) }}<div class="checkbox-field">{{ form.dark_mode }}{{ form.dark_mode.label }}</div></fieldset>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /> <div class="form-actions"><button class="button primary" type="submit">Save settings</button><a href="{{ url_for('main.dashboard') }}">Back to overview</a></div></form></div>{% endblock %}
</svg>
<span>Back</span>
</a>
<h1 class="text-2xl font-bold mb-4 text-center">Profile Settings</h1>
<div class="bg-white p-6 rounded-lg shadow-md mb-6">
<div class="flex items-center justify-center mb-4">
<img src="{{ url_for('user.profile_image', user_id=current_user.id) }}" alt="Profile Picture"
class="w-32 h-32 rounded-full border object-cover shadow">
</div>
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="mb-4">
{{ form.name.label(class="block text-sm font-medium text-gray-700") }}
{{ form.name(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
</div>
<div class="mb-4">
{{ form.email.label(class="block text-sm font-medium text-gray-700") }}
{{ form.email(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500") }}
</div>
<div class="mb-4">
{{ form.profile_pic.label(class="block text-sm font-medium text-gray-700") }}
{{ form.profile_pic(class="w-full p-2 border rounded focus:outline-none focus:ring-2
focus:ring-blue-500")
}}
</div>
<div class="mb-4">
{{ form.systolic_threshold.label(class="block text-sm font-medium text-gray-700") }}
{{ form.systolic_threshold(class="w-full p-2 border rounded focus:outline-none focus:ring-2
focus:ring-blue-500") }}
</div>
<div class="mb-4">
{{ form.diastolic_threshold.label(class="block text-sm font-medium text-gray-700") }}
{{ form.diastolic_threshold(class="w-full p-2 border rounded focus:outline-none focus:ring-2
focus:ring-blue-500") }}
</div>
<div class="mb-4">
{{ form.timezone.label(class="block text-sm font-medium text-gray-700") }}
{{ form.timezone(class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500")
}}
</div>
<div class="mb-4 flex items-center">
{{ form.dark_mode }}
{{ form.dark_mode.label(class="ml-2 text-sm font-medium text-gray-700") }}
</div>
<div>
{{ form.submit(class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700") }}
</div>
</form>
</div>
</div>
{% endblock %}
+12 -71
View File
@@ -1,74 +1,15 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% from 'components.html' import field %}
{% block title %}{{ 'Edit' if reading is defined else 'Add' }} reading · BP Tracker{% endblock %}
{% block content %} {% block content %}
<div class="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-md relative"> <div class="form-page"><a class="back-link" href="{{ url_for('main.dashboard') }}">← Back to overview</a><div class="page-heading"><div><p class="eyebrow">ONE READING AT A TIME</p><h1>{{ 'Edit reading' if reading is defined else 'Add reading' }}</h1><p class="muted">Enter the values shown on your monitor.</p></div></div>
<!-- Cancel Button (Top-Left) --> <form method="post" class="card entry-form" novalidate>
<a href="{{ request.referrer if request.referrer else url_for('main.dashboard') }}"
class="absolute top-5 left-4 flex items-center text-gray-600 hover:text-gray-800">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
<span>Back</span>
</a>
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">Add Reading</h1>
<form method="POST" action="{{ url_for('reading.add_reading') }}" novalidate class="space-y-6">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{% if form.errors %}<div class="notice notice-error" role="alert">Please check the highlighted fields.{% if form.csrf_token is defined %}{% for error in form.csrf_token.errors %} {{ error }}{% endfor %}{% endif %}</div>{% endif %}
<!-- Timestamp Field --> <fieldset><legend>Blood pressure <span class="muted small">mmHg</span></legend><div class="pressure-fields">{{ field(form.systolic, true) }}<span class="entry-slash" aria-hidden="true">/</span>{{ field(form.diastolic, true) }}</div></fieldset>
<div> {{ field(form.heart_rate, true) }}
{{ form.timestamp.label(class="block text-sm font-medium text-gray-700 mb-2") }} <div class="timestamp-field">{{ field(form.timestamp) }}<p class="muted small">Local time · {{ current_user.profile.timezone if current_user.profile else 'UTC' }}</p></div>
{{ form.timestamp(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none <div class="form-actions"><button class="button primary" name="action" value="save" type="submit">Save reading</button>{% if reading is not defined %}<button class="button" name="action" value="another" type="submit">Save & add another</button>{% endif %}<a href="{{ url_for('main.dashboard') }}">Cancel</a></div>
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }} {% if reading is defined %}<div class="delete-link"><a href="{{ url_for('reading.confirm_delete', reading_id=reading.id) }}">Delete this reading</a></div>{% endif %}
{% for error in form.timestamp.errors %} </form><p class="form-hint">Your readings stay editable in History.</p></div>
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Systolic Field -->
<div>
{{ form.systolic.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.systolic(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.systolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Diastolic Field -->
<div>
{{ form.diastolic.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.diastolic(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.diastolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Heart Rate Field -->
<div>
{{ form.heart_rate.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.heart_rate(class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none
focus:ring-2 focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.heart_rate.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Buttons -->
<div class="flex justify-between items-center space-x-6">
<!-- Cancel Button -->
<a href="{{ url_for('main.dashboard') }}" class="w-full bg-gray-100 text-gray-700 py-3 rounded-lg font-semibold
hover:bg-gray-200 hover:text-gray-900 shadow-md text-center transition">
Cancel
</a>
<!-- Save Button -->
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold
hover:bg-blue-700 shadow-lg text-center transition">
Save
</button>
</div>
</form>
</div>
{% endblock %} {% endblock %}
+3 -40
View File
@@ -1,40 +1,3 @@
{% extends "_layout.html" %} {% extends '_layout.html' %}
{% block title %}Delete reading · BP Tracker{% endblock %}
{% block content %} {% block content %}<div class="form-page"><div class="page-heading"><div><p class="eyebrow">READING DETAILS</p><h1>Delete this reading?</h1><p class="muted">This permanently removes the reading from your history.</p></div></div><section class="card"><p class="big-number">{{ reading.systolic }} / {{ reading.diastolic }} <span class="unit">mmHg</span></p><p>{{ reading.heart_rate }} bpm · {{ reading.local_timestamp.strftime('%d %b %Y, %I:%M %p') }}</p><form method="post" action="{{ url_for('reading.delete_reading', reading_id=reading.id) }}" class="form-actions">{{ form.hidden_tag() }}<button class="button danger" type="submit">Delete reading</button><a class="button" href="{{ url_for('reading.edit_reading', reading_id=reading.id) }}">Keep reading</a></form></section></div>{% endblock %}
<div class="max-w-lg mx-auto p-4 relative">
<!-- Cancel Button (Top-Left) -->
<a href="{{ request.referrer if request.referrer else url_for('main.dashboard') }}"
class="absolute top-5 left-4 flex items-center text-gray-600 hover:text-gray-800">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
<span>Back</span>
</a>
<h2 class="text-2xl font-bold mb-4 text-center">Confirm Deletion</h2>
<div class="bg-white p-8 rounded-lg shadow-md">
<p class="text-sm text-gray-600 mt-2">Are you sure you want to delete the following reading?</p>
<!-- Reading Details -->
<div class="mt-4 p-4 bg-gray-100 rounded">
<p><strong>Timestamp:</strong> {{ reading.timestamp.strftime('%d %b %Y, %I:%M %p') }}</p>
<p><strong>Systolic:</strong> {{ reading.systolic }} mmHg</p>
<p><strong>Diastolic:</strong> {{ reading.diastolic }} mmHg</p>
<p><strong>Heart Rate:</strong> {{ reading.heart_rate }} bpm</p>
</div>
<!-- Confirmation Buttons -->
<div class="mt-6 flex justify-end space-x-2">
<a href="{{ url_for('main.dashboard') }}"
class="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400">
Cancel
</a>
<form method="POST" action="{{ url_for('reading.delete_reading', reading_id=reading.id) }}">
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
Confirm
</button>
</form>
</div>
</div>
</div>
{% endblock %}
+1 -79
View File
@@ -1,79 +1 @@
{% extends "_layout.html" %} {% extends 'reading/add_reading.html' %}
{% block content %}
<div class="max-w-lg mx-auto p-4 relative">
<!-- Cancel Button (Top-Left) -->
<a href="{{ url_for('main.dashboard') }}"
class="absolute top-5 left-4 flex items-center text-gray-600 hover:text-gray-800">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
<span>Back</span>
</a>
<!-- Delete Button (Top-Right) -->
<a href="{{ url_for('reading.confirm_delete', reading_id=reading.id) }}"
class="absolute top-4 right-4 text-red-500 hover:text-red-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</a>
<h1 class="text-2xl font-bold mb-4 text-center">Edit Reading</h1>
<form method="POST" action="{{ url_for('reading.edit_reading', reading_id=reading.id) }}" novalidate
class="bg-white p-8 rounded-lg shadow-md">
{{ form.hidden_tag() }}
<!-- Timestamp Field -->
<div class="mb-4">
{{ form.timestamp.label(class="block text-sm font-medium text-gray-700") }}
{{ form.timestamp(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.timestamp.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Systolic Field -->
<div class="mb-4">
{{ form.systolic.label(class="block text-sm font-medium text-gray-700") }}
{{ form.systolic(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.systolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Diastolic Field -->
<div class="mb-4">
{{ form.diastolic.label(class="block text-sm font-medium text-gray-700") }}
{{ form.diastolic(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.diastolic.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Heart Rate Field -->
<div class="mb-4">
{{ form.heart_rate.label(class="block text-sm font-medium text-gray-700") }}
{{ form.heart_rate(class="w-full p-3 border rounded-lg shadow-sm focus:outline-none focus:ring-2
focus:ring-blue-500 focus:border-blue-500") }}
{% for error in form.heart_rate.errors %}
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
<!-- Save Button -->
<div class="mt-6">
<button type="submit"
class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
Save Changes
</button>
</div>
</form>
</div>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends '_layout.html' %}
{% block title %}Printable summary · BP Tracker{% endblock %}
{% block content %}<div class="page-heading"><div><p class="eyebrow">YOUR RECORD</p><h1>Blood pressure summary</h1><p class="muted">{{ selected.start.strftime('%d %b %Y') }} {{ selected.end.strftime('%d %b %Y') }} · {{ timezone_name }}</p><p>{{ current_user.profile.name or current_user.username }}</p></div><button class="button primary print-action" type="button" data-print hidden>Print / Save PDF</button></div><p class="muted small no-print">Use your browsers Print command to print this report or save it as a PDF.</p><section class="card report"><div class="section-heading"><h2>{{ stats.count }} readings</h2>{% if stats.count %}<p>Average <strong>{{ stats.systolic }} / {{ stats.diastolic }} mmHg</strong> · {{ stats.pulse }} bpm</p>{% endif %}</div><div class="table-scroll"><table><thead><tr><th scope="col">Local date & time</th><th scope="col">Systolic (mmHg)</th><th scope="col">Diastolic (mmHg)</th><th scope="col">Pulse (bpm)</th></tr></thead><tbody>{% for reading in readings %}<tr><td>{{ reading.local_timestamp.strftime('%d %b %Y, %I:%M %p %Z') }}</td><td>{{ reading.systolic }}</td><td>{{ reading.diastolic }}</td><td>{{ reading.heart_rate }}</td></tr>{% else %}<tr><td colspan="4">No readings in this period.</td></tr>{% endfor %}</tbody></table></div></section>{% endblock %}
+2 -2
View File
@@ -4,8 +4,8 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "npx tailwindcss -o ./app/static/css/tailwind.css --minify && npx terser ./app/static/js/diy-turbo.js -o ./app/static/js/diy-turbo.min.js -c -m", "build": "tailwindcss -i ./app/static/css/input.css -o ./app/static/css/tailwind.css --minify && terser ./app/static/js/diy-turbo.js -o ./app/static/js/diy-turbo.min.js -c -m && node scripts/check-assets.cjs",
"serve": "npx tailwindcss -o ./app/static/css/tailwind.css --minify --watch" "serve": "tailwindcss -i ./app/static/css/input.css -o ./app/static/css/tailwind.css --minify --watch"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
+9
View File
@@ -0,0 +1,9 @@
// Run after compiling. Decimal kB; gzip response-body sizes, without HTTP headers.
const fs = require('node:fs');
const zlib = require('node:zlib');
for (const [file, limit] of [['app/static/css/tailwind.css', 8000], ['app/static/js/diy-turbo.min.js', 5000]]) {
const bytes = fs.readFileSync(file);
const size = zlib.gzipSync(bytes).length;
console.log(`${file}: ${bytes.length} bytes raw, ${size} bytes gzip (budget ${limit})`);
if (size > limit) process.exitCode = 1;
}
+48
View File
@@ -0,0 +1,48 @@
"""Measure compressed page bodies using only an isolated synthetic database."""
import gzip
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.environ['FLASK_CONFIG'] = 'TestingConfig'
from app.config import TestingConfig
TestingConfig.SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
from app import create_app, db
from app.models import User, Profile, Reading
app = create_app()
results = {}
with app.app_context():
db.create_all()
db.session.add(User(id=1, username='size-fixture', password_hash='unused', profile=Profile(timezone='Australia/Sydney')))
db.session.commit()
client = app.test_client()
with client.session_transaction() as session:
session['_user_id'] = '1'
session['_fresh'] = True
for filename in ('css/tailwind.css', 'js/diy-turbo.min.js', 'images/favicon.svg'):
payload = (ROOT / 'app/static' / filename).read_bytes()
results[filename] = {'raw_bytes': len(payload), 'gzip_bytes': len(gzip.compress(payload))}
for name, count in [('empty', 0), ('typical', 120), ('large', 10000)]:
db.session.query(Reading).filter_by(user_id=1).delete()
anchor = datetime(2026, 9, 8, 0, 0)
db.session.bulk_save_objects([Reading(user_id=1, timestamp=anchor - timedelta(hours=i * 5),
systolic=110 + i % 40, diastolic=65 + i % 25, heart_rate=60 + i % 30) for i in range(count)])
db.session.commit()
for view in ('overview', 'history', 'trends'):
dates = 'start_date=2020-01-01&end_date=2026-09-08' if name == 'large' and view == 'trends' else 'start_date=2026-09-02&end_date=2026-09-08'
payload = client.get(f'/dashboard?view={view}&{dates}')
assert payload.status_code == 200
size = len(gzip.compress(payload.data))
assert size <= 20000, (name, view, size)
results[f'{name}_{view}'] = {'raw_bytes': len(payload.data), 'gzip_bytes': size}
shared = sum(results[key]['gzip_bytes'] for key in ('css/tailwind.css', 'js/diy-turbo.min.js', 'images/favicon.svg'))
results['typical_cold_overview_bytes'] = shared + results['typical_overview']['gzip_bytes']
assert results['typical_cold_overview_bytes'] <= 40000
db.session.remove()
db.drop_all()
print(json.dumps(results, indent=2))
+32
View File
@@ -0,0 +1,32 @@
"""Run the redesign with disposable, synthetic data: python scripts/preview.py."""
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.environ['FLASK_CONFIG'] = 'TestingConfig'
from app.config import TestingConfig
TestingConfig.SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
from app import create_app, db
from app.models import Profile, Reading, User
from werkzeug.security import generate_password_hash
app = create_app()
with app.app_context():
db.create_all()
user = User(username='demo', password_hash=generate_password_hash('preview-only'),
profile=Profile(name='Alex Morgan', timezone='Australia/Sydney'))
db.session.add(user)
db.session.flush()
now = datetime.utcnow().replace(minute=30, second=0, microsecond=0)
db.session.add_all([Reading(user_id=user.id, timestamp=now - timedelta(hours=12 * i),
systolic=122 + ((i * 7) % 19), diastolic=74 + ((i * 3) % 12), heart_rate=62 + i % 14)
for i in range(120)])
db.session.commit()
if __name__ == '__main__':
print('Synthetic preview: http://127.0.0.1:5055 — demo / preview-only', flush=True)
app.run(host='127.0.0.1', port=5055, use_reloader=False, debug=False)
+38
View File
@@ -0,0 +1,38 @@
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.environ['FLASK_CONFIG'] = 'TestingConfig'
from app import create_app, db
from app.config import TestingConfig
from app.models import Profile, User
@pytest.fixture
def app(monkeypatch):
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
monkeypatch.setattr(TestingConfig, 'WTF_CSRF_ENABLED', False, raising=False)
application = create_app()
with application.app_context():
db.create_all()
db.session.add_all([
User(id=1, username='fixture', password_hash='unused', profile=Profile(timezone='Australia/Sydney')),
User(id=2, username='other', password_hash='unused', profile=Profile(timezone='UTC')),
])
db.session.commit()
yield application
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
client = app.test_client()
with client.session_transaction() as session:
session['_user_id'] = '1'
session['_fresh'] = True
return client
+197
View File
@@ -0,0 +1,197 @@
import csv
import gzip
import io
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from pytz import timezone
from app import db
from app.insights import parse_range, readings_query, chart_data, summary
from app.models import Reading, Profile
def add(timestamp, systolic=120, user_id=1, diastolic=75, heart_rate=65):
item = Reading(user_id=user_id, timestamp=timestamp, systolic=systolic,
diastolic=diastolic, heart_rate=heart_rate)
db.session.add(item)
db.session.commit()
return item
@pytest.mark.parametrize('url', ['/', '/dashboard', '/dashboard?view=history', '/dashboard?view=trends',
'/dashboard?view=history&mode=calendar', '/reading/add', '/user/profile', '/data/',
'/dashboard/report', '/auth/login', '/auth/signup'])
def test_pages_render(client, url):
response = client.get(url, follow_redirects=True)
assert response.status_code == 200
assert 'BP Tracker' in response.text
assert 'private, no-store' == response.headers['Cache-Control']
def test_empty_state(client):
response = client.get('/dashboard')
assert 'No readings yet' in response.text
assert '0 / 0' not in response.text
assert 'hx-trigger' not in response.text
@pytest.mark.parametrize('day,hours', [('2026-10-04', 23), ('2026-04-05', 25)])
def test_range_includes_entire_local_day_excludes_next_midnight(app, day, hours):
selected = parse_range({'start_date': day, 'end_date': day}, timezone('Australia/Sydney'))
assert (selected.end_utc - selected.start_utc).total_seconds() == hours * 3600
add(selected.start_utc - timedelta(seconds=1))
add(selected.start_utc, 110)
add(selected.end_utc - timedelta(microseconds=1), 130)
add(selected.end_utc, 160)
add(selected.start_utc, 200, user_id=2)
stats = summary(readings_query(1, selected))
assert stats['count'] == 2
assert stats['systolic'] == 120
@pytest.mark.parametrize('query', ['start_date=no&end_date=no', 'start_date=2026-09-01',
'start_date=2026-09-08&end_date=2026-09-01', 'days=all', 'days=-1',
'start_date=0001-01-01&end_date=9999-12-31'])
def test_invalid_range_is_actionable(client, query):
assert client.get('/dashboard?' + query).status_code == 400
assert client.get('/data/export?' + query).status_code == 400
def test_save_another_and_validation_preserve_timestamp(client):
values = dict(timestamp='2026-09-08T08:15', systolic='121', diastolic='77', heart_rate='67', action='another')
result = client.post('/reading/add', data=values, follow_redirects=True)
assert result.status_code == 200
assert result.request.path == '/reading/add'
assert 'Reading added successfully.' in result.text
assert 'class="notifications"' in result.text
assert 'value="121"' not in result.text
saved = Reading.query.one()
assert saved.timestamp == datetime(2026, 9, 7, 22, 15)
values['systolic'] = '999'
result = client.post('/reading/add', data=values)
assert result.status_code == 200
assert '2026-09-08T08:15' in result.text
assert 'value="999"' in result.text
assert Reading.query.count() == 1
def test_edit_timestamp_and_delete(client):
item = add(datetime(2026, 9, 7, 22))
result = client.post(f'/reading/{item.id}/edit', data=dict(timestamp='2026-09-08T09:20', systolic=125, diastolic=80, heart_rate=70))
assert result.status_code == 302
assert db.session.get(Reading, item.id).timestamp == datetime(2026, 9, 7, 23, 20)
assert client.get(f'/reading/{item.id}/confirm_delete').status_code == 200
result = client.post(f'/reading/{item.id}/delete')
assert result.status_code == 302
assert Reading.query.count() == 0
def test_user_isolation_and_export(client):
add(datetime(2026, 9, 7, 22), 123)
other = add(datetime(2026, 9, 7, 22), 234, user_id=2)
add(datetime(2026, 9, 8, 14), 190) # Midnight the following day in Sydney.
query = 'start_date=2026-09-08&end_date=2026-09-08'
response = client.get('/data/export?' + query)
assert response.status_code == 200
rows = list(csv.reader(io.StringIO(response.text)))
assert len(rows) == 2
assert rows[1] == ['2026-09-07 22:00:00', '123', '75', '65']
assert 'attachment' in response.headers['Content-Disposition']
report = client.get('/dashboard/report?' + query)
assert report.status_code == 200
assert '234' not in report.text and '190</td>' not in report.text
for suffix in ('edit', 'confirm_delete'):
assert client.get(f'/reading/{other.id}/{suffix}').status_code == 404
assert client.post(f'/reading/{other.id}/delete').status_code == 404
def test_saved_preferences(client):
profile = Profile.query.filter_by(user_id=1).one()
profile.systolic_threshold = 150
profile.diastolic_threshold = 100
profile.dark_mode = True
db.session.commit()
add(datetime(2026, 9, 7, 22), 140, diastolic=90)
response = client.get('/dashboard?view=history&start_date=2026-09-08&end_date=2026-09-08')
assert 'data-theme="dark"' in response.text
assert 'Below threshold' in response.text
assert '150/100' in response.text
def test_large_history_and_payload_budgets(client):
start = datetime(2020, 1, 1)
db.session.bulk_save_objects([Reading(user_id=1, timestamp=start + timedelta(hours=i * 5),
systolic=110 + i % 40, diastolic=65 + i % 25, heart_rate=60 + i % 30) for i in range(10_000)])
db.session.commit()
selected = parse_range({'start_date': '2020-01-01', 'end_date': '2026-09-08'}, timezone('Australia/Sydney'))
query = readings_query(1, selected)
chart = chart_data(query, selected)
assert len(chart['points']) <= 180
assert sum(p['count'] for p in chart['points']) == 10_000
assert summary(query)['count'] == 10_000
history = client.get('/dashboard?view=history&start_date=2020-01-01&end_date=2026-09-08')
assert history.text.count('class="edit-link"') == 25
assert 'Page 1 of 400' in history.text
urls = ['/dashboard', '/dashboard?view=trends&start_date=2020-01-01&end_date=2026-09-08',
'/dashboard?view=history&mode=calendar&anchor=2020-01-01']
for url in urls:
response = client.get(url)
assert response.status_code == 200
assert len(gzip.compress(response.data)) <= 20_000
static = Path(__file__).resolve().parents[1] / 'app' / 'static'
css_size = len(gzip.compress((static / 'css/tailwind.css').read_bytes()))
js_size = len(gzip.compress((static / 'js/diy-turbo.min.js').read_bytes()))
assert css_size <= 8_000
assert js_size <= 5_000
assert len(gzip.compress(client.get('/dashboard').data)) + css_size + js_size + 276 <= 40_000
def test_compression_and_static_cache(client):
response = client.get('/dashboard', headers={'Accept-Encoding': 'gzip'})
assert response.headers['Content-Encoding'] == 'gzip'
assert b'Overview' in gzip.decompress(response.data)
import re
css = re.search(r'href="([^"]+tailwind.css[^"]+)"', client.get('/dashboard').text).group(1)
response = client.get(css.replace('&amp;', '&'))
assert 'immutable' in response.headers['Cache-Control']
def test_import_validation_and_atomicity(client):
payload = b'Timestamp,Systolic,Diastolic,Heart Rate\n2026-09-07 22:00:00,120,75,65\n2026-09-07 23:00:00,999,75,65\n'
response = client.post('/data/', data={'file': (io.BytesIO(payload), 'readings.csv')}, follow_redirects=True)
assert 'Check the measurement values on row 3' in response.text
assert Reading.query.count() == 0
payload = payload.split(b'2026-09-07 23:00:00')[0]
response = client.post('/data/', data={'file': (io.BytesIO(payload), 'readings.csv')}, follow_redirects=True)
assert 'Data imported successfully' in response.text
assert Reading.query.count() == 1
@pytest.mark.parametrize('local_time', ['2026-10-04T02:30', '2026-04-05T02:30'])
def test_ambiguous_or_nonexistent_entry_time(client, local_time):
response = client.post('/reading/add', data=dict(timestamp=local_time, systolic=120, diastolic=75, heart_rate=65))
assert response.status_code == 200
assert 'daylight saving' in response.text
assert Reading.query.count() == 0
def test_csrf_required_for_saves_deletes_and_imports(app, client):
app.config['WTF_CSRF_ENABLED'] = True
item = add(datetime(2026, 9, 7, 22))
response = client.post('/reading/add', data=dict(timestamp='2026-09-08T09:00', systolic=120, diastolic=75, heart_rate=65))
assert 'CSRF token is missing' in response.text
client.post(f'/reading/{item.id}/delete')
client.post('/data/', data={'file': (io.BytesIO(b'Timestamp,Systolic,Diastolic,Heart Rate\n2026-09-07 22:00:00,120,75,65\n'), 'readings.csv')})
assert Reading.query.count() == 1
@pytest.mark.parametrize('url,fragment', [('/dashboard/list', 'view=history'),
('/dashboard/table', 'view=history'), ('/dashboard/graph', 'view=trends'),
('/dashboard/weekly', 'unit=week'), ('/dashboard/monthly', 'unit=month')])
def test_old_bookmarks_still_work(client, url, fragment):
response = client.get(url)
assert response.status_code == 302
assert fragment in response.location
assert client.get(response.location).status_code == 200