From 2caddf52fe634a5efa29160120bda00fa987f1a3 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sat, 28 Dec 2024 01:11:11 +1100 Subject: [PATCH] Add support for setting user timezone --- Readme.md | 14 + app/forms.py | 4 +- app/models.py | 1 + app/routes.py | 21 +- app/static/css/tailwind.css | 332 ++++-------------- app/templates/dashboard.html | 6 +- app/templates/profile.html | 6 + ...a1b78b966_add_timezone_to_profile_model.py | 32 ++ requirements.txt | 1 + 9 files changed, 147 insertions(+), 270 deletions(-) create mode 100644 migrations/versions/5e5a1b78b966_add_timezone_to_profile_model.py diff --git a/Readme.md b/Readme.md index ae62200..c6fb9af 100644 --- a/Readme.md +++ b/Readme.md @@ -30,6 +30,20 @@ `docker run -p 5000:5000 -e DATABASE_URL=postgres://postgres:59fff56880e1bbb42e753d2a82ac21b6@peterstockings.com:15389/bloodpressure_db bloodpressure` +# Model updates + +Create migration + +``` +flask db migrate -m "Add timezone to Profile model" +``` + +Apply migration + +``` +flask db upgrade +``` + # Fix deployment issues Because I was originally using a Heroku buildpack to build/host this app prior to switching to a Dockerfile it sets the ports to: diff --git a/app/forms.py b/app/forms.py index 2f054cd..70ec89a 100644 --- a/app/forms.py +++ b/app/forms.py @@ -1,6 +1,7 @@ from typing import Optional from flask_wtf import FlaskForm -from wtforms import BooleanField, FileField, StringField, PasswordField, SubmitField, IntegerField, DateTimeLocalField +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 @@ -85,6 +86,7 @@ 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)] diff --git a/app/models.py b/app/models.py index 82383d8..e787c3e 100644 --- a/app/models.py +++ b/app/models.py @@ -18,6 +18,7 @@ class Profile(db.Model): systolic_threshold = db.Column(db.Integer, default=140) 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' class Reading(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/routes.py b/app/routes.py index d35f94b..63aeb21 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,6 +4,7 @@ from io import StringIO import io from flask import Blueprint, Response, make_response, render_template, redirect, request, send_file, url_for, flash import humanize +from pytz import timezone, utc from sqlalchemy import func from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.http import http_date @@ -177,10 +178,15 @@ def dashboard(): # Fetch readings readings = readings_query.order_by(Reading.timestamp.desc()).all() - # Add relative timestamps to readings + # Fetch the user's timezone (default to 'UTC' if none is set) + user_timezone = current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC' + local_tz = timezone(user_timezone) + + # Add relative & local 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(local_tz) # Calculate weekly summary and progress badges systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary(readings) @@ -248,10 +254,20 @@ def edit_reading(reading_id): if reading.user_id != current_user.id: flash('You are not authorized to edit this reading.', 'danger') return redirect(url_for('main.dashboard')) + + # Fetch the user's timezone (default to 'UTC' if none is set) + user_timezone = current_user.profile.timezone if current_user.profile and current_user.profile.timezone else 'UTC' + local_tz = timezone(user_timezone) + + reading.local_timestamp = utc.localize(reading.timestamp).astimezone(local_tz) form = ReadingForm(obj=reading) # Populate form with existing reading data + form.timestamp.data = reading.local_timestamp if form.validate_on_submit(): - reading.timestamp = form.timestamp.data + # Convert the local timestamp back to UTC for saving + local_timestamp = form.timestamp.data + reading.timestamp = user_timezone.localize(local_timestamp).astimezone(utc) + reading.systolic = form.systolic.data reading.diastolic = form.diastolic.data reading.heart_rate = form.heart_rate.data @@ -306,6 +322,7 @@ def profile(): 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: diff --git a/app/static/css/tailwind.css b/app/static/css/tailwind.css index 3bc80b1..f010bb8 100644 --- a/app/static/css/tailwind.css +++ b/app/static/css/tailwind.css @@ -608,58 +608,42 @@ video { position: relative; } -.inset-0 { - inset: 0px; -} - -.top-0 { - top: 0px; -} - -.right-2 { - right: 0.5rem; -} - -.top-2 { - top: 0.5rem; -} - -.bottom-2 { - bottom: 0.5rem; +.bottom-0 { + bottom: 0px; } .left-4 { left: 1rem; } +.right-0 { + right: 0px; +} + +.right-2 { + right: 0.5rem; +} + .right-4 { right: 1rem; } +.top-0 { + top: 0px; +} + +.top-2 { + top: 0.5rem; +} + .top-4 { top: 1rem; } -.top-6 { - top: 1.5rem; -} - .top-5 { top: 1.25rem; } -.bottom-0 { - bottom: 0px; -} - -.left-0 { - left: 0px; -} - -.right-0 { - right: 0px; -} - .z-10 { z-index: 10; } @@ -673,16 +657,6 @@ video { margin-right: auto; } -.my-2 { - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} - -.my-3 { - margin-top: 0.75rem; - margin-bottom: 0.75rem; -} - .mb-2 { margin-bottom: 0.5rem; } @@ -755,14 +729,6 @@ video { display: flex; } -.inline-flex { - display: inline-flex; -} - -.table { - display: table; -} - .grid { display: grid; } @@ -780,14 +746,14 @@ video { height: 4rem; } -.h-24 { - height: 6rem; -} - .h-4 { height: 1rem; } +.h-44 { + height: 11rem; +} + .h-5 { height: 1.25rem; } @@ -800,34 +766,18 @@ video { height: 2rem; } -.h-3 { - height: 0.75rem; -} - -.h-28 { - height: 7rem; -} - -.h-32 { - height: 8rem; -} - -.h-44 { - height: 11rem; -} - .w-16 { width: 4rem; } -.w-24 { - width: 6rem; -} - .w-4 { width: 1rem; } +.w-44 { + width: 11rem; +} + .w-5 { width: 1.25rem; } @@ -844,26 +794,6 @@ video { width: 100%; } -.w-3 { - width: 0.75rem; -} - -.w-28 { - width: 7rem; -} - -.w-32 { - width: 8rem; -} - -.w-44 { - width: 11rem; -} - -.max-w-2xl { - max-width: 42rem; -} - .max-w-4xl { max-width: 56rem; } @@ -892,10 +822,6 @@ video { flex-grow: 1; } -.border-collapse { - border-collapse: collapse; -} - .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } @@ -940,17 +866,12 @@ video { gap: 1rem; } -.gap-8 { - gap: 2rem; -} - .gap-6 { gap: 1.5rem; } -.gap-x-4 { - -moz-column-gap: 1rem; - column-gap: 1rem; +.gap-8 { + gap: 2rem; } .space-x-2 > :not([hidden]) ~ :not([hidden]) { @@ -983,24 +904,6 @@ video { margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); } -.overflow-hidden { - overflow: hidden; -} - -.overflow-x-auto { - overflow-x: auto; -} - -.truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.whitespace-nowrap { - white-space: nowrap; -} - .rounded { border-radius: 0.25rem; } @@ -1013,10 +916,6 @@ video { border-radius: 0.5rem; } -.rounded-md { - border-radius: 0.375rem; -} - .border { border-width: 1px; } @@ -1033,6 +932,11 @@ video { border-bottom-width: 2px; } +.border-blue-600 { + --tw-border-opacity: 1; + border-color: rgb(37 99 235 / var(--tw-border-opacity, 1)); +} + .border-gray-300 { --tw-border-opacity: 1; border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); @@ -1043,16 +947,6 @@ video { border-color: rgb(255 255 255 / var(--tw-border-opacity, 1)); } -.border-blue-600 { - --tw-border-opacity: 1; - border-color: rgb(37 99 235 / var(--tw-border-opacity, 1)); -} - -.border-green-200 { - --tw-border-opacity: 1; - border-color: rgb(187 247 208 / var(--tw-border-opacity, 1)); -} - .bg-blue-600 { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); @@ -1108,16 +1002,6 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); } -.bg-blue-100 { - --tw-bg-opacity: 1; - background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); -} - -.bg-green-50 { - --tw-bg-opacity: 1; - background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1)); -} - .bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)); } @@ -1141,6 +1025,14 @@ video { object-fit: cover; } +.p-0 { + padding: 0px; +} + +.p-1 { + padding: 0.25rem; +} + .p-2 { padding: 0.5rem; } @@ -1161,14 +1053,6 @@ video { padding: 2rem; } -.p-1 { - padding: 0.25rem; -} - -.p-0 { - padding: 0px; -} - .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; @@ -1189,11 +1073,6 @@ video { padding-right: 2rem; } -.py-1 { - padding-top: 0.25rem; - padding-bottom: 0.25rem; -} - .py-16 { padding-top: 4rem; padding-bottom: 4rem; @@ -1219,11 +1098,6 @@ video { padding-bottom: 1rem; } -.px-3 { - padding-left: 0.75rem; - padding-right: 0.75rem; -} - .pl-2 { padding-left: 0.5rem; } @@ -1232,10 +1106,6 @@ video { padding-top: 1.5rem; } -.pt-2 { - padding-top: 0.5rem; -} - .text-left { text-align: left; } @@ -1296,15 +1166,16 @@ video { font-weight: 600; } -.uppercase { - text-transform: uppercase; -} - .text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity, 1)); } +.text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity, 1)); +} + .text-gray-500 { --tw-text-opacity: 1; color: rgb(107 114 128 / var(--tw-text-opacity, 1)); @@ -1325,11 +1196,21 @@ video { color: rgb(31 41 55 / var(--tw-text-opacity, 1)); } +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity, 1)); +} + .text-green-800 { --tw-text-opacity: 1; color: rgb(22 101 52 / var(--tw-text-opacity, 1)); } +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); +} + .text-red-600 { --tw-text-opacity: 1; color: rgb(220 38 38 / var(--tw-text-opacity, 1)); @@ -1340,31 +1221,6 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } -.text-gray-400 { - --tw-text-opacity: 1; - color: rgb(156 163 175 / var(--tw-text-opacity, 1)); -} - -.text-green-600 { - --tw-text-opacity: 1; - color: rgb(22 163 74 / var(--tw-text-opacity, 1)); -} - -.text-red-500 { - --tw-text-opacity: 1; - color: rgb(239 68 68 / var(--tw-text-opacity, 1)); -} - -.text-blue-800 { - --tw-text-opacity: 1; - color: rgb(30 64 175 / var(--tw-text-opacity, 1)); -} - -.text-green-700 { - --tw-text-opacity: 1; - color: rgb(21 128 61 / var(--tw-text-opacity, 1)); -} - .no-underline { text-decoration-line: none; } @@ -1401,12 +1257,6 @@ video { transition-duration: 150ms; } -.transition-all { - transition-property: all; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - .transition-shadow { transition-property: box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); @@ -1433,9 +1283,9 @@ video { background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); } -.hover\:bg-gray-50:hover { +.hover\:bg-green-200:hover { --tw-bg-opacity: 1; - background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); + background-color: rgb(187 247 208 / var(--tw-bg-opacity, 1)); } .hover\:bg-green-700:hover { @@ -1448,41 +1298,11 @@ video { background-color: rgb(185 28 28 / var(--tw-bg-opacity, 1)); } -.hover\:bg-green-200:hover { - --tw-bg-opacity: 1; - background-color: rgb(187 247 208 / var(--tw-bg-opacity, 1)); -} - -.hover\:bg-green-100:hover { - --tw-bg-opacity: 1; - background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1)); -} - -.hover\:text-blue-800:hover { - --tw-text-opacity: 1; - color: rgb(30 64 175 / var(--tw-text-opacity, 1)); -} - .hover\:text-gray-200:hover { --tw-text-opacity: 1; color: rgb(229 231 235 / var(--tw-text-opacity, 1)); } -.hover\:text-gray-900:hover { - --tw-text-opacity: 1; - color: rgb(17 24 39 / var(--tw-text-opacity, 1)); -} - -.hover\:text-red-800:hover { - --tw-text-opacity: 1; - color: rgb(153 27 27 / var(--tw-text-opacity, 1)); -} - -.hover\:text-white:hover { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity, 1)); -} - .hover\:text-gray-600:hover { --tw-text-opacity: 1; color: rgb(75 85 99 / var(--tw-text-opacity, 1)); @@ -1493,11 +1313,21 @@ video { color: rgb(31 41 55 / var(--tw-text-opacity, 1)); } +.hover\:text-gray-900:hover { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); +} + .hover\:text-red-700:hover { --tw-text-opacity: 1; color: rgb(185 28 28 / var(--tw-text-opacity, 1)); } +.hover\:text-white:hover { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -1517,16 +1347,6 @@ video { border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); } -.focus\:border-gray-400:focus { - --tw-border-opacity: 1; - border-color: rgb(156 163 175 / var(--tw-border-opacity, 1)); -} - -.focus\:border-red-500:focus { - --tw-border-opacity: 1; - border-color: rgb(239 68 68 / var(--tw-border-opacity, 1)); -} - .focus\:text-white:focus { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity, 1)); @@ -1548,21 +1368,11 @@ video { --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1)); } -.focus\:ring-gray-400:focus { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(156 163 175 / var(--tw-ring-opacity, 1)); -} - .focus\:ring-green-500:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1)); } -.focus\:ring-red-500:focus { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity, 1)); -} - .group:hover .group-hover\:scale-105 { --tw-scale-x: 1.05; --tw-scale-y: 1.05; @@ -1573,12 +1383,6 @@ video { text-decoration-line: underline; } -@media (min-width: 640px) { - .sm\:p-0 { - padding: 0px; - } -} - @media (min-width: 768px) { .md\:w-auto { width: auto; diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index de28e81..9573657 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -107,7 +107,7 @@ - + {{ reading.relative_timestamp }} @@ -163,7 +163,7 @@

{{ reading.heart_rate }} bpm

- {{ reading.timestamp.strftime('%I:%M %p') }} + {{ reading.local_timestamp.strftime('%I:%M %p') }}
{% endfor %} @@ -222,7 +222,7 @@

{{ reading.heart_rate }} bpm

- {{ reading.timestamp.strftime('%I:%M %p') }} + {{ reading.local_timestamp.strftime('%I:%M %p') }}
{% endfor %} diff --git a/app/templates/profile.html b/app/templates/profile.html index 48d8b27..02cc65c 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -57,6 +57,12 @@ focus:ring-blue-500") }} +
+ {{ 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") + }} +
+
{{ form.dark_mode }} {{ form.dark_mode.label(class="ml-2 text-sm font-medium text-gray-700") }} diff --git a/migrations/versions/5e5a1b78b966_add_timezone_to_profile_model.py b/migrations/versions/5e5a1b78b966_add_timezone_to_profile_model.py new file mode 100644 index 0000000..5c0b10b --- /dev/null +++ b/migrations/versions/5e5a1b78b966_add_timezone_to_profile_model.py @@ -0,0 +1,32 @@ +"""Add timezone to Profile model + +Revision ID: 5e5a1b78b966 +Revises: 59097bee8942 +Create Date: 2024-12-28 00:46:52.941616 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5e5a1b78b966' +down_revision = '59097bee8942' +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('timezone', sa.String(length=50), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('profile', schema=None) as batch_op: + batch_op.drop_column('timezone') + + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index c0d8f5c..cee4eec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ packaging==24.2 pillow==11.0.0 psycopg2==2.9.10 psycopg2-binary==2.9.10 +pytz==2024.2 SQLAlchemy==2.0.36 typing-extensions==4.12.2 werkzeug==3.1.3