Fix timezone issues; add user-friendly due date entry; add client-side auto-update to the table fields

Closes #15 and makes the UI for making and viewing task info quite a bit nicer.
This commit is contained in:
digimint 2025-11-21 16:26:20 -06:00
parent 9136628cd3
commit 991717687b
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
22 changed files with 1006 additions and 79 deletions

View file

@ -9,6 +9,8 @@
- humanize (for generating human-readable timedeltas)
- nh3 (for markdown and general HTML sanitization)
- scour (for SVG icon processing)
- requests
- markdown
## Already Included
- Certain tag definitions from bleach-allowlist, used in markdown sanitization.

View file

@ -1,6 +1,4 @@
from datetime import datetime
import logging
import humanize
from typing import Any
from flask import Flask, render_template, url_for
@ -11,6 +9,7 @@ from taskflower.api import APIBase
from taskflower.db.model.user import User
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
from taskflower.tools.icons import get_icon, svg_bp
from taskflower.util.time import render_abstime, render_reltime
from taskflower.web import web_base
from taskflower.tools.hibp import hibp_bp
@ -59,21 +58,6 @@ def template_utility_fns():
+ ')'
)
def reltime(until: datetime) -> str:
''' Turn a timestamp into a human-readable relative value, relative
to the moment this function is called.
'''
now = datetime.now()
delta = now - until
if now > until:
return humanize.naturaldelta(
delta
) + ' ago'
else:
return 'in ' + humanize.naturaldelta(
delta
)
def can_generate_sign_up_codes(usr: User) -> bool:
match config.sign_up_mode:
case SignUpMode.OPEN:
@ -98,7 +82,8 @@ def template_utility_fns():
return dict(
literal_call=literal_call,
reltime=reltime,
reltime=render_reltime,
abstime=render_abstime,
can_generate_sign_up_codes=can_generate_sign_up_codes,
render_as_markdown=render_as_markdown,
icon=icon

View file

@ -134,8 +134,8 @@ def _create_user_role(user: User) -> Either[Exception, UserRole]:
def _gen_user_namespace(user: User) -> Either[Exception, Namespace]:
try:
new_ns = Namespace(
name=f'{user.display_name}\'s Namespace'[:64], # pyright:ignore[reportCallIssue]
description='Your default namespace!' # pyright:ignore[reportCallIssue]
name=f'{user.display_name}\'s Zone'[:64], # pyright:ignore[reportCallIssue]
description='Your default zone!' # pyright:ignore[reportCallIssue]
)
db.session.add(new_ns)

View file

@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import timedelta
import logging
from secrets import token_hex
from flask_sqlalchemy import SQLAlchemy
@ -8,6 +8,7 @@ from taskflower.config import SignUpMode, config
from taskflower.db.model.codes import SignUpCode
from taskflower.db.model.user import User
from taskflower.types.either import Either, Left, Right
from taskflower.util.time import now
log = logging.getLogger(__name__)
@ -17,7 +18,7 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
return db.session.query(
SignUpCode
).filter(
SignUpCode.expires > datetime.now()
SignUpCode.expires > now()
).filter(
SignUpCode.grants_admin
).filter(
@ -30,7 +31,7 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
delete_count = db.session.query(
SignUpCode
).filter(
SignUpCode.expires > datetime.now()
SignUpCode.expires > now()
).filter(
SignUpCode.grants_admin
).filter(
@ -63,8 +64,8 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
else:
code = SignUpCode(
code=token_hex(16), # pyright:ignore[reportCallIssue]
created=datetime.now(), # pyright:ignore[reportCallIssue]
expires=datetime.now() + timedelta(hours=1), # pyright:ignore[reportCallIssue]
created=now(), # pyright:ignore[reportCallIssue]
expires=now() + timedelta(hours=1), # pyright:ignore[reportCallIssue]
created_by=-1, # pyright:ignore[reportCallIssue]
grants_admin=True # pyright:ignore[reportCallIssue]
)

View file

@ -108,7 +108,7 @@ class HIBPLocalCacheMode(EnumFromEnv):
@dataclass(frozen=True)
class ConfigType:
# Application secrets
db_secret : str # Secret value used to 'pepper' password hashes. This MUST
db_secret : str = 'potato' # Secret value used to 'pepper' password hashes. This MUST
# be generated randomly and cryptographically securely.
# For an example of how to do this:
# https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY
@ -116,7 +116,7 @@ class ConfigType:
# In a multi-node environment, this key must be the same
# across all nodes.
app_secret : str # Secret value used to generate session tokens. This should
app_secret : str = 'potato' # Secret value used to generate session tokens. This should
# ALSO be generated securely, and it should be different from
# ``db_secret``

View file

@ -1,23 +1,23 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.sql import func
from sqlalchemy.orm import Mapped, mapped_column
from taskflower.db import db
from taskflower.util.time import now
class SignUpCode(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(32), unique=True)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
expires: Mapped[datetime] = mapped_column(DateTime(timezone=False))
grants_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
class NamespaceInviteCode(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(32), unique=True)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
expires: Mapped[datetime] = mapped_column(DateTime(timezone=False))
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
for_role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id'))
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
for_role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id', ondelete='CASCADE'))

View file

@ -1,8 +1,9 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from taskflower.db import db
from taskflower.util.time import now
class Namespace(db.Model):
@ -11,12 +12,12 @@ class Namespace(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64))
description: Mapped[str] = mapped_column(String)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
class TaskToNamespace(db.Model):
__tablename__: str = 'task_to_namespace'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id'))
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE'))

View file

@ -1,7 +1,8 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from taskflower.db import db
from taskflower.util.time import now
class NamespaceRole(db.Model):
@ -12,15 +13,15 @@ class NamespaceRole(db.Model):
permissions: Mapped[int] = mapped_column(Integer)
perms_deny: Mapped[int] = mapped_column(Integer)
priority: Mapped[int] = mapped_column(Integer)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
class UserToNamespaceRole(db.Model):
__tablename__: str = 'user_to_namespace_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id'))
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id', ondelete='CASCADE'))
class UserRole(db.Model):
__tablename__: str = 'user_role'
@ -30,12 +31,12 @@ class UserRole(db.Model):
permissions: Mapped[int] = mapped_column(Integer)
perms_deny: Mapped[int] = mapped_column(Integer)
priority: Mapped[int] = mapped_column(Integer)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
class UserToUserRole(db.Model):
__tablename__: str = 'user_to_user_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
role: Mapped[int] = mapped_column(Integer, ForeignKey('user_role.id'))
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
role: Mapped[int] = mapped_column(Integer, ForeignKey('user_role.id', ondelete='CASCADE'))

View file

@ -1,9 +1,9 @@
from datetime import datetime
from sqlalchemy.sql import func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime
from taskflower.db import db
from taskflower.util.time import now
class Task(db.Model):
__tablename__: str = 'task'
@ -11,7 +11,8 @@ class Task(db.Model):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64))
description: Mapped[str] = mapped_column(String)
due: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
due: Mapped[datetime] = mapped_column(DateTime(timezone=False))
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
complete: Mapped[bool] = mapped_column(Boolean, default=False)
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
owner: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))

View file

@ -2,11 +2,12 @@ from datetime import datetime
from typing import override
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from taskflower.db import db
from flask_login import UserMixin # pyright:ignore[reportMissingTypeStubs]
from taskflower.util.time import now
class User(db.Model, UserMixin):
__tablename__: str = 'user'
@ -14,7 +15,7 @@ class User(db.Model, UserMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(32), unique=True)
display_name: Mapped[str] = mapped_column(String(256))
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
# Status
enabled: Mapped[bool] = mapped_column(Boolean)

View file

@ -1,5 +1,7 @@
from datetime import timezone
from typing import override
from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators
from zoneinfo import ZoneInfo
from wtforms import Field, Form, SelectField, StringField, TextAreaField, ValidationError, validators
from taskflower.auth.permission import NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task
@ -12,6 +14,35 @@ from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
from taskflower.types import ann
from taskflower.types.either import Either, Left, Right
from taskflower.types.option import Option
from taskflower.util.time import from_sophont_provided_data
def is_valid_iana(_: Form, field: Field):
if not isinstance(field.data, str): # pyright:ignore[reportAny]
raise ValidationError('Incorrect type')
try:
ZoneInfo(field.data) # pyright:ignore[reportUnusedCallResult]
except Exception:
raise ValidationError('Invalid timezone.')
def is_valid_timestamp(form: Form, field: Field):
if not isinstance(field.data, str): # pyright:ignore[reportAny]
raise ValidationError('Incorrect type')
if (
(not hasattr(form, 'timezone'))
or (not isinstance(tzfld:=getattr(form, 'timezone'), Field)) # pyright:ignore[reportAny]
or (not isinstance(tzdata:=tzfld.data, str)) # pyright:ignore[reportAny]
):
raise ValidationError('Invalid timezone.')
res = from_sophont_provided_data(
field.data,
tzdata
)
if isinstance(res, Left):
raise ValidationError(f'Parse failure: {res.val!s}')
def task_edit_form_for_task(
t: Task
@ -28,12 +59,20 @@ def task_edit_form_for_task(
],
default=t.name
)
due: DateTimeLocalField = DateTimeLocalField(
due: StringField = StringField(
'Due Date',
[
validators.DataRequired()
validators.Optional(),
is_valid_timestamp
],
default=t.due # pyright:ignore[reportArgumentType]
default=None
)
timezone: StringField = StringField(
'Timezone, in IANA format',
[
is_valid_iana
],
default='Etc/UTC'
)
description: TextAreaField = TextAreaField(
'Description',
@ -47,7 +86,15 @@ def task_edit_form_for_task(
def edit_object(self, current_user: User, target_object: Task) -> Either[Exception, Task]:
def _do_edit(tsk: Task) -> Either[Exception, Task]:
tsk.name = ann(self.name.data)
tsk.due = ann(self.due.data)
if self.due.data:
# We already check the validity during validation,
# so this call should always be Right
tsk.due = from_sophont_provided_data(
self.due.data,
ann(self.timezone.data)
).assert_right().val.astimezone(timezone.utc)
tsk.description = (
self.description.data
) if self.description.data else ''
@ -94,12 +141,19 @@ def task_form_for_user(
)
]
)
due: DateTimeLocalField = DateTimeLocalField(
due: StringField = StringField(
'Due Date',
[
validators.DataRequired()
is_valid_timestamp
]
)
timezone: StringField = StringField(
'Timezone, in IANA format',
[
is_valid_iana
],
default='Etc/UTC'
)
namespace: SelectField = SelectField(
'Select a namespace',
[
@ -121,6 +175,16 @@ def task_form_for_user(
current_user: User
) -> Either[Exception, Task]:
if self.validate():
due_date_parsed = from_sophont_provided_data(
ann(self.due.data),
ann(self.timezone.data)
).assert_right().val.astimezone(timezone.utc)
print(f'Timezone is: {self.timezone.data}')
print(f'Time is {due_date_parsed.astimezone(ZoneInfo("America/Chicago"))!s}')
print(f'Time (UTC) is {due_date_parsed!s}')
print(f'Timestamp is {int(due_date_parsed.timestamp())}')
return Option[Namespace].encapsulate(
db.session.query(
Namespace
@ -140,13 +204,14 @@ def task_form_for_user(
).map(
lambda ns: Task(
name=self.name.data, # pyright:ignore[reportCallIssue]
due=self.due.data, # pyright:ignore[reportCallIssue]
due=due_date_parsed, # pyright:ignore[reportCallIssue]
description=( # pyright:ignore[reportCallIssue]
self.description.data
if self.description.data
else ''
),
namespace=ns.id # pyright:ignore[reportCallIssue]
namespace=ns.id, # pyright:ignore[reportCallIssue]
owner=current_user.id # pyright:ignore[reportCallIssue]
)
)
else:

View file

@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from datetime import timedelta
from secrets import token_hex
from typing import override
from wtforms import SelectField
@ -15,6 +15,7 @@ from taskflower.form import FormCreatesObjectWithUserAndNamespace
from taskflower.types import ann
from taskflower.types.either import Either, Left, Right, gather_successes
from taskflower.types.option import Option
from taskflower.util.time import now
def gen_namespace_invite(
@ -37,7 +38,7 @@ def gen_namespace_invite(
code=token_hex(16), # pyright:ignore[reportCallIssue]
created_by=creator.id, # pyright:ignore[reportCallIssue]
for_role=role.id, # pyright:ignore[reportCallIssue]
expires=datetime.now() + timedelta(days=7) # pyright:ignore[reportCallIssue]
expires=now() + timedelta(days=7) # pyright:ignore[reportCallIssue]
)
)

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from typing import Self
import humanize
@ -10,11 +10,13 @@ from taskflower.auth.permission.lookups import get_user_perms_on_task
from taskflower.db.model.task import Task
from taskflower.db.model.user import User
from taskflower.types.either import Either
from taskflower.util.time import ensure_timezone_aware, now
def _due_str(due: datetime) -> str:
now = datetime.now()
delta = datetime.now() - due
if now > due:
due = ensure_timezone_aware(due)
cur_dt = now()
delta = now() - due
if cur_dt > due:
return humanize.naturaldelta(
delta
) + ' ago'
@ -65,7 +67,7 @@ class TaskForUser:
tsk.id,
tsk.name,
tsk.description,
tsk.due,
tsk.due.replace(tzinfo=timezone.utc),
_due_str(tsk.due),
tsk.created,
tsk.complete,

View file

@ -0,0 +1,173 @@
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
]
const numbers = [
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
]
function day_suffix(day){
switch(day){
case 1:
return 'st'
case 2:
return 'nd'
case 3:
return 'rd'
default:
if((day % 100) > 20){
return day_suffix(day % 10)
}else{
return 'th'
}
}
}
function _pad(v){
return `${v}`.padStart(2, '0')
}
function long_datetime(date){
return `${months[date.getMonth()]} ${date.getDay()}${day_suffix(date.getDay())}, ${date.getFullYear()} at ${date.getHours()}:${_pad(date.getMinutes())}`
}
function short_datetime(date){
return `${months[date.getMonth()]} ${date.getDay()}${day_suffix(date.getDay())} at ${date.getHours()}:${_pad(date.getMinutes())}`
}
function render_abstimes(){
Array.from(document.querySelectorAll(
'.render-timestamp'
)).forEach(
(el) => {
const el_time = new Date(Number(el.dataset.timestamp)*1000)
el.textContent = `${long_datetime(el_time)}`
el.classList.remove('render-timestamp') // Timestamps only need to be rendered to the local timezone once.
}
)
}
function textize_number(num){
switch(num){
case 1:
return 'one'
case 2:
return 'two'
case 3:
return 'three'
case 4:
return 'four'
case 5:
return 'five'
case 6:
return 'six'
case 7:
}
}
function sophontize(delta, literal){
const isFuture = delta > 0
const absDelta = Math.floor(Math.abs(delta) / 1000)
const seconds = absDelta % 60
const minutes = Math.floor(absDelta/60) % 60
const hours = Math.floor(absDelta/(60*60)) % 24
const days = Math.floor(absDelta/(60*60*24))
const weeks = Math.floor(days/7)
function _cap(v){
return v.charAt(0).toUpperCase() + v.slice(1)
}
function _relative(v){
if(isFuture){
return `In ${v}`
}else{
return _cap(`${v} ago`)
}
}
function _plur(v){
if(v == 1){
return ''
}else{
return 's'
}
}
function _num(v){
if(v < 3){
return numbers[v]
}else{
return v.toString()
}
}
if(days > 365){
return long_datetime(literal)
}else if(days > 28){
return short_datetime(literal)
}else if(weeks >= 1){
return _relative(`${_num(weeks)} week${_plur(weeks)}`)
}else if(days >= 1){
return _relative(`${_num(days)} day${_plur(days)}`)
}else if(hours >= 1){
return _relative(`${_num(hours)} hour${_plur(hours)}`)
}else if(minutes >= 5){
return _relative(`${_num(minutes)} minute${_plur(minutes)}`)
}else if(seconds >= 1 && isFuture){
return _relative(`${_pad(minutes)}:${_pad(seconds)}`)
}else if(minutes >= 1){
return _relative(`${_num(minutes)} minute${_plur(minutes)}`)
}else if(seconds >= 1){
return _relative(`${_num(seconds)} second${_plur(seconds)}`)
}else{
return 'Now'
}
}
function update_reltimes(){
Array.from(
document.querySelectorAll(
'.render-delta'
)
).forEach((el) => {
const time = new Date(Number(el.dataset.timestamp)*1000)
const delta = time - Date.now()
el.textContent = sophontize(delta, time)
})
}
function check_and_fill_timezone(){
const el = document.getElementById('timezone')
if(el){
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
console.log(`User timezone detected as ${tz}. Filling it in automatically.`)
el.value = tz
document.getElementById('tz-container').style = 'display: none;'
}
}
render_abstimes()
update_reltimes()
check_and_fill_timezone()
setInterval(update_reltimes, 1000)

View file

@ -3,6 +3,7 @@
<head>
{% block head %}
<link rel="stylesheet" href="/static/style.css" />
<script type="text/javascript" src="/static/time-utils.js" defer></script>
<title>{% block raw_title %}{% endblock %}</title>
{% block head_extras %}{% endblock %}
{% endblock %}

View file

@ -14,6 +14,7 @@
{{ render_field(form.name) }}
{{ render_field(form.due) }}
{{ render_field(form.description) }}
<div id="tz-container">{{ render_field(form.timezone) }}</div>
{{ render_field(form.namespace) }}
</dl>
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}CREATE TASK</button></p>

View file

@ -42,7 +42,7 @@
<label for="{{ tlist_cid('check-inner', task.id, list_id) }}">{{ task.name }}</label>
</td>
<td class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}">
<p>{{ task.due_rel }}</p>
<p>{{ reltime(task.due)|safe }}</p>
</td>
</tr>
<tr></tr> <!-- placeholder for CSS styling -->
@ -61,8 +61,8 @@
</div>
<p class="small-details">
Task ID: {{ task.id }}
<br/>Created: {{ task.created }} in namespace {{ task.namespace_id }}
<br/>Due: {{ task.due }}
<br/>Created: {{ abstime(task.created)|safe }} in namespace {{ task.namespace_id }}
<br/>Due: {{ abstime(task.due)|safe }}
</p>
<hr/>
<div class="main-description">{{ render_as_markdown(task.description)|safe }}</div>

View file

@ -14,6 +14,7 @@
{{ render_field(form.name) }}
{{ render_field(form.due) }}
{{ render_field(form.description) }}
<div id="tz-container">{{ render_field(form.timezone) }}</div>
</dl>
<button class="icon-btn green" type="submit" id="submit-form">{{icon('add')|safe}}Edit Task</button>
</form>

View file

@ -6,7 +6,7 @@
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
import multiprocessing
from multiprocessing.synchronize import BoundedSemaphore
import os
@ -18,6 +18,7 @@ from taskflower.auth import password_breach_count
from taskflower.db import db
from taskflower.db.model.hibp import PwnedPassword
from taskflower.types.either import Either, Left, Right, reduce_either
from taskflower.util.time import now
API_BASE = 'https://api.pwnedpasswords.com/range'
HASH_CHARS = '0123456789ABCDEF'
@ -300,7 +301,7 @@ def hibp_to_database(
print(f'[main] Error while clearing database: {e}. Cancelling.')
return
op_start = datetime.now()
op_start = now()
print(f'[main] Started reading data at {op_start}')
@ -328,7 +329,7 @@ def hibp_to_database(
except Exception as e:
print(f'[{fname}] Error while processing file: {e}')
finally:
cur_time = datetime.now()
cur_time = now()
elapsed = cur_time - op_start
pct_done = (dex+1)/len(fnames_to_read)
pct_left = 1.0 - pct_done

View file

689
src/taskflower/util/time.py Normal file
View file

@ -0,0 +1,689 @@
from datetime import datetime, time, timedelta, timezone
import re
import humanize
import logging
from zoneinfo import ZoneInfo
from taskflower.sanitize.markdown import SafeHTML
from taskflower.types.either import Either, Left, Right
log = logging.getLogger(__name__)
TimezoneAwareDatetime = datetime
TAD = TimezoneAwareDatetime
TimezoneNaiveDatetime = datetime
TND = TimezoneNaiveDatetime
def is_timezone_aware(dt: TAD|TND) -> bool:
return (
(dt.tzinfo is not None)
and (dt.tzinfo.utcoffset(dt) is not None)
)
def ensure_timezone_aware(dt: TAD|TND) -> TAD:
''' Ensures that ``dt`` is timezone-aware, by assuming that timezone-naive
``datetime``s are in UTC.
'''
if not is_timezone_aware(dt):
log.debug(f'Non-timezone-aware datetime object {dt!s} was passed to `ensure_timezone_aware()`. It will be assumed to be UTC.')
return dt.astimezone(timezone.utc)
else:
return dt
def now():
return datetime.now(timezone.utc)
def get_reltime(until: TAD|TND) -> timedelta:
return ensure_timezone_aware(until) - now()
def from_local(local: TND, tz: str = 'Etc/UTC') -> TAD:
''' Converts a datetime received in a users's local time to one in UTC.
If we don't get any timezone info, assume it's already UTC.
If ``tz`` is invalid, it is also assumed to be UTC.
'''
try:
local_tz = ZoneInfo(tz)
except Exception:
local_tz = timezone.utc
return local.replace(
tzinfo=local_tz
).astimezone(
timezone.utc
)
def render_reltime(dt: TAD) -> SafeHTML:
dt_aware = ensure_timezone_aware(dt)
humanized = humanize.naturaltime(dt_aware)
# Client-side javascript is responsible for updating the timestamp on the
# user's end, but we provide a sensible default for non-JS browsers.
return f'<span class="render-delta" data-timestamp="{int(dt_aware.astimezone(timezone.utc).timestamp())}">{humanized}</span>'
def render_abstime(dt: TAD) -> SafeHTML:
dt_aware = ensure_timezone_aware(dt)
user_text = dt_aware.strftime('%Y-%m-%d %H:%M:%S')
# Client-side javascript is responsible for doing timezone conversions on
# the user's end, but we provide a sensible default for non-JS browsers.
return f'<span class="render-timestamp" data-timestamp="{int(dt_aware.astimezone(timezone.utc).timestamp())}">{user_text} UTC</span>'
WEEKDAYS=[
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
'SUNDAY'
]
WEEKDAYS_RE = '|'.join(WEEKDAYS)
TIMESPANS=[
'SECOND',
'MINUTE',
'HOUR',
'DAY',
'WEEK',
'MONTH',
'YEAR',
'DECADE',
'CENTURY',
'MILLENNIUM'
]
TIMESPANS_PLURAL=[
'SECONDS',
'MINUTES',
'HOURS',
'DAYS',
'WEEKS',
'MONTHS',
'YEARS',
'DECADES',
'CENTURIES',
'MILLENNIA'
]
TIMESPANS_SINGULAR_RE = '|'.join(TIMESPANS)
TIMESPANS_RE = '|'.join(TIMESPANS) + '|' + '|'.join(TIMESPANS_PLURAL)
MONTHS=[
'JANUARY',
'FEBRUARY',
'MARCH',
'APRIL',
'MAY',
'JUNE',
'JULY',
'AUGUST',
'SEPTEMBER',
'OCTOBER',
'NOVEMBER',
'DECEMBER',
]
MONTHS_RE = '|'.join(MONTHS)
TIME_RE = (
'(?P<time_normal>(?P<hours>[0-9]{1,2})'
+ '(?::(?P<minutes>[0-9]{2}))?'
+ '(?::(?P<seconds>[0-9]{2}))?'
+ ' ?(?P<ampm>AM|PM)?)'
+ '|(?P<time_special>NOON|MIDNIGHT)'
)
STRICT_TIME_RE = (
'(?P<time_normal>(?P<hours>[0-9]{1,2})'
+ '(?::(?P<minutes>[0-9]{2}))'
+ '(?::(?P<seconds>[0-9]{2}))?'
+ ' ?(?P<ampm>AM|PM)?)'
+ '|(?P<time_special>NOON|MIDNIGHT)'
)
RELATIVE_TIMESTAMP_RE = re.compile(
'^('
+ '(?P<immediate>YESTERDAY|TODAY|TOMORROW)'
+ '|(?P<lastnext_group>'
+ f'(?P<lastnext_weekday>{WEEKDAYS_RE})?'
+ '(?P<lastnext>LAST |THIS |NEXT )?'
+ '(?P<lastnext_target>'
+ WEEKDAYS_RE
+ '|' + TIMESPANS_SINGULAR_RE
+ ')'
+ ')|(?P<inago_group>'
+ '(?P<in>IN )?'
+ '(?P<inago_count>[0-9]+) '
+ '(?P<inago_target>'
+ TIMESPANS_RE
+ ')'
+ '(?P<ago> AGO)?'
+ f') ?(?P<time_group>{TIME_RE})?'
+ ')$'
)
def days_per_month(month: str|int, year: int) -> int:
if isinstance(month, int):
month = MONTHS[month]
month = month.upper()
if month not in MONTHS:
return 0
if month == 'FEBRUARY':
return (
29 if (year % 4 == 0) else 28
)
if month in [
'JANUARY',
'MARCH',
'MAY',
'JULY',
'AUGUST',
'OCTOBER',
'DECEMBER'
]:
return 31
else:
return 30
def try_parse_relative_timestamp(
data: str,
user_tz: ZoneInfo|timezone
) -> Either[Exception, TAD]:
dnorm = re.sub(
'\\s+',
' ',
data # ensure any whitespace is reduced to single spaces
).upper().strip().replace(',', '')
cur_dt = now().astimezone(user_tz)
err: Left[Exception, TAD] = Left(
SyntaxError('Unable to parse relative time-string')
)
m = re.match(
RELATIVE_TIMESTAMP_RE,
dnorm
)
if not m:
return err
time_to_use = time(23, 59, 59)
if m['time_group']:
if m['time_normal']:
hours = int(m['hours'])
minutes = int(m['minutes']) if m['minutes'] else 59
seconds = int(m['seconds']) if m['seconds'] else 59
if hours > 23 or minutes > 59 or seconds > 59:
return err
if m['ampm'] == 'AM' and hours > 12:
return err
if m['ampm'] == 'PM' and hours < 12:
hours += 12
if m['ampm'] == 'AM' and hours == 12:
hours = 0
time_to_use = time(hours, minutes, seconds)
elif m['time_special']:
match m['time_special']:
case 'NOON':
time_to_use = time(12, 00, 00)
case 'MIDNIGHT':
time_to_use = time(00, 00, 00)
case _:
pass
def _from_date(dt: TAD, tz: ZoneInfo|timezone):
adj = dt.astimezone(tz)
return Right[Exception, datetime](datetime(
adj.year,
adj.month,
adj.day,
time_to_use.hour,
time_to_use.minute,
time_to_use.second,
0,
tz
))
def _end_of_month(year: int, month: int, tz: ZoneInfo|timezone):
return Right[Exception, datetime](datetime(
year,
month,
days_per_month(MONTHS[month], year),
time_to_use.hour,
time_to_use.minute,
time_to_use.second,
0,
tz
))
if m['immediate']:
match m['immediate']:
case 'YESTERDAY':
yesterday = cur_dt - timedelta(days=1)
return _from_date(yesterday, user_tz)
case 'TODAY':
return _from_date(cur_dt, user_tz)
case 'TOMORROW':
tomorrow = cur_dt + timedelta(days=1)
return _from_date(tomorrow, user_tz)
case _:
return err
if m['lastnext_group']:
target = m['lastnext_target']
lastnext = m['lastnext']
if target in WEEKDAYS:
tgt_weekday = WEEKDAYS.index(target)
cur_weekday = cur_dt.weekday()
if m['lastnext_weekday']:
# no, "tuesday next wednesday" is NOT a valid timestring.
# you absolute BUFFOON.
return err
if lastnext == 'LAST ':
days_delta = cur_weekday - tgt_weekday
if days_delta <= 0:
# if days_delta == 0, that means e.g. today is wednesday and
# the query is 'last wednesday', so we want the delta to be
# -7 days in that case. if it's negative, that means e.g.
# today is wednesday and the query is `last saturday`, so we
# want to wrap around the weekdays.
days_delta += 7
days_delta *= -1
elif lastnext == 'NEXT ':
# "Next X" is kind of ambiguous. We default to the following:
# - Advance to the next Sunday
# - Advance to the next X
# - If the delta is 3 days or less, (e.g. Next Monday on a
# Friday), then add an extra week.
to_next_week_boundary = 7 - cur_weekday
days_delta = to_next_week_boundary + tgt_weekday
if days_delta <= 3:
days_delta += 7
elif lastnext == 'THIS ':
# "This X" means "the X of this week." can be in the future or
# the past.
days_delta = tgt_weekday - cur_weekday
else:
# Just "X" means "the soonest X after today."
days_delta = tgt_weekday - cur_weekday
if days_delta <= 0:
days_delta += 7
return _from_date(
cur_dt + timedelta(days=days_delta),
user_tz
)
elif target in MONTHS:
tgt_month = MONTHS.index(target) + 1 # datetime months are 1-indexed
cur_month = cur_dt.month
match lastnext:
case 'LAST ':
if cur_month > tgt_month:
return _end_of_month(
cur_dt.year,
tgt_month,
user_tz
)
else:
return _end_of_month(
cur_dt.year-1,
tgt_month,
user_tz
)
case 'THIS ':
return _end_of_month(
cur_dt.year,
tgt_month,
user_tz
)
case 'NEXT ':
return _end_of_month(
cur_dt.year+1,
tgt_month,
user_tz
)
case _:
if cur_month > tgt_month:
return _end_of_month(
cur_dt.year+1,
tgt_month,
user_tz
)
else:
return _end_of_month(
cur_dt.year,
tgt_month,
user_tz
)
if (
m['inago_group'] and
(
m['inago_target'] in TIMESPANS
or m['inago_target'] in TIMESPANS_PLURAL
)
) or (
m['lastnext_group'] and
(
m['lastnext_target'] in TIMESPANS
or m['lastnext_target'] in TIMESPANS_PLURAL
)
):
target = (
(
m['inago_target']
) if m['inago_target'] in TIMESPANS else (
# Singularize timespans before continuing
TIMESPANS[TIMESPANS_PLURAL.index(m['inago_target'])]
) if m['inago_target'] in TIMESPANS_PLURAL else (
m['lastnext_target']
) if m['lastnext_target'] in TIMESPANS else (
TIMESPANS[TIMESPANS_PLURAL.index(m['lastnext_target'])]
) if m['lastnext_target'] in TIMESPANS_PLURAL else (
None
)
)
if not isinstance(target, str):
return err
delta_unit = 0
if m['lastnext_group']:
delta_unit = (
1 if m['lastnext'] == 'NEXT ' else
-1 if m['lastnext'] == 'LAST ' else
0
)
elif m['inago_group']:
try:
delta_unit = (
(-1 * int(m['inago_count'])) if m['ago']
else int(m['inago_count'])
)
except Exception as e:
return Left(e)
match target:
case 'SECOND':
return Right(cur_dt + timedelta(
seconds=delta_unit
))
case 'MINUTE':
return Right((cur_dt + timedelta(
minutes=delta_unit
)).replace(
second=59
))
case 'HOUR':
return Right((cur_dt + timedelta(
hours=delta_unit
)).replace(
minute=59,
second=59
))
case 'DAY':
return _from_date((cur_dt + timedelta(
days=delta_unit
)), user_tz)
case 'WEEK':
# default "end of week" is friday.
days_until_friday = WEEKDAYS.index('FRIDAY') - cur_dt.weekday()
if days_until_friday < 0:
days_until_friday += 7
days_delta = days_until_friday + 7*delta_unit
return _from_date(cur_dt + timedelta(
days=days_delta
), user_tz)
case 'MONTH':
final_month = ((cur_dt.month-1) + delta_unit) % 12 + 1
final_year = cur_dt.year + ((cur_dt.month + delta_unit) // 12)
return _from_date(cur_dt.replace(
year=final_year,
month=final_month,
day=days_per_month(MONTHS[final_month-1], final_year)
), user_tz)
case 'YEAR':
return _from_date(cur_dt.replace(
year=cur_dt.year + delta_unit,
month=12,
day=31
), user_tz)
case 'DECADE':
return _from_date(cur_dt.replace(
year=cur_dt.year + 10*delta_unit,
month=12,
day=31
), user_tz)
case 'CENTURY':
return _from_date(cur_dt.replace(
year=cur_dt.year + 100*delta_unit,
month=12,
day=31
), user_tz)
case 'MILLENNIUM':
return _from_date(cur_dt.replace(
year=cur_dt.year + 1000*delta_unit,
month=12,
day=31
), user_tz)
case _:
return err
return err
ABSOLUTE_TIMESTAMP_RE = re.compile(
'^'
+ '(?P<year>[0-9][0-9]+)?'
+ '(?:[-:\\/\\s]*(?P<month>[0-9]{1,2}))?'
+ '(?:[-:\\/\\s]*(?P<day>[0-9]{1,2}))?'
+ f' ?(?P<time_group>{TIME_RE})?'
+ '$'
)
JUST_TIME_RE = re.compile(
f'^{STRICT_TIME_RE}$'
)
def from_sophont_provided_data(data: str, user_tz: str = 'Etc/UTC') -> Either[Exception, TAD]:
''' Try to parse a 'sophont-friendly' time-string to a timezone-aware
datetime.
This function will process absolute timestamps ('2026-01-01 12:00') as
well as relative ones ('next thursday 11:59PM'). Relative ones will be
processed relative to the user's client-side current time, calculated as
the server-side current time converted to the user's timezone.
If a time is not provided, the time will be assumed to be 11:59PM.
Note that user-local timezones will be converted to UTC.
>>> from_sophont_provided_data('thursday', 'Etc/UTC')
Right[datetime](datetime(2025, 11, 27, 23, 59, 59, tzinfo=timezone.utc))
>>> from_sophont_provided_data('2026-01-01', 'America/Chicago')
Right[datetime](datetime(2026, 01, 02, 05, 59, 59, tzinfo=timezone.utc))
'''
try:
tz = ZoneInfo(user_tz)
except Exception as e:
return Left(e)
err: Left[Exception, TAD] = Left(
SyntaxError('Unable to parse absolute time-string.')
)
try:
if m:=JUST_TIME_RE.match(data):
# User input *just* a time.
time_to_use = time(23, 59, 59)
if m['time_normal']:
hours = int(m['hours'])
minutes = int(m['minutes']) if m['minutes'] else 59
seconds = int(m['seconds']) if m['seconds'] else 59
if hours > 23 or minutes > 59 or seconds > 59:
return err
if m['ampm'] == 'AM' and hours > 12:
return err
if m['ampm'] == 'PM' and hours < 12:
hours += 12
if m['ampm'] == 'AM' and hours == 12:
hours = 0
time_to_use = time(hours, minutes, seconds)
elif m['time_special']:
match m['time_special']:
case 'NOON':
time_to_use = time(12, 00, 00)
case 'MIDNIGHT':
time_to_use = time(00, 00, 00)
case _:
return err
return Right(datetime.now(tz).replace(
hour=time_to_use.hour,
minute=time_to_use.minute,
second=time_to_use.second
))
if not (m:=ABSOLUTE_TIMESTAMP_RE.match(data)):
return try_parse_relative_timestamp(
data,
tz
)
time_to_use = time(23, 59, 59)
if m['time_group']:
if m['time_normal']:
hours = int(m['hours'])
minutes = int(m['minutes']) if m['minutes'] else 59
seconds = int(m['seconds']) if m['seconds'] else 59
if hours > 23 or minutes > 59 or seconds > 59:
return err
if m['ampm'] == 'AM' and hours > 12:
return err
if m['ampm'] == 'PM' and hours < 12:
hours += 12
if m['ampm'] == 'AM' and hours == 12:
hours = 0
time_to_use = time(hours, minutes, seconds)
elif m['time_special']:
match m['time_special']:
case 'NOON':
time_to_use = time(12, 00, 00)
case 'MIDNIGHT':
time_to_use = time(00, 00, 00)
case _:
pass
year = int(m['year']) if m['year'] else None
year_raw = year
if year is not None and year < 100:
year += (datetime.now(tz).year // 100)*100 # handle 2-digit years
month = int(m['month']) if m['month'] else None
day = int(m['day']) if m['day'] else None
if year and month and day:
return Right(datetime(
year,
month,
day,
time_to_use.hour,
time_to_use.minute,
time_to_use.second,
tzinfo=tz
))
if year and month:
if year_raw is not None and year_raw < 12:
# User input something like "10/25".
# Assume that's actually a month and a day, not a year and a month.
cur = datetime.now(tz)
target = datetime(
cur.year,
year_raw,
month,
time_to_use.hour,
time_to_use.minute,
time_to_use.second,
tzinfo=tz
)
if target > cur:
# that month and date this year is in the future.
return Right(target)
else:
# that month and date this year would be in the past.
# assume they actually want it due on that date *next* year.
return Right(target.replace(
year=cur.year+1
))
else:
# User input something like "26/11".
# This is probably *actually* a year and a month
return Right(
datetime(
year,
month,
days_per_month(MONTHS[month-1], year),
time_to_use.hour,
time_to_use.minute,
time_to_use.second,
tzinfo=tz
)
)
if month and day:
cur = datetime.now(tz)
target = datetime(
cur.year,
month,
day,
time_to_use.hour,
time_to_use.minute,
time_to_use.second,
tzinfo=tz
)
if target > cur:
# that month and date this year is in the future.
return Right(target)
else:
# that month and date this year would be in the past.
# assume they actually want it due on that date *next* year.
return Right(target.replace(
year=cur.year+1
))
if year:
return Right(
datetime(
year,
12,
31,
time_to_use.hour,
time_to_use.minute,
time_to_use.second,
tzinfo=tz
)
)
# User input something bizarre. See if parse_relative() can do anything with it.
return try_parse_relative_timestamp(data, tz)
except Exception as e:
log.error(f'An internal error occured while parsing the time-string {data}: {e!s}')
return Left(e)

View file

@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import timedelta
from secrets import token_hex
from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import current_user, login_required # pyright:ignore[reportMissingTypeStubs, reportUnknownVariableType]
@ -20,6 +20,7 @@ from taskflower.sanitize.code import SignUpCodeForUser, ZoneInviteCodeForUser
from taskflower.types import assert_usr
from taskflower.types.either import Either, Left, Right, gather_successes
from taskflower.types.option import Option
from taskflower.util.time import now
from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorForbidden, ResponseErrorNotFound, response_from_exception
from taskflower.web.utils.request import get_next
@ -124,8 +125,8 @@ def new_sign_up():
try:
code = SignUpCode(
code=token_hex(16), # pyright:ignore[reportCallIssue]
created=datetime.now(), # pyright:ignore[reportCallIssue]
expires=datetime.now() + timedelta(weeks=1), # pyright:ignore[reportCallIssue]
created=now(), # pyright:ignore[reportCallIssue]
expires=now() + timedelta(weeks=1), # pyright:ignore[reportCallIssue]
created_by=cur_usr.id # pyright:ignore[reportCallIssue]
)
db.session.add(code)
@ -240,7 +241,7 @@ def enter():
)
).flat_map(
lambda code: Either.do_assert(
code.expires > datetime.now(),
code.expires > now(),
'Code expiration date is in the future'
).map(
lambda _: code