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` `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 # 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: 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 typing import Optional
from flask_wtf import FlaskForm 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 wtforms.validators import DataRequired, Length, EqualTo, ValidationError, Email, Optional, NumberRange
from app.models import User from app.models import User
from datetime import datetime from datetime import datetime
@@ -85,6 +86,7 @@ class ProfileForm(FlaskForm):
name = StringField('Name', validators=[Optional()]) name = StringField('Name', validators=[Optional()])
email = StringField('Email', validators=[Optional(), Email()]) email = StringField('Email', validators=[Optional(), Email()])
profile_pic = FileField('Profile Picture (optional)') profile_pic = FileField('Profile Picture (optional)')
timezone = SelectField('Timezone', choices=[(tz, tz) for tz in all_timezones])
systolic_threshold = IntegerField( systolic_threshold = IntegerField(
'Systolic Threshold (mmHg)', 'Systolic Threshold (mmHg)',
validators=[Optional(), NumberRange(min=90, max=200)] 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) systolic_threshold = db.Column(db.Integer, default=140)
diastolic_threshold = db.Column(db.Integer, default=90) diastolic_threshold = db.Column(db.Integer, default=90)
dark_mode = db.Column(db.Boolean, default=False) dark_mode = db.Column(db.Boolean, default=False)
timezone = db.Column(db.String(50), default='UTC') # e.g., 'Australia/Sydney'
class Reading(db.Model): class Reading(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@@ -4,6 +4,7 @@ from io import StringIO
import io import io
from flask import Blueprint, Response, make_response, render_template, redirect, request, send_file, url_for, flash from flask import Blueprint, Response, make_response, render_template, redirect, request, send_file, url_for, flash
import humanize import humanize
from pytz import timezone, utc
from sqlalchemy import func from sqlalchemy import func
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.http import http_date from werkzeug.http import http_date
@@ -177,10 +178,15 @@ def dashboard():
# Fetch readings # Fetch readings
readings = readings_query.order_by(Reading.timestamp.desc()).all() 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() now = datetime.utcnow()
for reading in readings: for reading in readings:
reading.relative_timestamp = humanize.naturaltime(now - reading.timestamp) reading.relative_timestamp = humanize.naturaltime(now - reading.timestamp)
reading.local_timestamp = utc.localize(reading.timestamp).astimezone(local_tz)
# Calculate weekly summary and progress badges # Calculate weekly summary and progress badges
systolic_avg, diastolic_avg, heart_rate_avg = calculate_weekly_summary(readings) 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: if reading.user_id != current_user.id:
flash('You are not authorized to edit this reading.', 'danger') flash('You are not authorized to edit this reading.', 'danger')
return redirect(url_for('main.dashboard')) 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 = ReadingForm(obj=reading) # Populate form with existing reading data
form.timestamp.data = reading.local_timestamp
if form.validate_on_submit(): 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.systolic = form.systolic.data
reading.diastolic = form.diastolic.data reading.diastolic = form.diastolic.data
reading.heart_rate = form.heart_rate.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.systolic_threshold = form.systolic_threshold.data or profile.systolic_threshold
profile.diastolic_threshold = form.diastolic_threshold.data or profile.diastolic_threshold profile.diastolic_threshold = form.diastolic_threshold.data or profile.diastolic_threshold
profile.dark_mode = form.dark_mode.data profile.dark_mode = form.dark_mode.data
profile.timezone = form.timezone.data
# Handle profile picture upload # Handle profile picture upload
if form.profile_pic.data: if form.profile_pic.data:

View File

@@ -608,58 +608,42 @@ video {
position: relative; position: relative;
} }
.inset-0 { .bottom-0 {
inset: 0px; bottom: 0px;
}
.top-0 {
top: 0px;
}
.right-2 {
right: 0.5rem;
}
.top-2 {
top: 0.5rem;
}
.bottom-2 {
bottom: 0.5rem;
} }
.left-4 { .left-4 {
left: 1rem; left: 1rem;
} }
.right-0 {
right: 0px;
}
.right-2 {
right: 0.5rem;
}
.right-4 { .right-4 {
right: 1rem; right: 1rem;
} }
.top-0 {
top: 0px;
}
.top-2 {
top: 0.5rem;
}
.top-4 { .top-4 {
top: 1rem; top: 1rem;
} }
.top-6 {
top: 1.5rem;
}
.top-5 { .top-5 {
top: 1.25rem; top: 1.25rem;
} }
.bottom-0 {
bottom: 0px;
}
.left-0 {
left: 0px;
}
.right-0 {
right: 0px;
}
.z-10 { .z-10 {
z-index: 10; z-index: 10;
} }
@@ -673,16 +657,6 @@ video {
margin-right: auto; 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 { .mb-2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@@ -755,14 +729,6 @@ video {
display: flex; display: flex;
} }
.inline-flex {
display: inline-flex;
}
.table {
display: table;
}
.grid { .grid {
display: grid; display: grid;
} }
@@ -780,14 +746,14 @@ video {
height: 4rem; height: 4rem;
} }
.h-24 {
height: 6rem;
}
.h-4 { .h-4 {
height: 1rem; height: 1rem;
} }
.h-44 {
height: 11rem;
}
.h-5 { .h-5 {
height: 1.25rem; height: 1.25rem;
} }
@@ -800,34 +766,18 @@ video {
height: 2rem; height: 2rem;
} }
.h-3 {
height: 0.75rem;
}
.h-28 {
height: 7rem;
}
.h-32 {
height: 8rem;
}
.h-44 {
height: 11rem;
}
.w-16 { .w-16 {
width: 4rem; width: 4rem;
} }
.w-24 {
width: 6rem;
}
.w-4 { .w-4 {
width: 1rem; width: 1rem;
} }
.w-44 {
width: 11rem;
}
.w-5 { .w-5 {
width: 1.25rem; width: 1.25rem;
} }
@@ -844,26 +794,6 @@ video {
width: 100%; 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-w-4xl {
max-width: 56rem; max-width: 56rem;
} }
@@ -892,10 +822,6 @@ video {
flex-grow: 1; flex-grow: 1;
} }
.border-collapse {
border-collapse: collapse;
}
.transform { .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)); 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: 1rem;
} }
.gap-8 {
gap: 2rem;
}
.gap-6 { .gap-6 {
gap: 1.5rem; gap: 1.5rem;
} }
.gap-x-4 { .gap-8 {
-moz-column-gap: 1rem; gap: 2rem;
column-gap: 1rem;
} }
.space-x-2 > :not([hidden]) ~ :not([hidden]) { .space-x-2 > :not([hidden]) ~ :not([hidden]) {
@@ -983,24 +904,6 @@ video {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); 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 { .rounded {
border-radius: 0.25rem; border-radius: 0.25rem;
} }
@@ -1013,10 +916,6 @@ video {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.rounded-md {
border-radius: 0.375rem;
}
.border { .border {
border-width: 1px; border-width: 1px;
} }
@@ -1033,6 +932,11 @@ video {
border-bottom-width: 2px; 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 { .border-gray-300 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(209 213 219 / var(--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-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 { .bg-blue-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--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)); 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 { .bg-gradient-to-r {
background-image: linear-gradient(to right, var(--tw-gradient-stops)); background-image: linear-gradient(to right, var(--tw-gradient-stops));
} }
@@ -1141,6 +1025,14 @@ video {
object-fit: cover; object-fit: cover;
} }
.p-0 {
padding: 0px;
}
.p-1 {
padding: 0.25rem;
}
.p-2 { .p-2 {
padding: 0.5rem; padding: 0.5rem;
} }
@@ -1161,14 +1053,6 @@ video {
padding: 2rem; padding: 2rem;
} }
.p-1 {
padding: 0.25rem;
}
.p-0 {
padding: 0px;
}
.px-2 { .px-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
@@ -1189,11 +1073,6 @@ video {
padding-right: 2rem; padding-right: 2rem;
} }
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.py-16 { .py-16 {
padding-top: 4rem; padding-top: 4rem;
padding-bottom: 4rem; padding-bottom: 4rem;
@@ -1219,11 +1098,6 @@ video {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.pl-2 { .pl-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
} }
@@ -1232,10 +1106,6 @@ video {
padding-top: 1.5rem; padding-top: 1.5rem;
} }
.pt-2 {
padding-top: 0.5rem;
}
.text-left { .text-left {
text-align: left; text-align: left;
} }
@@ -1296,15 +1166,16 @@ video {
font-weight: 600; font-weight: 600;
} }
.uppercase {
text-transform: uppercase;
}
.text-blue-600 { .text-blue-600 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(37 99 235 / var(--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 { .text-gray-500 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(107 114 128 / var(--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)); 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 { .text-green-800 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(22 101 52 / var(--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 { .text-red-600 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(220 38 38 / var(--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)); 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 { .no-underline {
text-decoration-line: none; text-decoration-line: none;
} }
@@ -1401,12 +1257,6 @@ video {
transition-duration: 150ms; 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-shadow {
transition-property: box-shadow; transition-property: box-shadow;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 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)); 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; --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 { .hover\:bg-green-700:hover {
@@ -1448,41 +1298,11 @@ video {
background-color: rgb(185 28 28 / var(--tw-bg-opacity, 1)); 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 { .hover\:text-gray-200:hover {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(229 231 235 / var(--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 { .hover\:text-gray-600:hover {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(75 85 99 / var(--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)); 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 { .hover\:text-red-700:hover {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(185 28 28 / var(--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 { .hover\:underline:hover {
text-decoration-line: underline; text-decoration-line: underline;
} }
@@ -1517,16 +1347,6 @@ video {
border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); 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 { .focus\:text-white:focus {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--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)); --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 { .focus\:ring-green-500:focus {
--tw-ring-opacity: 1; --tw-ring-opacity: 1;
--tw-ring-color: rgb(34 197 94 / var(--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 { .group:hover .group-hover\:scale-105 {
--tw-scale-x: 1.05; --tw-scale-x: 1.05;
--tw-scale-y: 1.05; --tw-scale-y: 1.05;
@@ -1573,12 +1383,6 @@ video {
text-decoration-line: underline; text-decoration-line: underline;
} }
@media (min-width: 640px) {
.sm\:p-0 {
padding: 0px;
}
}
@media (min-width: 768px) { @media (min-width: 768px) {
.md\:w-auto { .md\:w-auto {
width: auto; width: auto;

View File

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

View File

@@ -57,6 +57,12 @@
focus:ring-blue-500") }} focus:ring-blue-500") }}
</div> </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"> <div class="mb-4 flex items-center">
{{ form.dark_mode }} {{ form.dark_mode }}
{{ form.dark_mode.label(class="ml-2 text-sm font-medium text-gray-700") }} {{ 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 pillow==11.0.0
psycopg2==2.9.10 psycopg2==2.9.10
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
pytz==2024.2
SQLAlchemy==2.0.36 SQLAlchemy==2.0.36
typing-extensions==4.12.2 typing-extensions==4.12.2
werkzeug==3.1.3 werkzeug==3.1.3