Add support for setting user timezone

This commit is contained in:
Peter Stockings
2024-12-28 01:11:11 +11:00
parent 32f810a8b3
commit 2caddf52fe
9 changed files with 147 additions and 270 deletions

View File

@@ -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:

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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:

View File

@@ -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;

View File

@@ -107,7 +107,7 @@
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m9-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
</svg>
<span title="{{ reading.timestamp.strftime('%d %b %Y, %I:%M %p') }}">
<span title="{{ reading.local_timestamp.strftime('%d %b %Y, %I:%M %p') }}">
{{ reading.relative_timestamp }}
</span>
</div>
@@ -163,7 +163,7 @@
<p class="text-xs text-gray-600 mt-1">{{ reading.heart_rate }} bpm</p>
<!-- Timestamp -->
<div class="text-xs text-gray-500 mt-1">
{{ reading.timestamp.strftime('%I:%M %p') }}
{{ reading.local_timestamp.strftime('%I:%M %p') }}
</div>
</a>
{% endfor %}
@@ -222,7 +222,7 @@
<p class="text-xs text-gray-600 mt-1">{{ reading.heart_rate }} bpm</p>
<!-- Timestamp -->
<div class="text-xs text-gray-500 mt-1">
{{ reading.timestamp.strftime('%I:%M %p') }}
{{ reading.local_timestamp.strftime('%I:%M %p') }}
</div>
</a>
{% endfor %}

View File

@@ -57,6 +57,12 @@
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") }}

View File

@@ -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 ###

View File

@@ -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