Redesign BP tracker with lightweight views and unobtrusive save confirmations
This commit is contained in:
+79
-57
@@ -1,57 +1,79 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_login import LoginManager
|
||||
from flask_compress import Compress
|
||||
from flask_minify import Minify
|
||||
|
||||
# Initialize Flask extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
bcrypt = Bcrypt()
|
||||
login_manager = LoginManager()
|
||||
compress = Compress()
|
||||
minify = Minify(html=True, js=True, cssless=True, fail_safe=True)
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
config_name = os.getenv('FLASK_CONFIG', 'DevelopmentConfig') # Default to DevelopmentConfig
|
||||
app.config.from_object(f'app.config.{config_name}')
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
bcrypt.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
compress.init_app(app)
|
||||
minify.init_app(app)
|
||||
|
||||
|
||||
# Import models here to avoid circular imports
|
||||
from app.models import User # Import the User model
|
||||
|
||||
# Set up the user_loader function
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth
|
||||
from app.routes.data import data
|
||||
from app.routes.main import main
|
||||
from app.routes.user import user
|
||||
from app.routes.reading import reading
|
||||
app.register_blueprint(main, url_prefix='/')
|
||||
app.register_blueprint(auth, url_prefix='/auth')
|
||||
app.register_blueprint(user, url_prefix='/user')
|
||||
app.register_blueprint(data, url_prefix="/data")
|
||||
app.register_blueprint(reading, url_prefix="/reading")
|
||||
|
||||
return app
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_login import LoginManager
|
||||
from flask_compress import Compress
|
||||
from flask_minify import Minify
|
||||
|
||||
# Initialize Flask extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
bcrypt = Bcrypt()
|
||||
login_manager = LoginManager()
|
||||
compress = Compress()
|
||||
minify = Minify(html=True, js=True, cssless=True, fail_safe=True)
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
config_name = os.getenv('FLASK_CONFIG', 'DevelopmentConfig') # Default to DevelopmentConfig
|
||||
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
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
bcrypt.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
compress.init_app(app)
|
||||
minify.init_app(app)
|
||||
|
||||
|
||||
# Import models here to avoid circular imports
|
||||
from app.models import User # Import the User model
|
||||
|
||||
# Set up the user_loader function
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth
|
||||
from app.routes.data import data
|
||||
from app.routes.main import main
|
||||
from app.routes.user import user
|
||||
from app.routes.reading import reading
|
||||
app.register_blueprint(main, url_prefix='/')
|
||||
app.register_blueprint(auth, url_prefix='/auth')
|
||||
app.register_blueprint(user, url_prefix='/user')
|
||||
app.register_blueprint(data, url_prefix="/data")
|
||||
app.register_blueprint(reading, url_prefix="/reading")
|
||||
|
||||
return app
|
||||
|
||||
+113
-102
@@ -1,102 +1,113 @@
|
||||
from typing import Optional
|
||||
from flask_wtf import FlaskForm
|
||||
from pytz import all_timezones
|
||||
from wtforms import BooleanField, FileField, SelectField, StringField, PasswordField, SubmitField, IntegerField, DateTimeLocalField
|
||||
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError, Email, Optional, NumberRange
|
||||
from app.models import User
|
||||
from datetime import datetime
|
||||
|
||||
class SignupForm(FlaskForm):
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Length(min=4, max=20, message="Username must be between 4 and 20 characters.")
|
||||
]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Length(min=6, message="Password must be at least 6 characters long.")
|
||||
]
|
||||
)
|
||||
confirm_password = PasswordField(
|
||||
'Confirm Password',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message="Passwords must match.")
|
||||
]
|
||||
)
|
||||
submit = SubmitField('Sign Up')
|
||||
|
||||
# Custom validator to check if username is already taken
|
||||
def validate_username(self, username):
|
||||
user = User.query.filter_by(username=username.data).first()
|
||||
if user:
|
||||
raise ValidationError("Username is already taken. Please choose a different one.")
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[DataRequired(message="Username is required.")]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[DataRequired(message="Password is required.")]
|
||||
)
|
||||
submit = SubmitField('Login')
|
||||
|
||||
|
||||
class ReadingForm(FlaskForm):
|
||||
timestamp = DateTimeLocalField(
|
||||
'Timestamp',
|
||||
format='%Y-%m-%dT%H:%M',
|
||||
default=datetime.now, # Set default to current time
|
||||
validators=[DataRequired(message="Timestamp is required.")]
|
||||
)
|
||||
systolic = IntegerField(
|
||||
'Systolic (mmHg)',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=50, max=250, message="Systolic pressure must be between 50 and 250 mmHg.")
|
||||
]
|
||||
)
|
||||
diastolic = IntegerField(
|
||||
'Diastolic (mmHg)',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=30, max=150, message="Diastolic pressure must be between 30 and 150 mmHg.")
|
||||
]
|
||||
)
|
||||
heart_rate = IntegerField(
|
||||
'Heart Rate (bpm)',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=30, max=200, message="Heart rate must be between 30 and 200 bpm.")
|
||||
]
|
||||
)
|
||||
submit = SubmitField('Save Reading')
|
||||
|
||||
class DeleteForm(FlaskForm):
|
||||
submit = SubmitField('Delete')
|
||||
|
||||
class ProfileForm(FlaskForm):
|
||||
name = StringField('Name', validators=[Optional()])
|
||||
email = StringField('Email', validators=[Optional(), Email()])
|
||||
profile_pic = FileField('Profile Picture (optional)')
|
||||
timezone = SelectField('Timezone', choices=[(tz, tz) for tz in all_timezones])
|
||||
systolic_threshold = IntegerField(
|
||||
'Systolic Threshold (mmHg)',
|
||||
validators=[Optional(), NumberRange(min=90, max=200)]
|
||||
)
|
||||
diastolic_threshold = IntegerField(
|
||||
'Diastolic Threshold (mmHg)',
|
||||
validators=[Optional(), NumberRange(min=60, max=120)]
|
||||
)
|
||||
dark_mode = BooleanField('Enable Dark Mode')
|
||||
submit = SubmitField('Save Settings')
|
||||
|
||||
|
||||
|
||||
from typing import Optional
|
||||
from flask_wtf import FlaskForm
|
||||
from pytz import all_timezones
|
||||
from wtforms import BooleanField, FileField, SelectField, StringField, PasswordField, SubmitField, IntegerField, DateTimeLocalField
|
||||
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError, Email, Optional, NumberRange
|
||||
from app.models import User
|
||||
from datetime import datetime
|
||||
|
||||
class SignupForm(FlaskForm):
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Length(min=4, max=20, message="Username must be between 4 and 20 characters.")
|
||||
]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Length(min=6, message="Password must be at least 6 characters long.")
|
||||
]
|
||||
)
|
||||
confirm_password = PasswordField(
|
||||
'Confirm Password',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message="Passwords must match.")
|
||||
]
|
||||
)
|
||||
submit = SubmitField('Sign Up')
|
||||
|
||||
# Custom validator to check if username is already taken
|
||||
def validate_username(self, username):
|
||||
user = User.query.filter_by(username=username.data).first()
|
||||
if user:
|
||||
raise ValidationError("Username is already taken. Please choose a different one.")
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField(
|
||||
'Username',
|
||||
validators=[DataRequired(message="Username is required.")]
|
||||
)
|
||||
password = PasswordField(
|
||||
'Password',
|
||||
validators=[DataRequired(message="Password is required.")]
|
||||
)
|
||||
submit = SubmitField('Login')
|
||||
|
||||
|
||||
class ReadingForm(FlaskForm):
|
||||
timestamp = DateTimeLocalField(
|
||||
'Timestamp',
|
||||
format='%Y-%m-%dT%H:%M',
|
||||
default=datetime.now, # Set default to current time
|
||||
validators=[DataRequired(message="Timestamp is required.")]
|
||||
)
|
||||
systolic = IntegerField(
|
||||
'Systolic (mmHg)',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=50, max=250, message="Systolic pressure must be between 50 and 250 mmHg.")
|
||||
]
|
||||
)
|
||||
diastolic = IntegerField(
|
||||
'Diastolic (mmHg)',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=30, max=150, message="Diastolic pressure must be between 30 and 150 mmHg.")
|
||||
]
|
||||
)
|
||||
heart_rate = IntegerField(
|
||||
'Heart Rate (bpm)',
|
||||
validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=30, max=200, message="Heart rate must be between 30 and 200 bpm.")
|
||||
]
|
||||
)
|
||||
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):
|
||||
submit = SubmitField('Delete')
|
||||
|
||||
class ProfileForm(FlaskForm):
|
||||
name = StringField('Name', validators=[Optional()])
|
||||
email = StringField('Email', validators=[Optional(), Email()])
|
||||
profile_pic = FileField('Profile Picture (optional)')
|
||||
timezone = SelectField('Timezone', choices=[(tz, tz) for tz in all_timezones])
|
||||
systolic_threshold = IntegerField(
|
||||
'Systolic Threshold (mmHg)',
|
||||
validators=[Optional(), NumberRange(min=90, max=200)]
|
||||
)
|
||||
diastolic_threshold = IntegerField(
|
||||
'Diastolic Threshold (mmHg)',
|
||||
validators=[Optional(), NumberRange(min=60, max=120)]
|
||||
)
|
||||
dark_mode = BooleanField('Enable Dark Mode')
|
||||
submit = SubmitField('Save Settings')
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""Shared, timezone-aware ranges and bounded chart summaries."""
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from math import ceil
|
||||
|
||||
from flask_login import current_user
|
||||
from pytz import timezone, utc
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models import Reading
|
||||
|
||||
|
||||
def user_timezone():
|
||||
profile = current_user.profile
|
||||
return timezone(profile.timezone if profile and profile.timezone else 'UTC')
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateRange:
|
||||
start: date
|
||||
end: date
|
||||
tz: object
|
||||
|
||||
@property
|
||||
def start_utc(self):
|
||||
return self.tz.localize(datetime.combine(self.start, time.min)).astimezone(utc).replace(tzinfo=None)
|
||||
|
||||
@property
|
||||
def end_utc(self):
|
||||
# Localize each midnight independently: a local day may be 23 or 25 hours.
|
||||
return self.tz.localize(datetime.combine(self.end + timedelta(days=1), time.min)).astimezone(utc).replace(tzinfo=None)
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
return {'start_date': self.start.isoformat(), 'end_date': self.end.isoformat()}
|
||||
|
||||
|
||||
def parse_range(args, tz, default_days=7):
|
||||
today = datetime.now(tz).date()
|
||||
start, end = args.get('start_date'), args.get('end_date')
|
||||
if start or end:
|
||||
try:
|
||||
selected = DateRange(date.fromisoformat(start), date.fromisoformat(end), tz)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('Choose a valid start and end date.')
|
||||
else:
|
||||
try:
|
||||
days = int(args.get('days', default_days))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('Choose a 7, 30 or 90 day period.')
|
||||
if days not in (7, 30, 90):
|
||||
raise ValueError('Choose a 7, 30 or 90 day period.')
|
||||
selected = DateRange(today - timedelta(days=days - 1), today, tz)
|
||||
if selected.start > selected.end:
|
||||
raise ValueError('Start date must be on or before end date.')
|
||||
if selected.start.year < 1900 or selected.end.year > 2100:
|
||||
raise ValueError('Choose dates between 1900 and 2100.')
|
||||
return selected
|
||||
|
||||
|
||||
def readings_query(user_id, selected=None):
|
||||
query = Reading.query.filter_by(user_id=user_id)
|
||||
if selected:
|
||||
query = query.filter(Reading.timestamp >= selected.start_utc, Reading.timestamp < selected.end_utc)
|
||||
return query
|
||||
|
||||
|
||||
def summary(query):
|
||||
row = query.with_entities(func.count(Reading.id), func.avg(Reading.systolic),
|
||||
func.avg(Reading.diastolic), func.avg(Reading.heart_rate)).one()
|
||||
return dict(count=row[0], systolic=round(row[1], 1) if row[0] else None,
|
||||
diastolic=round(row[2], 1) if row[0] else None,
|
||||
pulse=round(row[3], 1) if row[0] else None)
|
||||
|
||||
|
||||
def annotate(readings, tz):
|
||||
for reading in readings:
|
||||
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(tz)
|
||||
return readings
|
||||
|
||||
|
||||
def chart_data(query, selected):
|
||||
"""Stream raw values into <=180 buckets; never average bucket averages."""
|
||||
width = max(1, ceil(((selected.end - selected.start).days + 1) / 180))
|
||||
buckets = {}
|
||||
rows = query.with_entities(Reading.timestamp, Reading.systolic, Reading.diastolic,
|
||||
Reading.heart_rate).yield_per(500)
|
||||
for ts, systolic, diastolic, pulse in rows:
|
||||
day = utc.localize(ts).astimezone(selected.tz).date()
|
||||
index = (day - selected.start).days // width
|
||||
sums = buckets.setdefault(index, [0, 0, 0, 0])
|
||||
for i, value in enumerate((systolic, diastolic, pulse, 1)):
|
||||
sums[i] += value
|
||||
points = []
|
||||
span = (selected.end - selected.start).days
|
||||
for index, (sys, dia, pulse, count) in sorted(buckets.items()):
|
||||
day = selected.start + timedelta(days=index * width)
|
||||
end = min(day + timedelta(days=width - 1), selected.end)
|
||||
values = [round(v / count, 1) for v in (sys, dia, pulse)]
|
||||
x = round(48 + (day - selected.start).days / span * 704, 1) if span else 400
|
||||
points.append(dict(date=day.isoformat(), end=end.isoformat(), count=count,
|
||||
systolic=values[0], diastolic=values[1], pulse=values[2], x=x))
|
||||
paths = []
|
||||
for key, label in [('systolic', 'Systolic'), ('diastolic', 'Diastolic'), ('pulse', 'Pulse')]:
|
||||
coords = [(p['x'], round(244 - p[key] / 260 * 208, 1)) for p in points]
|
||||
paths.append(dict(key=key, label=label, coordinates=coords,
|
||||
path=' '.join(f'{"M" if i == 0 else "L"}{x},{y}' for i, (x, y) in enumerate(coords))))
|
||||
return dict(points=points, paths=paths, bucket_days=width)
|
||||
+97
-77
@@ -1,77 +1,97 @@
|
||||
import csv
|
||||
from io import StringIO
|
||||
from flask import Blueprint, render_template, redirect, request, send_file, url_for, flash
|
||||
from app.models import Reading, db
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
data = Blueprint('data', __name__)
|
||||
|
||||
@data.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manage_data():
|
||||
if request.method == 'POST':
|
||||
# Handle CSV file upload
|
||||
file = request.files.get('file')
|
||||
if file and file.filename.endswith('.csv'):
|
||||
try:
|
||||
csv_data = csv.reader(StringIO(file.read().decode('utf-8')))
|
||||
next(csv_data) # Skip the header row
|
||||
readings_to_add = []
|
||||
for row in csv_data:
|
||||
timestamp, systolic, diastolic, heart_rate = row
|
||||
readings_to_add.append(Reading(
|
||||
user_id=current_user.id,
|
||||
timestamp=datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S'),
|
||||
systolic=int(systolic),
|
||||
diastolic=int(diastolic),
|
||||
heart_rate=int(heart_rate),
|
||||
))
|
||||
db.session.bulk_save_objects(readings_to_add)
|
||||
db.session.commit()
|
||||
flash('Data imported successfully!', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Error importing data: {str(e)}', 'danger')
|
||||
else:
|
||||
flash('Please upload a valid CSV file.', 'danger')
|
||||
return redirect(url_for('data.manage_data'))
|
||||
|
||||
return render_template('data.html')
|
||||
|
||||
@data.route('/export', methods=['GET'])
|
||||
@login_required
|
||||
def export_data():
|
||||
import io
|
||||
from flask import Response
|
||||
|
||||
def generate_csv():
|
||||
"""Stream CSV rows to avoid loading all readings into memory."""
|
||||
# Write header
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# Stream readings in chunks using yield_per
|
||||
readings = Reading.query.filter_by(user_id=current_user.id).order_by(
|
||||
Reading.timestamp
|
||||
).yield_per(500)
|
||||
|
||||
for reading in readings:
|
||||
writer.writerow([
|
||||
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.heart_rate,
|
||||
])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
return Response(
|
||||
generate_csv(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename=readings.csv'}
|
||||
)
|
||||
import csv
|
||||
from io import StringIO
|
||||
from flask import Blueprint, render_template, redirect, request, url_for, flash, stream_with_context
|
||||
from app.models import Reading, db
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
from app.forms import DeleteForm
|
||||
from app.insights import parse_range, readings_query, user_timezone
|
||||
|
||||
data = Blueprint('data', __name__)
|
||||
|
||||
@data.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manage_data():
|
||||
form = DeleteForm()
|
||||
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
|
||||
file = request.files.get('file')
|
||||
if file and file.filename.endswith('.csv'):
|
||||
try:
|
||||
payload = file.read(2_000_001)
|
||||
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 = []
|
||||
for line, row in enumerate(csv_data, 2):
|
||||
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(
|
||||
user_id=current_user.id,
|
||||
timestamp=datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S'),
|
||||
systolic=int(systolic),
|
||||
diastolic=int(diastolic),
|
||||
heart_rate=int(heart_rate),
|
||||
))
|
||||
db.session.bulk_save_objects(readings_to_add)
|
||||
db.session.commit()
|
||||
flash('Data imported successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error importing data: {str(e)}', 'danger')
|
||||
else:
|
||||
flash('Please upload a valid CSV file.', 'danger')
|
||||
return redirect(url_for('data.manage_data'))
|
||||
|
||||
return render_template('data.html', form=form)
|
||||
|
||||
@data.route('/export', methods=['GET'])
|
||||
@login_required
|
||||
def export_data():
|
||||
import io
|
||||
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():
|
||||
"""Stream CSV rows to avoid loading all readings into memory."""
|
||||
# Write header
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# Stream readings in chunks using yield_per
|
||||
readings = readings_query(user_id, selected).order_by(
|
||||
Reading.timestamp, Reading.id
|
||||
).yield_per(500)
|
||||
|
||||
for reading in readings:
|
||||
writer.writerow([
|
||||
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.heart_rate,
|
||||
])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate_csv()),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename=readings.csv'}
|
||||
)
|
||||
|
||||
+176
-347
@@ -1,347 +1,176 @@
|
||||
from collections import defaultdict
|
||||
from flask import Blueprint, render_template, redirect, request, url_for
|
||||
import humanize
|
||||
from pytz import timezone, utc
|
||||
from sqlalchemy import func
|
||||
from app.models import Reading, db
|
||||
from app.forms import DeleteForm
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
# Number of readings to show per page in list view
|
||||
PAGE_SIZE = 25
|
||||
|
||||
@main.route('/', methods=['GET'])
|
||||
def landing():
|
||||
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
|
||||
|
||||
@main.route('/health')
|
||||
def health():
|
||||
return "OK", 200
|
||||
|
||||
@main.route('/dashboard', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Render the dashboard shell and default list view."""
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
|
||||
# Calculate weekly averages via SQL
|
||||
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary_sql(current_user.id)
|
||||
|
||||
badges = calculate_progress_badges(current_user.id, user_tz)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
profile=current_user.profile,
|
||||
badges=badges,
|
||||
systolic_avg=systolic_avg,
|
||||
diastolic_avg=diastolic_avg,
|
||||
heart_rate_avg=heart_rate_avg,
|
||||
delete_form=DeleteForm(),
|
||||
active_view='list',
|
||||
)
|
||||
|
||||
@main.route('/dashboard/list', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_list():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
# List view is no longer constrained by date filter
|
||||
paginated = fetch_readings_paginated(current_user.id, None, None, user_tz, page, PAGE_SIZE)
|
||||
annotate_readings(paginated.items, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_list.html', readings=paginated.items, pagination=paginated)
|
||||
|
||||
@main.route('/dashboard/table', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_table():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz)
|
||||
|
||||
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
|
||||
end_date = request.args.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
paginated = fetch_readings_paginated(current_user.id, start_date, end_date, user_tz, page, PAGE_SIZE)
|
||||
annotate_readings(paginated.items, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_table.html', readings=paginated.items, pagination=paginated, start_date=start_date, end_date=end_date)
|
||||
|
||||
@main.route('/dashboard/weekly', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_weekly():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
week_offset = request.args.get('week_offset', 0, type=int)
|
||||
now = datetime.now(user_tz)
|
||||
|
||||
target_week_date = now + timedelta(weeks=week_offset)
|
||||
target_week_start = target_week_date - timedelta(days=target_week_date.weekday())
|
||||
target_week_start_utc = target_week_start.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
target_week_end_utc = (target_week_start + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, target_week_start_utc, target_week_end_utc)
|
||||
annotate_readings(calendar_readings, user_tz)
|
||||
readings_by_day = build_readings_by_day(calendar_readings, user_tz)
|
||||
week_view = generate_weekly_calendar(readings_by_day, target_week_date, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_weekly.html', week=week_view, week_offset=week_offset)
|
||||
|
||||
@main.route('/dashboard/monthly', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_monthly():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
month_offset = request.args.get('month_offset', 0, type=int)
|
||||
now = datetime.now(user_tz)
|
||||
|
||||
target_month_year = now.year + (now.month + month_offset - 1) // 12
|
||||
target_month_month = (now.month + month_offset - 1) % 12 + 1
|
||||
target_month_date = now.replace(year=target_month_year, month=target_month_month, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
first_day = target_month_date
|
||||
start_date = first_day - timedelta(days=(first_day.weekday() + 1) % 7)
|
||||
end_date = start_date + timedelta(days=42)
|
||||
|
||||
start_utc = start_date.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
end_utc = end_date.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(utc)
|
||||
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, start_utc, end_utc)
|
||||
annotate_readings(calendar_readings, user_tz)
|
||||
readings_by_day = build_readings_by_day(calendar_readings, user_tz)
|
||||
month_view = generate_monthly_calendar(readings_by_day, target_month_date, user_tz)
|
||||
|
||||
return render_template('partials/dashboard_monthly.html', month=month_view, target_month_date=target_month_date, month_offset=month_offset)
|
||||
|
||||
@main.route('/dashboard/graph', methods=['GET'])
|
||||
@login_required
|
||||
def dashboard_graph():
|
||||
user_tz = timezone(current_user.profile.timezone or 'UTC')
|
||||
first_reading, last_reading = get_reading_date_range(current_user.id, user_tz)
|
||||
|
||||
start_date = request.args.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
|
||||
end_date = request.args.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
|
||||
|
||||
if start_date and end_date:
|
||||
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc)
|
||||
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1)
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, start_dt, end_dt)
|
||||
else:
|
||||
now = datetime.now(user_tz)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
month_start_utc = month_start.astimezone(utc)
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, month_start_utc)
|
||||
|
||||
annotate_readings(calendar_readings, user_tz)
|
||||
|
||||
graph_data = prepare_graph_data(calendar_readings)
|
||||
graph_data['start_date'] = start_date
|
||||
graph_data['end_date'] = end_date
|
||||
|
||||
return render_template('partials/dashboard_graph.html', **graph_data)
|
||||
|
||||
def get_reading_date_range(user_id, user_tz):
|
||||
"""Fetch the earliest and latest reading timestamps for a user."""
|
||||
result = db.session.query(
|
||||
func.min(Reading.timestamp).label('first'),
|
||||
func.max(Reading.timestamp).label('last')
|
||||
).filter(Reading.user_id == user_id).first()
|
||||
|
||||
first = utc.localize(result.first).astimezone(user_tz) if result.first else None
|
||||
last = utc.localize(result.last).astimezone(user_tz) if result.last else None
|
||||
return first, last
|
||||
|
||||
|
||||
def fetch_readings_paginated(user_id, start_date, end_date, user_tz, page, per_page):
|
||||
"""Retrieve paginated readings filtered by date range."""
|
||||
query = Reading.query.filter_by(user_id=user_id)
|
||||
|
||||
if start_date and end_date:
|
||||
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc)
|
||||
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1)
|
||||
query = query.filter(
|
||||
Reading.timestamp >= start_dt,
|
||||
Reading.timestamp <= end_dt
|
||||
)
|
||||
|
||||
return query.order_by(Reading.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
|
||||
def fetch_readings_for_range(user_id, start_utc, end_utc=None):
|
||||
"""Fetch readings from a UTC start time onwards (for calendar/graph views)."""
|
||||
query = Reading.query.filter(
|
||||
Reading.user_id == user_id,
|
||||
Reading.timestamp >= start_utc
|
||||
)
|
||||
if end_utc:
|
||||
query = query.filter(Reading.timestamp <= end_utc)
|
||||
return query.order_by(Reading.timestamp.desc()).all()
|
||||
|
||||
|
||||
def annotate_readings(readings, user_tz):
|
||||
"""Add relative and localized timestamps to readings."""
|
||||
now = datetime.utcnow()
|
||||
for reading in readings:
|
||||
reading.relative_timestamp = humanize.naturaltime(now - reading.timestamp)
|
||||
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(user_tz)
|
||||
|
||||
|
||||
def build_readings_by_day(readings, user_tz):
|
||||
"""Build a dict mapping dates to readings (single pass, shared by calendar views)."""
|
||||
readings_by_day = defaultdict(list)
|
||||
for reading in readings:
|
||||
local_date = reading.local_timestamp.date() if hasattr(reading, 'local_timestamp') else utc.localize(reading.timestamp).astimezone(user_tz).date()
|
||||
readings_by_day[local_date].append(reading)
|
||||
return readings_by_day
|
||||
|
||||
|
||||
def calculate_weekly_summary_sql(user_id):
|
||||
"""Calculate weekly averages using SQL aggregation (single DB query)."""
|
||||
one_week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
result = db.session.query(
|
||||
func.round(func.avg(Reading.systolic), 1).label('sys_avg'),
|
||||
func.round(func.avg(Reading.diastolic), 1).label('dia_avg'),
|
||||
func.round(func.avg(Reading.heart_rate), 1).label('hr_avg'),
|
||||
).filter(
|
||||
Reading.user_id == user_id,
|
||||
Reading.timestamp >= one_week_ago
|
||||
).first()
|
||||
|
||||
if result and result.sys_avg is not None:
|
||||
return float(result.sys_avg), float(result.dia_avg), float(result.hr_avg)
|
||||
return 0, 0, 0
|
||||
|
||||
|
||||
def generate_monthly_calendar(readings_by_day, selected_date, local_tz):
|
||||
"""Generate a monthly calendar view from pre-built readings_by_day."""
|
||||
today = datetime.now(local_tz).date()
|
||||
first_day = selected_date.replace(day=1)
|
||||
start_date = first_day - timedelta(days=(first_day.weekday() + 1) % 7)
|
||||
end_date = start_date + timedelta(days=41)
|
||||
|
||||
return [
|
||||
{
|
||||
'day': current_date.day,
|
||||
'is_today': current_date == today,
|
||||
'is_in_current_month': current_date.month == selected_date.month,
|
||||
'readings': readings_by_day.get(current_date.date(), []),
|
||||
}
|
||||
for current_date in (start_date + timedelta(days=i) for i in range((end_date - start_date).days + 1))
|
||||
]
|
||||
|
||||
def generate_weekly_calendar(readings_by_day, selected_date, local_tz):
|
||||
"""Generate a weekly calendar view from pre-built readings_by_day."""
|
||||
today = datetime.now(local_tz).date()
|
||||
start_of_week = selected_date - timedelta(days=selected_date.weekday())
|
||||
|
||||
return [
|
||||
{
|
||||
'date': current_date.strftime('%a, %b %d'),
|
||||
'is_today': current_date == today,
|
||||
'readings': readings_by_day.get(current_date.date(), []),
|
||||
}
|
||||
for current_date in (start_of_week + timedelta(days=i) for i in range(7))
|
||||
]
|
||||
|
||||
def prepare_graph_data(readings):
|
||||
"""Prepare data for graph rendering, reversing so chronological order is left-to-right."""
|
||||
chronological_readings = list(reversed(readings))
|
||||
|
||||
n = len(chronological_readings)
|
||||
time_percentages = []
|
||||
|
||||
systolic_vals = [r.systolic for r in chronological_readings]
|
||||
diastolic_vals = [r.diastolic for r in chronological_readings]
|
||||
hr_vals = [r.heart_rate for r in chronological_readings]
|
||||
|
||||
sys_avg = round(sum(systolic_vals) / n, 1) if n > 0 else 0
|
||||
dia_avg = round(sum(diastolic_vals) / n, 1) if n > 0 else 0
|
||||
hr_avg = round(sum(hr_vals) / n, 1) if n > 0 else 0
|
||||
|
||||
if n == 0:
|
||||
pass
|
||||
elif n == 1:
|
||||
time_percentages = [0.5]
|
||||
else:
|
||||
first_time = chronological_readings[0].timestamp.timestamp()
|
||||
last_time = chronological_readings[-1].timestamp.timestamp()
|
||||
time_span = last_time - first_time
|
||||
|
||||
for r in chronological_readings:
|
||||
if time_span == 0:
|
||||
time_percentages.append(0.5)
|
||||
else:
|
||||
p = (r.timestamp.timestamp() - first_time) / time_span
|
||||
time_percentages.append(p)
|
||||
|
||||
return {
|
||||
'timestamps': [r.timestamp.strftime('%b %d\n%H:%M') for r in chronological_readings],
|
||||
'systolic': systolic_vals,
|
||||
'diastolic': diastolic_vals,
|
||||
'heart_rate': hr_vals,
|
||||
'time_percentages': time_percentages,
|
||||
'sys_avg': sys_avg,
|
||||
'dia_avg': dia_avg,
|
||||
'hr_avg': hr_avg,
|
||||
}
|
||||
|
||||
def calculate_progress_badges(user_id, user_tz):
|
||||
"""Generate badges based on user activity and milestones using optimized queries."""
|
||||
# Fetch only timestamps (index-only scan on the composite index)
|
||||
timestamps = db.session.query(Reading.timestamp).filter(Reading.user_id == user_id).order_by(Reading.timestamp.desc()).all()
|
||||
total_readings = len(timestamps)
|
||||
return _compute_badges(total_readings, timestamps, user_tz)
|
||||
|
||||
def _compute_badges(total_readings, timestamps, user_tz, now_local=None):
|
||||
if now_local is None:
|
||||
now_local = datetime.now(user_tz).date()
|
||||
badges = []
|
||||
|
||||
if total_readings == 0:
|
||||
return badges
|
||||
|
||||
streak_count = 0
|
||||
if timestamps:
|
||||
distinct_dates = []
|
||||
last_date = None
|
||||
for (ts,) in timestamps:
|
||||
local_date = utc.localize(ts).astimezone(user_tz).date()
|
||||
if local_date != last_date:
|
||||
distinct_dates.append(local_date)
|
||||
last_date = local_date
|
||||
|
||||
if distinct_dates:
|
||||
most_recent_date = distinct_dates[0]
|
||||
if (now_local - most_recent_date).days <= 1:
|
||||
streak_count = 1
|
||||
current_check_date = most_recent_date
|
||||
|
||||
for d in distinct_dates[1:]:
|
||||
if (current_check_date - d).days == 1:
|
||||
streak_count += 1
|
||||
current_check_date = d
|
||||
else:
|
||||
break
|
||||
|
||||
if streak_count >= 1:
|
||||
badges.append(f"Current Streak: {streak_count} Days")
|
||||
if streak_count >= 7:
|
||||
badges.append("Logged Every Day for a Week")
|
||||
if streak_count >= 30:
|
||||
badges.append("Monthly Streak")
|
||||
|
||||
last_7_readings = timestamps[:7]
|
||||
if len(last_7_readings) == 7:
|
||||
if all(5 <= utc.localize(ts).astimezone(user_tz).hour < 12 for (ts,) in last_7_readings):
|
||||
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
|
||||
|
||||
if all(18 <= utc.localize(ts).astimezone(user_tz).hour <= 23 for (ts,) in last_7_readings):
|
||||
badges.append("Night Owl: Logged Readings Every Night for a Week")
|
||||
|
||||
milestones = [10, 50, 100, 500, 1000, 5000, 10000]
|
||||
highest_milestone = max((m for m in milestones if total_readings >= m), default=None)
|
||||
if highest_milestone:
|
||||
badges.append(f"{highest_milestone} Readings Logged")
|
||||
|
||||
return badges
|
||||
from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, render_template, redirect, request, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from pytz import utc
|
||||
from app.models import Reading
|
||||
from app.insights import DateRange, user_timezone, parse_range, readings_query, summary, annotate, chart_data
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
PAGE_SIZE = 25
|
||||
|
||||
@main.route('/')
|
||||
def landing():
|
||||
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
|
||||
|
||||
@main.route('/health')
|
||||
def health():
|
||||
return 'OK'
|
||||
|
||||
@main.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
view = request.args.get('view', 'overview')
|
||||
if view not in ('overview', 'history', 'trends'):
|
||||
view = 'overview'
|
||||
tz = user_timezone()
|
||||
mode = request.args.get('mode', 'readings') if view == 'history' else 'readings'
|
||||
calendar = None
|
||||
try:
|
||||
selected = parse_range(request.args, tz, 7 if view == 'overview' else 30)
|
||||
if mode == 'calendar':
|
||||
unit = request.args.get('unit', 'month')
|
||||
if unit not in ('week', 'month'):
|
||||
raise ValueError('Choose a week or month calendar.')
|
||||
anchor = date.fromisoformat(request.args.get('anchor', datetime.now(tz).date().isoformat()))
|
||||
if not 1900 <= anchor.year <= 2099:
|
||||
raise ValueError('Choose a calendar date between 1900 and 2099.')
|
||||
if unit == 'week':
|
||||
start = anchor - timedelta(days=anchor.weekday())
|
||||
end = start + timedelta(days=6)
|
||||
prev, following = start - timedelta(days=7), start + timedelta(days=7)
|
||||
else:
|
||||
start = anchor.replace(day=1)
|
||||
following = (start + timedelta(days=32)).replace(day=1)
|
||||
end = following - timedelta(days=1)
|
||||
prev = (start - timedelta(days=1)).replace(day=1)
|
||||
selected = DateRange(start, end, tz)
|
||||
calendar = dict(unit=unit, anchor=anchor.isoformat(), prev=prev.isoformat(), next=following.isoformat())
|
||||
except ValueError as error:
|
||||
return render_template('error.html', message=str(error)), 400
|
||||
query = readings_query(current_user.id, selected)
|
||||
stats = summary(query)
|
||||
pagination = None
|
||||
readings = []
|
||||
latest = None
|
||||
chart = None
|
||||
if view == 'overview':
|
||||
latest = readings_query(current_user.id).order_by(Reading.timestamp.desc(), Reading.id.desc()).first()
|
||||
if latest:
|
||||
annotate([latest], tz)
|
||||
readings = annotate(query.order_by(Reading.timestamp.desc(), Reading.id.desc()).limit(5).all(), tz)
|
||||
elif mode != 'calendar':
|
||||
if view == 'history':
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
pagination = query.order_by(Reading.timestamp.desc(), Reading.id.desc()).paginate(page=page, per_page=PAGE_SIZE, error_out=False)
|
||||
readings = annotate(pagination.items, tz)
|
||||
if view in ('overview', 'trends') or calendar:
|
||||
chart = chart_data(query, selected)
|
||||
if calendar:
|
||||
by_day = {p['date']: p for p in chart['points']}
|
||||
calendar['days'] = [dict(date=(selected.start + timedelta(days=i)), data=by_day.get((selected.start + timedelta(days=i)).isoformat()))
|
||||
for i in range((selected.end - selected.start).days + 1)]
|
||||
profile = current_user.profile
|
||||
active_days = (selected.end - selected.start).days + 1 if selected.end == datetime.now(tz).date() else None
|
||||
return render_template('dashboard.html', view=view, mode=mode, selected=selected, stats=stats,
|
||||
readings=readings, latest=latest, chart=chart, pagination=pagination,
|
||||
calendar=calendar, timezone_name=str(tz), active_days=active_days,
|
||||
sys_threshold=(profile.systolic_threshold or 140) if profile else 140,
|
||||
dia_threshold=(profile.diastolic_threshold or 90) if profile else 90)
|
||||
|
||||
@main.route('/dashboard/report')
|
||||
@login_required
|
||||
def report():
|
||||
tz = user_timezone()
|
||||
try:
|
||||
selected = parse_range(request.args, tz, 30)
|
||||
except ValueError as error:
|
||||
return render_template('error.html', message=str(error)), 400
|
||||
query = readings_query(current_user.id, selected)
|
||||
readings = annotate(query.order_by(Reading.timestamp, Reading.id).all(), tz)
|
||||
return render_template('report.html', selected=selected, stats=summary(query), readings=readings, timezone_name=str(tz))
|
||||
|
||||
# Preserve bookmarks to the former separate views.
|
||||
@main.route('/dashboard/list')
|
||||
@main.route('/dashboard/table')
|
||||
@login_required
|
||||
def dashboard_table():
|
||||
params = request.args.to_dict()
|
||||
params['view'] = 'history'
|
||||
return redirect(url_for('main.dashboard', **params))
|
||||
|
||||
@main.route('/dashboard/graph')
|
||||
@login_required
|
||||
def dashboard_graph():
|
||||
params = request.args.to_dict()
|
||||
params['view'] = 'trends'
|
||||
return redirect(url_for('main.dashboard', **params))
|
||||
|
||||
@main.route('/dashboard/weekly')
|
||||
@main.route('/dashboard/monthly')
|
||||
@login_required
|
||||
def dashboard_calendar():
|
||||
unit = 'week' if request.path.endswith('weekly') else 'month'
|
||||
anchor = datetime.now(user_timezone()).date()
|
||||
offset = request.args.get('week_offset' if unit == 'week' else 'month_offset', 0, type=int)
|
||||
try:
|
||||
if unit == 'week':
|
||||
anchor += timedelta(weeks=offset)
|
||||
else:
|
||||
month_index = anchor.year * 12 + anchor.month - 1 + offset
|
||||
anchor = date(month_index // 12, month_index % 12 + 1, 1)
|
||||
except (OverflowError, ValueError):
|
||||
return render_template('error.html', message='Choose a valid calendar date.'), 400
|
||||
return redirect(url_for('main.dashboard', view='history', mode='calendar', unit=unit, anchor=anchor.isoformat()))
|
||||
|
||||
def _compute_badges(total_readings, timestamps, user_tz, now_local=None):
|
||||
if now_local is None:
|
||||
now_local = datetime.now(user_tz).date()
|
||||
badges = []
|
||||
|
||||
if total_readings == 0:
|
||||
return badges
|
||||
|
||||
streak_count = 0
|
||||
if timestamps:
|
||||
distinct_dates = []
|
||||
last_date = None
|
||||
for (ts,) in timestamps:
|
||||
local_date = utc.localize(ts).astimezone(user_tz).date()
|
||||
if local_date != last_date:
|
||||
distinct_dates.append(local_date)
|
||||
last_date = local_date
|
||||
|
||||
if distinct_dates:
|
||||
most_recent_date = distinct_dates[0]
|
||||
if (now_local - most_recent_date).days <= 1:
|
||||
streak_count = 1
|
||||
current_check_date = most_recent_date
|
||||
|
||||
for d in distinct_dates[1:]:
|
||||
if (current_check_date - d).days == 1:
|
||||
streak_count += 1
|
||||
current_check_date = d
|
||||
else:
|
||||
break
|
||||
|
||||
if streak_count >= 1:
|
||||
badges.append(f"Current Streak: {streak_count} Days")
|
||||
if streak_count >= 7:
|
||||
badges.append("Logged Every Day for a Week")
|
||||
if streak_count >= 30:
|
||||
badges.append("Monthly Streak")
|
||||
|
||||
last_7_readings = timestamps[:7]
|
||||
if len(last_7_readings) == 7:
|
||||
if all(5 <= utc.localize(ts).astimezone(user_tz).hour < 12 for (ts,) in last_7_readings):
|
||||
badges.append("Morning Riser: Logged Readings Every Morning for a Week")
|
||||
|
||||
if all(18 <= utc.localize(ts).astimezone(user_tz).hour <= 23 for (ts,) in last_7_readings):
|
||||
badges.append("Night Owl: Logged Readings Every Night for a Week")
|
||||
|
||||
milestones = [10, 50, 100, 500, 1000, 5000, 10000]
|
||||
highest_milestone = max((m for m in milestones if total_readings >= m), default=None)
|
||||
if highest_milestone:
|
||||
badges.append(f"{highest_milestone} Readings Logged")
|
||||
|
||||
return badges
|
||||
|
||||
+86
-79
@@ -1,79 +1,86 @@
|
||||
from flask import Blueprint, render_template, redirect, request, url_for, flash
|
||||
from pytz import timezone, utc
|
||||
from app.models import Reading, db
|
||||
from app.forms import ReadingForm
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
reading = Blueprint('reading', __name__)
|
||||
|
||||
def get_user_timezone():
|
||||
"""Fetch the user's timezone, defaulting to UTC."""
|
||||
return timezone(current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC')
|
||||
|
||||
def localize_timestamp(timestamp, user_tz):
|
||||
"""Convert a UTC timestamp to the user's local timezone."""
|
||||
return utc.localize(timestamp).astimezone(user_tz)
|
||||
|
||||
def save_reading_from_form(reading, form, user_tz):
|
||||
"""Update a reading with form data and convert the timestamp to UTC."""
|
||||
local_timestamp = form.timestamp.data
|
||||
reading.timestamp = user_tz.localize(local_timestamp.replace(tzinfo=None)).astimezone(utc)
|
||||
reading.systolic = form.systolic.data
|
||||
reading.diastolic = form.diastolic.data
|
||||
reading.heart_rate = form.heart_rate.data
|
||||
db.session.commit()
|
||||
|
||||
@reading.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_reading():
|
||||
form = ReadingForm()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
if form.validate_on_submit():
|
||||
new_reading = Reading(
|
||||
user_id=current_user.id,
|
||||
timestamp=user_tz.localize(form.timestamp.data.replace(tzinfo=None)).astimezone(utc),
|
||||
systolic=form.systolic.data,
|
||||
diastolic=form.diastolic.data,
|
||||
heart_rate=form.heart_rate.data,
|
||||
)
|
||||
db.session.add(new_reading)
|
||||
db.session.commit()
|
||||
flash("Reading added successfully.", "success")
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
form.timestamp.data = localize_timestamp(datetime.utcnow(), user_tz)
|
||||
return render_template('reading/add_reading.html', form=form)
|
||||
|
||||
@reading.route('/<int:reading_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_reading(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
form = ReadingForm(obj=reading)
|
||||
form.timestamp.data = localize_timestamp(reading.timestamp, user_tz)
|
||||
|
||||
if form.validate_on_submit():
|
||||
save_reading_from_form(reading, form, user_tz)
|
||||
flash('Reading updated successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('reading/edit_reading.html', form=form, reading=reading)
|
||||
|
||||
@reading.route('/<int:reading_id>/confirm_delete', methods=['GET'])
|
||||
@login_required
|
||||
def confirm_delete(reading_id):
|
||||
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.route('/<int:reading_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_reading(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
db.session.delete(reading)
|
||||
db.session.commit()
|
||||
flash('Reading deleted successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
from flask import Blueprint, render_template, redirect, request, url_for, flash
|
||||
from pytz import timezone, utc
|
||||
from app.models import Reading, db
|
||||
from app.forms import ReadingForm, DeleteForm
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime
|
||||
|
||||
reading = Blueprint('reading', __name__)
|
||||
|
||||
def get_user_timezone():
|
||||
"""Fetch the user's timezone, defaulting to UTC."""
|
||||
return timezone(current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC')
|
||||
|
||||
def localize_timestamp(timestamp, user_tz):
|
||||
"""Convert a UTC timestamp to the user's local timezone."""
|
||||
return utc.localize(timestamp).astimezone(user_tz)
|
||||
|
||||
def save_reading_from_form(reading, form, user_tz):
|
||||
"""Update a reading with form data and convert the timestamp to UTC."""
|
||||
local_timestamp = form.timestamp.data
|
||||
reading.timestamp = user_tz.localize(local_timestamp.replace(tzinfo=None)).astimezone(utc)
|
||||
reading.systolic = form.systolic.data
|
||||
reading.diastolic = form.diastolic.data
|
||||
reading.heart_rate = form.heart_rate.data
|
||||
db.session.commit()
|
||||
|
||||
@reading.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_reading():
|
||||
form = ReadingForm()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
if form.validate_on_submit():
|
||||
new_reading = Reading(
|
||||
user_id=current_user.id,
|
||||
timestamp=user_tz.localize(form.timestamp.data.replace(tzinfo=None)).astimezone(utc),
|
||||
systolic=form.systolic.data,
|
||||
diastolic=form.diastolic.data,
|
||||
heart_rate=form.heart_rate.data,
|
||||
)
|
||||
db.session.add(new_reading)
|
||||
db.session.commit()
|
||||
flash("Reading added successfully.", "success")
|
||||
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)
|
||||
return render_template('reading/add_reading.html', form=form)
|
||||
|
||||
@reading.route('/<int:reading_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_reading(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
user_tz = get_user_timezone()
|
||||
|
||||
form = ReadingForm(obj=reading)
|
||||
if request.method == 'GET':
|
||||
form.timestamp.data = localize_timestamp(reading.timestamp, user_tz)
|
||||
|
||||
if form.validate_on_submit():
|
||||
save_reading_from_form(reading, form, user_tz)
|
||||
flash('Reading updated successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('reading/edit_reading.html', form=form, reading=reading)
|
||||
|
||||
@reading.route('/<int:reading_id>/confirm_delete', methods=['GET'])
|
||||
@login_required
|
||||
def confirm_delete(reading_id):
|
||||
reading = Reading.query.filter_by(id=reading_id, user_id=current_user.id).first_or_404()
|
||||
|
||||
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'])
|
||||
@login_required
|
||||
def delete_reading(reading_id):
|
||||
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.commit()
|
||||
flash('Reading deleted successfully!', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
+85
-81
@@ -1,81 +1,85 @@
|
||||
import io
|
||||
from flask import Blueprint, make_response, render_template, redirect, url_for, flash
|
||||
from werkzeug.http import http_date
|
||||
from app.models import Profile, db
|
||||
from app.forms import ProfileForm
|
||||
from flask_login import login_required, current_user
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
|
||||
user = Blueprint('user', __name__)
|
||||
|
||||
@user.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
profile = current_user.profile or Profile(user_id=current_user.id)
|
||||
form = ProfileForm(obj=profile)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Update profile fields
|
||||
profile.name = form.name.data
|
||||
profile.email = form.email.data
|
||||
profile.systolic_threshold = form.systolic_threshold.data or profile.systolic_threshold
|
||||
profile.diastolic_threshold = form.diastolic_threshold.data or profile.diastolic_threshold
|
||||
profile.dark_mode = form.dark_mode.data
|
||||
profile.timezone = form.timezone.data
|
||||
|
||||
# Handle profile picture upload
|
||||
if form.profile_pic.data:
|
||||
file_data = form.profile_pic.data.read()
|
||||
|
||||
# Resize and compress the image
|
||||
try:
|
||||
image = Image.open(io.BytesIO(file_data))
|
||||
image = image.convert("RGB") # Ensure it's in RGB format
|
||||
image.thumbnail((200, 200)) # Resize to a maximum of 200x200 pixels
|
||||
|
||||
# Save the resized image to a buffer
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=80) # Compress with quality=80
|
||||
buffer.seek(0)
|
||||
|
||||
# Encode the compressed image as base64
|
||||
profile.profile_pic = base64.b64encode(buffer.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
flash(f"Error processing profile picture: {e}", 'danger')
|
||||
|
||||
|
||||
db.session.add(profile)
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
return redirect(url_for('user.profile'))
|
||||
|
||||
return render_template('profile.html', form=form, profile=profile)
|
||||
|
||||
@user.route('/profile/image/<int:user_id>')
|
||||
def profile_image(user_id):
|
||||
# Ensure the reading belongs to the logged-in user
|
||||
if user_id != current_user.id:
|
||||
flash('You are not authorized to delete this reading.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
profile = Profile.query.filter_by(user_id=user_id).first()
|
||||
if profile and profile.profile_pic:
|
||||
image_data = base64.b64decode(profile.profile_pic)
|
||||
response = make_response(image_data)
|
||||
response.headers.set('Content-Type', 'image/jpeg')
|
||||
response.headers.set('Cache-Control', 'public, max-age=86400') # Cache for 1 day
|
||||
response.headers.set('ETag', str(hash(profile.profile_pic))) # Unique ETag for the image
|
||||
# Use actual profile update time instead of utcnow() which defeats caching
|
||||
last_modified = profile.updated_at or datetime.utcnow()
|
||||
response.headers.set('Last-Modified', http_date(last_modified.timestamp()))
|
||||
|
||||
return response
|
||||
else:
|
||||
# Serve the default SVG if no profile picture is found
|
||||
with open('app/static/images/default-profile.svg', 'r') as f:
|
||||
default_image = f.read()
|
||||
|
||||
response = make_response(default_image)
|
||||
response.headers.set('Content-Type', 'image/svg+xml')
|
||||
import io
|
||||
from flask import Blueprint, make_response, render_template, redirect, url_for, flash
|
||||
from werkzeug.http import http_date
|
||||
from app.models import Profile, db
|
||||
from app.forms import ProfileForm
|
||||
from flask_login import login_required, current_user
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
user = Blueprint('user', __name__)
|
||||
|
||||
@user.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@login_required
|
||||
def profile():
|
||||
profile = current_user.profile or Profile(user_id=current_user.id)
|
||||
form = ProfileForm(obj=profile)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Update profile fields
|
||||
profile.name = form.name.data
|
||||
profile.email = form.email.data
|
||||
profile.systolic_threshold = form.systolic_threshold.data or profile.systolic_threshold
|
||||
profile.diastolic_threshold = form.diastolic_threshold.data or profile.diastolic_threshold
|
||||
profile.dark_mode = form.dark_mode.data
|
||||
profile.timezone = form.timezone.data
|
||||
|
||||
# Handle profile picture upload
|
||||
if form.profile_pic.data:
|
||||
file_data = form.profile_pic.data.read()
|
||||
|
||||
# Resize and compress the image
|
||||
try:
|
||||
image = Image.open(io.BytesIO(file_data))
|
||||
image = image.convert("RGB") # Ensure it's in RGB format
|
||||
image.thumbnail((200, 200)) # Resize to a maximum of 200x200 pixels
|
||||
|
||||
# Save the resized image to a buffer
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=80) # Compress with quality=80
|
||||
buffer.seek(0)
|
||||
|
||||
# Encode the compressed image as base64
|
||||
profile.profile_pic = base64.b64encode(buffer.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
flash(f"Error processing profile picture: {e}", 'danger')
|
||||
|
||||
|
||||
db.session.add(profile)
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully!', 'success')
|
||||
return redirect(url_for('user.profile'))
|
||||
|
||||
return render_template('profile.html', form=form, profile=profile)
|
||||
|
||||
@user.route('/profile/image/<int:user_id>')
|
||||
@login_required
|
||||
def profile_image(user_id):
|
||||
# Ensure the reading belongs to the logged-in user
|
||||
if user_id != current_user.id:
|
||||
flash('You are not authorized to delete this reading.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
profile = Profile.query.filter_by(user_id=user_id).first()
|
||||
if profile and profile.profile_pic:
|
||||
image_data = base64.b64decode(profile.profile_pic)
|
||||
response = make_response(image_data)
|
||||
response.headers.set('Content-Type', 'image/jpeg')
|
||||
response.headers.set('Cache-Control', 'public, max-age=86400') # Cache for 1 day
|
||||
response.headers.set('ETag', str(hash(profile.profile_pic))) # Unique ETag for the image
|
||||
# Use actual profile update time instead of utcnow() which defeats caching
|
||||
last_modified = profile.updated_at or datetime.utcnow()
|
||||
response.headers.set('Last-Modified', http_date(last_modified.timestamp()))
|
||||
|
||||
return response
|
||||
else:
|
||||
# Serve the default SVG if no profile picture is found
|
||||
with open('app/static/images/default-profile.svg', 'r') as f:
|
||||
default_image = f.read()
|
||||
|
||||
response = make_response(default_image)
|
||||
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
+23
-68
@@ -1,68 +1,23 @@
|
||||
document.addEventListener('click', async (e) => {
|
||||
const link = e.target.closest('a');
|
||||
if (!link) return;
|
||||
|
||||
const url = link.getAttribute('href');
|
||||
if (!url || url.startsWith('#') || url.startsWith('javascript:')) return;
|
||||
|
||||
// Only intercept same-origin links
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', async () => {
|
||||
document.body.style.cursor = 'wait';
|
||||
try {
|
||||
const response = await fetch(window.location.href);
|
||||
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
|
||||
}
|
||||
});
|
||||
// Core flows use native links and forms. These enhancements are optional.
|
||||
document.addEventListener('click', (event) => {
|
||||
const close = event.target.closest('[data-dismiss]');
|
||||
if (close) {
|
||||
const notice = close.closest('.notice');
|
||||
const next = document.querySelector('.add-action, .entry-form input:not([type=hidden]), #main');
|
||||
notice.remove();
|
||||
next?.focus();
|
||||
}
|
||||
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) => {
|
||||
if (event.key === 'Escape') {
|
||||
document.querySelectorAll('.account-menu[open]').forEach((menu) => {
|
||||
menu.open = false;
|
||||
menu.querySelector('summary').focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('[data-print]').forEach((button) => { button.hidden = false; });
|
||||
|
||||
Vendored
+1
-1
@@ -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});
|
||||
+47
-295
@@ -1,295 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 href="/static/css/tailwind.css" rel="stylesheet">
|
||||
<script src="{{ url_for('static', filename='js/diy-turbo.min.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 text-gray-800 font-sans antialiased">
|
||||
<nav id="mobile-nav"
|
||||
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">
|
||||
<!--Logo etc-->
|
||||
<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 %}
|
||||
<li class="mr-3">
|
||||
<a class="inline-block py-2 px-4 no-underline
|
||||
{% if request.path == url_for('main.dashboard') %}
|
||||
text-white
|
||||
{% 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>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
<div class="fixed top-24 right-4 z-50 space-y-4">
|
||||
{% for category, message in messages %}
|
||||
<div
|
||||
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 class="font-medium">{{ message }}</span>
|
||||
<button
|
||||
class="flash-close-btn text-2xl font-bold ml-4 hover:text-gray-200 transition-colors">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto mt-24">
|
||||
{% 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>© 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>
|
||||
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="{{ 'dark' if current_user.is_authenticated and current_user.profile and current_user.profile.dark_mode else 'light' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{% block title %}BP Tracker{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset_url('images/favicon.svg') }}">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/tailwind.css') }}">
|
||||
<script defer src="{{ asset_url('js/diy-turbo.min.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<header class="site-header">
|
||||
<div class="shell header-inner">
|
||||
<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>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="main-nav" aria-label="Main navigation">
|
||||
{% for key, label in [('overview', 'Overview'), ('history', 'History'), ('trends', 'Trends')] %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</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) %}
|
||||
{% if messages %}<div class="notifications" aria-label="Notifications">
|
||||
{% for category, message in messages %}
|
||||
<div class="notice {{ 'notice-error' if category == 'danger' else '' }}" role="{{ 'alert' if category == 'danger' else 'status' }}">
|
||||
<span><strong>{{ 'Please check' if category == 'danger' else 'Done' }}</strong> · {{ message }}</span>
|
||||
<button type="button" class="notice-close" aria-label="Dismiss notification" data-dismiss>×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="shell site-footer"><span>BP Tracker</span><span>A little clarity, every day.</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,88 +1,4 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto mt-12 mb-20">
|
||||
<div class="bg-white p-10 rounded-3xl shadow-2xl border border-gray-100 relative overflow-hidden">
|
||||
<!-- 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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% from 'components.html' import field %}
|
||||
{% block title %}Log in · BP Tracker{% endblock %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,111 +1,4 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto mt-12 mb-20">
|
||||
<div class="bg-white p-10 rounded-3xl shadow-2xl border border-gray-100 relative overflow-hidden">
|
||||
<!-- 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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% from 'components.html' import field %}
|
||||
{% block title %}Create account · BP Tracker{% endblock %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
+34
-114
@@ -1,114 +1,34 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-5xl mx-auto p-4 space-y-6">
|
||||
<!-- Header Section with "Add New Reading" Button -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-gray-800">Dashboard</h1>
|
||||
<a rel="prefetch" href="{{ url_for('reading.add_reading') }}"
|
||||
class="bg-primary-600 text-white px-4 py-2 rounded shadow hover:bg-primary-700">
|
||||
+ Add New Reading
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Summary -->
|
||||
<div class="bg-gradient-to-r from-primary-500 to-primary-700 text-white p-6 rounded-xl shadow-md">
|
||||
<h3 class="text-lg font-bold">Weekly Summary</h3>
|
||||
<div class="flex justify-between mt-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">Systolic Average</p>
|
||||
<p class="text-2xl">{{ systolic_avg }} <span class="text-base">mmHg</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold">Diastolic Average</p>
|
||||
<p class="text-2xl">{{ diastolic_avg }} <span class="text-base">mmHg</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold">Heart Rate Average</p>
|
||||
<p class="text-2xl">{{ heart_rate_avg }} <span class="text-base">bpm</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% from 'components.html' import reading_rows %}
|
||||
{% block title %}{{ view|title }} · BP Tracker{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
{% if not calendar %}{% include 'partials/range_filter.html' %}{% endif %}
|
||||
{% if view == 'overview' %}
|
||||
<section class="summary-grid" aria-label="Summary">
|
||||
<article class="card latest-card"><div class="card-label"><h2>Latest reading</h2><span class="dot" aria-hidden="true"></span></div>
|
||||
{% 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>
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if view in ['overview', 'trends'] %}{% include 'partials/trend.html' %}{% endif %}
|
||||
{% if view == 'history' %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if calendar %}
|
||||
<section class="card calendar-card">
|
||||
<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>
|
||||
<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="muted small">Daily averages in mmHg. Select a day to see its readings.</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>
|
||||
</section>
|
||||
{% elif view in ['overview', 'history'] %}
|
||||
<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>
|
||||
{{ reading_rows(readings, sys_threshold, dia_threshold) }}
|
||||
{% 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 %}
|
||||
</section>
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
+3
-39
@@ -1,39 +1,3 @@
|
||||
{% extends "_layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% block title %}Import & export · BP Tracker{% endblock %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,84 +1,2 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% block content %}
|
||||
<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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -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">« 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 »</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>
|
||||
@@ -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">«
|
||||
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
|
||||
»</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,72 +1,9 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% block content %}
|
||||
<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">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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% from 'components.html' import field %}
|
||||
{% block title %}Settings · BP Tracker{% endblock %}
|
||||
{% 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() }}
|
||||
{% if form.errors %}<div class="notice notice-error" role="alert">Please check the highlighted fields.</div>{% endif %}
|
||||
<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>
|
||||
<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>
|
||||
<fieldset><legend>Display</legend>{{ field(form.timezone) }}<div class="checkbox-field">{{ form.dark_mode }}{{ form.dark_mode.label }}</div></fieldset>
|
||||
<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 %}
|
||||
|
||||
@@ -1,74 +1,15 @@
|
||||
{% extends "_layout.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-md 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-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() }}
|
||||
|
||||
<!-- Timestamp Field -->
|
||||
<div>
|
||||
{{ form.timestamp.label(class="block text-sm font-medium text-gray-700 mb-2") }}
|
||||
{{ form.timestamp(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.timestamp.errors %}
|
||||
<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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% from 'components.html' import field %}
|
||||
{% block title %}{{ 'Edit' if reading is defined else 'Add' }} reading · BP Tracker{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
<form method="post" class="card entry-form" novalidate>
|
||||
{{ 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 %}
|
||||
<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>
|
||||
{{ field(form.heart_rate, true) }}
|
||||
<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>
|
||||
<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>
|
||||
{% 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 %}
|
||||
</form><p class="form-hint">Your readings stay editable in History.</p></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,40 +1,3 @@
|
||||
{% extends "_layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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 %}
|
||||
{% extends '_layout.html' %}
|
||||
{% block title %}Delete reading · BP Tracker{% endblock %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,79 +1 @@
|
||||
{% extends "_layout.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 %}
|
||||
{% extends 'reading/add_reading.html' %}
|
||||
|
||||
@@ -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 browser’s 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 %}
|
||||
Reference in New Issue
Block a user