Add indexes and pagination to improve app performance
This commit is contained in:
@@ -33,7 +33,7 @@ def create_app():
|
||||
# Set up the user_loader function
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id)) # Query the User model by ID
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth
|
||||
|
||||
@@ -7,7 +7,7 @@ class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(255), nullable=False, unique=True)
|
||||
password_hash = db.Column(db.Text, nullable=False)
|
||||
profile = db.relationship('Profile', backref='user', uselist=False)
|
||||
profile = db.relationship('Profile', backref='user', uselist=False, lazy='joined')
|
||||
|
||||
class Profile(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -19,8 +19,13 @@ class Profile(db.Model):
|
||||
diastolic_threshold = db.Column(db.Integer, default=90)
|
||||
dark_mode = db.Column(db.Boolean, default=False)
|
||||
timezone = db.Column(db.String(50), default='UTC') # e.g., 'Australia/Sydney'
|
||||
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
|
||||
|
||||
class Reading(db.Model):
|
||||
__table_args__ = (
|
||||
db.Index('ix_reading_user_timestamp', 'user_id', 'timestamp'),
|
||||
)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
@@ -41,31 +41,36 @@ def manage_data():
|
||||
@login_required
|
||||
def export_data():
|
||||
import io
|
||||
from flask import Response
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
def generate_csv():
|
||||
"""Stream CSV rows to avoid loading all readings into memory."""
|
||||
# Write header
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# Write CSV header
|
||||
writer.writerow(['Timestamp', 'Systolic', 'Diastolic', 'Heart Rate'])
|
||||
# Stream readings in chunks using yield_per
|
||||
readings = Reading.query.filter_by(user_id=current_user.id).order_by(
|
||||
Reading.timestamp
|
||||
).yield_per(500)
|
||||
|
||||
# Write user readings to the CSV
|
||||
readings = Reading.query.filter_by(user_id=current_user.id).all()
|
||||
for reading in readings:
|
||||
writer.writerow([
|
||||
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.heart_rate,
|
||||
])
|
||||
for reading in readings:
|
||||
writer.writerow([
|
||||
reading.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.heart_rate,
|
||||
])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# Convert text to bytes for `send_file`
|
||||
output.seek(0)
|
||||
response = io.BytesIO(output.getvalue().encode('utf-8'))
|
||||
output.close()
|
||||
|
||||
return send_file(
|
||||
response,
|
||||
return Response(
|
||||
generate_csv(),
|
||||
mimetype='text/csv',
|
||||
as_attachment=True,
|
||||
download_name='readings.csv'
|
||||
headers={'Content-Disposition': 'attachment; filename=readings.csv'}
|
||||
)
|
||||
@@ -10,6 +10,9 @@ from datetime import date, datetime, timedelta
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
|
||||
# Number of readings to show per page in list view
|
||||
PAGE_SIZE = 50
|
||||
|
||||
@main.route('/', methods=['GET'])
|
||||
def landing():
|
||||
return redirect(url_for('main.dashboard')) if current_user.is_authenticated else render_template('landing.html')
|
||||
@@ -29,26 +32,42 @@ def dashboard():
|
||||
start_date = request.form.get('start_date') or (first_reading and first_reading.strftime('%Y-%m-%d'))
|
||||
end_date = request.form.get('end_date') or (last_reading and last_reading.strftime('%Y-%m-%d'))
|
||||
|
||||
# Fetch filtered readings
|
||||
readings = fetch_readings(current_user.id, start_date, end_date, user_tz)
|
||||
# Pagination for list view
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
# Annotate readings with relative and localized timestamps
|
||||
annotate_readings(readings, user_tz)
|
||||
# Fetch paginated readings for the list view
|
||||
paginated = fetch_readings_paginated(current_user.id, start_date, end_date, user_tz, page, PAGE_SIZE)
|
||||
|
||||
# Generate calendar view
|
||||
week_view = generate_weekly_calendar(readings, datetime.now(user_tz), user_tz)
|
||||
month_view = generate_monthly_calendar(readings, datetime.now(user_tz), user_tz)
|
||||
# For calendar/graph/badges, fetch only current month + week readings (much smaller set)
|
||||
now = datetime.now(user_tz)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
month_start_utc = month_start.astimezone(utc)
|
||||
calendar_readings = fetch_readings_for_range(current_user.id, month_start_utc)
|
||||
|
||||
# Calculate stats and badges
|
||||
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary(readings)
|
||||
badges = calculate_progress_badges(readings)
|
||||
# Annotate all readings with relative and localized timestamps
|
||||
annotate_readings(paginated.items, user_tz)
|
||||
annotate_readings(calendar_readings, user_tz)
|
||||
|
||||
# Prepare graph data
|
||||
graph_data = prepare_graph_data(readings)
|
||||
# Build shared lookup for calendar views (single pass)
|
||||
readings_by_day = build_readings_by_day(calendar_readings, user_tz)
|
||||
|
||||
# Generate calendar views from the shared lookup
|
||||
week_view = generate_weekly_calendar(readings_by_day, now, user_tz)
|
||||
month_view = generate_monthly_calendar(readings_by_day, now, user_tz)
|
||||
|
||||
# Calculate weekly averages via SQL (much faster than Python)
|
||||
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary_sql(current_user.id)
|
||||
|
||||
# Badges from the paginated readings (or full set if needed)
|
||||
badges = calculate_progress_badges(paginated.items)
|
||||
|
||||
# Prepare graph data from calendar readings (current month)
|
||||
graph_data = prepare_graph_data(calendar_readings)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
readings=readings,
|
||||
readings=paginated.items,
|
||||
pagination=paginated,
|
||||
profile=current_user.profile,
|
||||
badges=badges,
|
||||
systolic_avg=systolic_avg,
|
||||
@@ -58,7 +77,7 @@ def dashboard():
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
month=month_view,
|
||||
week = week_view,
|
||||
week=week_view,
|
||||
date=date,
|
||||
**graph_data
|
||||
)
|
||||
@@ -75,22 +94,32 @@ def get_reading_date_range(user_id, user_tz):
|
||||
return first, last
|
||||
|
||||
|
||||
def fetch_readings(user_id, start_date, end_date, user_tz):
|
||||
"""Retrieve readings filtered by date range."""
|
||||
def fetch_readings_paginated(user_id, start_date, end_date, user_tz, page, per_page):
|
||||
"""Retrieve paginated readings filtered by date range."""
|
||||
query = Reading.query.filter_by(user_id=user_id)
|
||||
|
||||
if start_date and end_date:
|
||||
# Convert dates to the user's timezone
|
||||
start_dt = user_tz.localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(utc)
|
||||
end_dt = user_tz.localize(datetime.strptime(end_date, '%Y-%m-%d')).astimezone(utc) + timedelta(days=1) - timedelta(seconds=1)
|
||||
|
||||
query = query.filter(
|
||||
Reading.timestamp >= start_dt,
|
||||
Reading.timestamp <= end_dt
|
||||
)
|
||||
|
||||
return query.order_by(Reading.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
|
||||
def fetch_readings_for_range(user_id, start_utc, end_utc=None):
|
||||
"""Fetch readings from a UTC start time onwards (for calendar/graph views)."""
|
||||
query = Reading.query.filter(
|
||||
Reading.user_id == user_id,
|
||||
Reading.timestamp >= start_utc
|
||||
)
|
||||
if end_utc:
|
||||
query = query.filter(Reading.timestamp <= end_utc)
|
||||
return query.order_by(Reading.timestamp.desc()).all()
|
||||
|
||||
|
||||
def annotate_readings(readings, user_tz):
|
||||
"""Add relative and localized timestamps to readings."""
|
||||
now = datetime.utcnow()
|
||||
@@ -98,30 +127,40 @@ def annotate_readings(readings, user_tz):
|
||||
reading.relative_timestamp = humanize.naturaltime(now - reading.timestamp)
|
||||
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(user_tz)
|
||||
|
||||
def calculate_weekly_summary(readings):
|
||||
"""Calculate averages for the past week."""
|
||||
|
||||
def build_readings_by_day(readings, user_tz):
|
||||
"""Build a dict mapping dates to readings (single pass, shared by calendar views)."""
|
||||
readings_by_day = defaultdict(list)
|
||||
for reading in readings:
|
||||
local_date = reading.local_timestamp.date() if hasattr(reading, 'local_timestamp') else utc.localize(reading.timestamp).astimezone(user_tz).date()
|
||||
readings_by_day[local_date].append(reading)
|
||||
return readings_by_day
|
||||
|
||||
|
||||
def calculate_weekly_summary_sql(user_id):
|
||||
"""Calculate weekly averages using SQL aggregation (single DB query)."""
|
||||
one_week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
weekly_readings = [r for r in readings if r.timestamp >= one_week_ago]
|
||||
if weekly_readings:
|
||||
return (
|
||||
round(sum(r.systolic for r in weekly_readings) / len(weekly_readings), 1),
|
||||
round(sum(r.diastolic for r in weekly_readings) / len(weekly_readings), 1),
|
||||
round(sum(r.heart_rate for r in weekly_readings) / len(weekly_readings), 1),
|
||||
)
|
||||
result = db.session.query(
|
||||
func.round(func.avg(Reading.systolic), 1).label('sys_avg'),
|
||||
func.round(func.avg(Reading.diastolic), 1).label('dia_avg'),
|
||||
func.round(func.avg(Reading.heart_rate), 1).label('hr_avg'),
|
||||
).filter(
|
||||
Reading.user_id == user_id,
|
||||
Reading.timestamp >= one_week_ago
|
||||
).first()
|
||||
|
||||
if result and result.sys_avg is not None:
|
||||
return float(result.sys_avg), float(result.dia_avg), float(result.hr_avg)
|
||||
return 0, 0, 0
|
||||
|
||||
def generate_monthly_calendar(readings, selected_date, local_tz):
|
||||
"""Generate a monthly calendar view."""
|
||||
|
||||
def generate_monthly_calendar(readings_by_day, selected_date, local_tz):
|
||||
"""Generate a monthly calendar view from pre-built readings_by_day."""
|
||||
today = datetime.now(local_tz).date()
|
||||
first_day = selected_date.replace(day=1)
|
||||
start_date = first_day - timedelta(days=(first_day.weekday() + 1) % 7)
|
||||
end_date = start_date + timedelta(days=41)
|
||||
|
||||
readings_by_day = defaultdict(list)
|
||||
for reading in readings:
|
||||
local_date = reading.timestamp.astimezone(local_tz).date()
|
||||
readings_by_day[local_date].append(reading)
|
||||
|
||||
return [
|
||||
{
|
||||
'day': current_date.day,
|
||||
@@ -132,23 +171,14 @@ def generate_monthly_calendar(readings, selected_date, local_tz):
|
||||
for current_date in (start_date + timedelta(days=i) for i in range((end_date - start_date).days + 1))
|
||||
]
|
||||
|
||||
def generate_weekly_calendar(readings, selected_date, local_tz):
|
||||
"""Generate a weekly calendar view."""
|
||||
# Get the start of the week (Monday) and the end of the week (Sunday)
|
||||
def generate_weekly_calendar(readings_by_day, selected_date, local_tz):
|
||||
"""Generate a weekly calendar view from pre-built readings_by_day."""
|
||||
today = datetime.now(local_tz).date()
|
||||
start_of_week = selected_date - timedelta(days=selected_date.weekday())
|
||||
end_of_week = start_of_week + timedelta(days=6)
|
||||
|
||||
# Group readings by day
|
||||
readings_by_day = defaultdict(list)
|
||||
for reading in readings:
|
||||
local_date = reading.timestamp.astimezone(local_tz).date()
|
||||
readings_by_day[local_date].append(reading)
|
||||
|
||||
# Build the weekly calendar view
|
||||
return [
|
||||
{
|
||||
'date': current_date.strftime('%a, %b %d') ,
|
||||
'date': current_date.strftime('%a, %b %d'),
|
||||
'is_today': current_date == today,
|
||||
'readings': readings_by_day.get(current_date.date(), []),
|
||||
}
|
||||
@@ -167,22 +197,21 @@ def prepare_graph_data(readings):
|
||||
def calculate_progress_badges(readings):
|
||||
"""Generate badges based on user activity and milestones."""
|
||||
now = datetime.utcnow().date()
|
||||
streak_count, badges = 1, []
|
||||
badges = []
|
||||
|
||||
if not readings:
|
||||
return badges
|
||||
|
||||
sorted_readings = sorted(readings, key=lambda r: r.timestamp.date())
|
||||
# Use reversed() instead of re-sorting — readings come in desc order from DB
|
||||
sorted_readings = list(reversed(readings))
|
||||
streak_count = 1
|
||||
daily_streak = True
|
||||
|
||||
# Start with the first reading
|
||||
previous_date = sorted_readings[0].timestamp.date()
|
||||
|
||||
for reading in sorted_readings[1:]:
|
||||
current_date = reading.timestamp.date()
|
||||
|
||||
# Check for consecutive daily streaks
|
||||
if (current_date - previous_date).days == 1:
|
||||
streak_count += 1
|
||||
elif (current_date - previous_date).days > 1:
|
||||
@@ -190,7 +219,6 @@ def calculate_progress_badges(readings):
|
||||
|
||||
previous_date = current_date
|
||||
|
||||
# Add streak badges
|
||||
if daily_streak and streak_count >= 1:
|
||||
badges.append(f"Current Streak: {streak_count} Days")
|
||||
if daily_streak and streak_count >= 7:
|
||||
|
||||
@@ -67,7 +67,9 @@ def profile_image(user_id):
|
||||
response.headers.set('Content-Type', 'image/jpeg')
|
||||
response.headers.set('Cache-Control', 'public, max-age=86400') # Cache for 1 day
|
||||
response.headers.set('ETag', str(hash(profile.profile_pic))) # Unique ETag for the image
|
||||
response.headers.set('Last-Modified', http_date(datetime.utcnow().timestamp()))
|
||||
# Use actual profile update time instead of utcnow() which defeats caching
|
||||
last_modified = profile.updated_at or datetime.utcnow()
|
||||
response.headers.set('Last-Modified', http_date(last_modified.timestamp()))
|
||||
|
||||
return response
|
||||
else:
|
||||
|
||||
@@ -657,6 +657,10 @@ video {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.-mb-px {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -681,10 +685,6 @@ video {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.mr-3 {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
@@ -717,14 +717,6 @@ video {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.-mb-px {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.ml-0 {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
@@ -750,10 +742,18 @@ video {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.h-36 {
|
||||
height: 9rem;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
@@ -774,14 +774,6 @@ video {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.h-36 {
|
||||
height: 9rem;
|
||||
}
|
||||
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
@@ -862,10 +854,6 @@ video {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -932,12 +920,6 @@ video {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -979,21 +961,26 @@ video {
|
||||
border-color: rgb(37 99 235 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-white {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(255 255 255 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-green-50 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(240 253 244 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-white {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(255 255 255 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-blue-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
|
||||
@@ -1009,6 +996,11 @@ video {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-gray-200 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-gray-300 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
@@ -1105,6 +1097,11 @@ video {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
@@ -1120,6 +1117,11 @@ video {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.py-16 {
|
||||
padding-top: 4rem;
|
||||
padding-bottom: 4rem;
|
||||
@@ -1145,28 +1147,14 @@ video {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.px-0 {
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
.pl-1 {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.pl-2 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.pt-6 {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pl-1 {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.pr-2 {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
@@ -1175,8 +1163,8 @@ video {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
.pt-6 {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
@@ -1235,14 +1223,6 @@ video {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-light {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.leading-none {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(37 99 235 / var(--tw-text-opacity, 1));
|
||||
@@ -1355,6 +1335,11 @@ video {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-300:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-400:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1));
|
||||
@@ -1405,10 +1390,6 @@ video {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.hover\:no-underline:hover {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
@@ -1461,10 +1442,6 @@ video {
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:ml-0\.5 {
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
.sm\:block {
|
||||
display: block;
|
||||
}
|
||||
@@ -1476,18 +1453,9 @@ video {
|
||||
.sm\:h-40 {
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
.sm\:px-0\.5 {
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.md\:block {
|
||||
display: block;
|
||||
}
|
||||
@@ -1496,8 +1464,8 @@ video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md\:w-auto {
|
||||
width: auto;
|
||||
.md\:w-1\/3 {
|
||||
width: 33.333333%;
|
||||
}
|
||||
|
||||
.md\:grid-cols-2 {
|
||||
@@ -1515,18 +1483,9 @@ video {
|
||||
.md\:p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.md\:px-0\.5 {
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.lg\:block {
|
||||
display: block;
|
||||
}
|
||||
@@ -1551,11 +1510,6 @@ video {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lg\:px-0\.5 {
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
}
|
||||
|
||||
.lg\:pt-0 {
|
||||
padding-top: 0px;
|
||||
}
|
||||
@@ -1569,8 +1523,4 @@ video {
|
||||
.xl\:hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xl\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{% 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>
|
||||
@@ -32,7 +31,6 @@
|
||||
|
||||
<!-- Progress Badges -->
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-2">Progress Badges</h3>
|
||||
<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">
|
||||
@@ -42,46 +40,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ open: {{ 'true' if request.method == 'POST' else 'false' }} }"
|
||||
class="p-4 bg-white rounded-lg shadow-md">
|
||||
<!-- Collapsible Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-bold text-gray-800">Filter Readings</h3>
|
||||
<button @click="open = !open" class="text-blue-600 hover:underline flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 mr-2">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
|
||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
<span x-show="!open">Show Filters</span>
|
||||
<span x-show="open" x-cloak>Hide Filters</span>
|
||||
</button>
|
||||
</div>
|
||||
<div x-data="{ open: {{ 'true' if request.method == 'POST' else 'false' }} }" class="relative">
|
||||
<!-- Compact Icon -->
|
||||
<button @click="open = !open"
|
||||
class="bg-blue-600 text-white p-3 rounded-full shadow-lg focus:outline-none hover:bg-blue-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
|
||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Collapsible Content -->
|
||||
<form method="POST" action="{{ url_for('main.dashboard') }}" x-show="open" x-transition.duration.50ms
|
||||
class="mt-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Collapsible Filter Form -->
|
||||
<div x-show="open" x-transition.duration.300ms
|
||||
class="w-full md:w-1/3 bg-white p-6 rounded-lg shadow-lg border border-gray-200">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-800">Filter Readings</h3>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('main.dashboard') }}" class="space-y-4">
|
||||
<!-- Start Date -->
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700">Start Date</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
|
||||
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">
|
||||
<input type="date" name="start_date" id="start_date"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700">End Date</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
|
||||
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">
|
||||
<input type="date" name="end_date" id="end_date"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit"
|
||||
class="w-full md:w-auto bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Apply Button -->
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 rounded-lg font-semibold shadow-md hover:bg-blue-700 focus:outline-none">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="max-w-5xl mx-auto" x-data="{ activeView: 'list' }">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b mb-4">
|
||||
@@ -146,6 +148,32 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div x-show="activeView === 'list'" class="flex justify-center items-center gap-2 mt-6">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('main.dashboard', page=pagination.prev_num) }}"
|
||||
class="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm">« Prev</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
<a href="{{ url_for('main.dashboard', page=page_num) }}"
|
||||
class="px-3 py-1 rounded text-sm {% if page_num == pagination.page %}bg-blue-600 text-white{% else %}bg-gray-200 hover:bg-gray-300{% endif %}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400">…</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('main.dashboard', page=pagination.next_num) }}"
|
||||
class="px-3 py-1 rounded bg-gray-200 hover:bg-gray-300 text-sm">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Weekly View -->
|
||||
<div x-show="activeView === 'weekly'" class="grid grid-cols-7 text-center">
|
||||
{% for day in week %}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add reading index and profile updated_at
|
||||
|
||||
Revision ID: 8cfe56a1e597
|
||||
Revises: 5e5a1b78b966
|
||||
Create Date: 2026-03-09 21:21:44.291659
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8cfe56a1e597'
|
||||
down_revision = '5e5a1b78b966'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('profile', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('updated_at', sa.DateTime(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('reading', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_reading_user_timestamp', ['user_id', 'timestamp'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('reading', schema=None) as batch_op:
|
||||
batch_op.drop_index('ix_reading_user_timestamp')
|
||||
|
||||
with op.batch_alter_table('profile', schema=None) as batch_op:
|
||||
batch_op.drop_column('updated_at')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
Reference in New Issue
Block a user