Partial daily plan implementation

This commit is contained in:
digimint 2025-12-11 02:24:56 -06:00
parent c7f6f3f4f1
commit e0063fffdb
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
27 changed files with 1687 additions and 53 deletions

View file

@ -0,0 +1,74 @@
"""Daily tasks setup
Revision ID: 4a2deb2e7bda
Revises: 8576b056149e
Create Date: 2025-12-10 20:55:31.114636
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4a2deb2e7bda'
down_revision = '8576b056149e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('daily_plan',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='fk_dp_user', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('daily_plan_tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('task_id', sa.Integer(), nullable=True),
sa.Column('plan_id', sa.Integer(), nullable=False),
sa.Column('order', sa.Integer(), nullable=False),
sa.Column('target', sa.DateTime(), nullable=False),
sa.Column('completed', sa.DateTime(), nullable=True),
sa.Column('time_spent', sa.Integer(), nullable=False),
sa.Column('time_goal', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['plan_id'], ['daily_plan.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['task_id'], ['task.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.add_column(sa.Column('mental_burn', sa.Integer(), nullable=False, default=0))
batch_op.add_column(sa.Column('social_burn', sa.Integer(), nullable=False, default=0))
batch_op.add_column(sa.Column('time_estimate', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('time_spent', sa.Integer(), nullable=False, default=0))
batch_op.add_column(sa.Column('importance', sa.Integer(), nullable=False, default=2))
batch_op.add_column(sa.Column('divisible', sa.Boolean(), nullable=False, default=False))
batch_op.add_column(sa.Column('last_reviewed', sa.DateTime(), nullable=True))
batch_op.alter_column('due',
existing_type=sa.DATETIME(),
nullable=True)
batch_op.drop_column('soft_due')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.add_column(sa.Column('soft_due', sa.DATETIME(), nullable=True))
batch_op.alter_column('due',
existing_type=sa.DATETIME(),
nullable=False)
batch_op.drop_column('last_reviewed')
batch_op.drop_column('divisible')
batch_op.drop_column('importance')
batch_op.drop_column('time_spent')
batch_op.drop_column('time_estimate')
batch_op.drop_column('social_burn')
batch_op.drop_column('mental_burn')
op.drop_table('daily_plan_tasks')
op.drop_table('daily_plan')
# ### end Alembic commands ###

View file

@ -0,0 +1,39 @@
"""Add break info to DailyPlan
Revision ID: 604ec4c8d043
Revises: f1bc3cfb815d
Create Date: 2025-12-11 00:11:06.723265
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '604ec4c8d043'
down_revision = 'f1bc3cfb815d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.add_column(sa.Column('break_time_goal', sa.Integer(), default=0, nullable=True))
batch_op.add_column(sa.Column('break_time_spent', sa.Integer(), default=0, nullable=True))
op.execute('UPDATE daily_plan SET break_time_goal = 0')
op.execute('UPDATE daily_plan SET break_time_spent = 0')
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.alter_column('break_time_goal', nullable=False)
batch_op.alter_column('break_time_spent', nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.drop_column('break_time_spent')
batch_op.drop_column('break_time_goal')
# ### end Alembic commands ###

View file

@ -0,0 +1,59 @@
"""Add intent information to DailyPlan
Revision ID: a2f8f8dc3ab0
Revises: ae3136f2aa90
Create Date: 2025-12-11 01:45:56.938210
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a2f8f8dc3ab0'
down_revision = 'ae3136f2aa90'
branch_labels = None
depends_on = None
def upgrade():
### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.add_column(sa.Column('total_time_intent', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_time_goal' , sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_time_spent' , sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('burn_intent' , sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('burn_goal' , sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('flags_raw' , sa.Integer(), nullable=True))
for col in [
'total_time_intent',
'total_time_goal',
'total_time_spent',
'burn_intent',
'burn_goal',
'flags_raw'
]:
op.execute(f'UPDATE daily_plan SET {col} = 0')
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.alter_column('total_time_intent', nullable=False)
batch_op.alter_column('total_time_goal', nullable=False)
batch_op.alter_column('total_time_spent', nullable=False)
batch_op.alter_column('burn_intent', nullable=False)
batch_op.alter_column('burn_goal', nullable=False)
batch_op.alter_column('flags_raw', nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.drop_column('flags_raw')
batch_op.drop_column('burn_goal')
batch_op.drop_column('burn_intent')
batch_op.drop_column('total_time_spent')
batch_op.drop_column('total_time_goal')
batch_op.drop_column('total_time_intent')
# ### end Alembic commands ###

View file

@ -0,0 +1,52 @@
"""Change DailyPlanTask datetimes to use auto-conversion
Revision ID: ae3136f2aa90
Revises: 604ec4c8d043
Create Date: 2025-12-11 00:17:06.827686
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ae3136f2aa90'
down_revision = '604ec4c8d043'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.alter_column('start_at',
new_column_name='start_at_raw'
)
with op.batch_alter_table('daily_plan_tasks', schema=None) as batch_op:
batch_op.alter_column('target',
new_column_name='target_raw'
)
batch_op.alter_column('completed',
new_column_name='completed_raw'
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('daily_plan_tasks', schema=None) as batch_op:
batch_op.alter_column('target_raw',
new_column_name='target'
)
batch_op.alter_column('completed_raw',
new_column_name='completed'
)
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
batch_op.alter_column('start_at_raw',
new_column_name='start_at'
)
# ### end Alembic commands ###

View file

@ -0,0 +1,37 @@
"""Rename Task columns
Revision ID: f1bc3cfb815d
Revises: 4a2deb2e7bda
Create Date: 2025-12-10 22:47:42.436743
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'f1bc3cfb815d'
down_revision = '4a2deb2e7bda'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.alter_column('due', new_column_name='due_raw')
batch_op.alter_column('created', new_column_name='created_raw')
batch_op.alter_column('completed', new_column_name='completed_raw')
batch_op.alter_column('last_reviewed', new_column_name='last_reviewed_raw')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.alter_column(column_name='due_raw', new_column_name='due')
batch_op.alter_column(column_name='created_raw', new_column_name='created')
batch_op.alter_column(column_name='completed_raw', new_column_name='completed')
batch_op.alter_column(column_name='last_reviewed_raw', new_column_name='last_reviewed')
# ### end Alembic commands ###

View file

@ -13,7 +13,7 @@ from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
from taskflower.tools.icons import get_icon, svg_bp
from taskflower.types import ann
from taskflower.types.either import Left
from taskflower.util.time import render_abstime, render_reltime
from taskflower.util.time import render_abstime, render_reltime, render_timer
from taskflower.web import web_base
from taskflower.tools.hibp import hibp_bp
@ -56,10 +56,13 @@ APIBase.register(app)
with app.app_context():
# db.create_all()
log.info('Checking for and applying database migrations')
upgrade()
_init_logs() # flask-migrate overrides our logging config >:(
log = logging.getLogger(__name__)
if config.debug_disable_auto_migrate:
log.warning('Database migrations disabled by user configuration. Crashes, instability, and data corruption are possible!')
else:
log.info('Checking for and applying database migrations')
upgrade()
_init_logs() # flask-migrate overrides our logging config >:(
log = logging.getLogger(__name__)
log.info('Running startup checks...')
res = startup_checks(db)
@ -123,15 +126,33 @@ def template_utility_fns():
def t_len(ls: list[Any]) -> int: # pyright:ignore[reportExplicitAny]
return len(ls)
def _render_time_est(time_est: int) -> str:
tstr = ''
if time_est > 60*60:
tstr += f'{time_est//(60*60)} hours '
time_est %= 60*60
if time_est > 60:
tstr += f'{time_est//60} minutes '
time_est %= 60
if time_est > 0:
tstr += f'{time_est} seconds'
return tstr
return dict(
literal_call=literal_call,
reltime=render_reltime,
abstime=render_abstime,
rtimer=render_timer,
can_generate_sign_up_codes=can_generate_sign_up_codes,
render_as_markdown=render_as_markdown,
icon=icon,
cur_page_with_variables=cur_page_with_variables,
len=t_len
len=t_len,
time_estimate_str=_render_time_est
)
@app.route('/')

View file

@ -38,7 +38,7 @@ def get_tasks_for_user(user: User) -> list[Task]:
).order_by(
Task.complete.asc()
).order_by(
Task.due.asc()
Task.due_raw.asc()
).all()
def namespaces_where_user_can(

View file

@ -142,6 +142,11 @@ class ConfigType:
# Debug settings
debug: bool = False
# Disable automatic database migration. This allows for manually downgrading
# the database to a previous version and preventing upgrades. This is
# generally a very bad idea.
debug_disable_auto_migrate: bool = False
# Regenerate icon file with each request. Useful during development, but it
# should never be enabled in production.
debug_always_regen_icon_file: bool = False

View file

@ -0,0 +1,186 @@
# pyright: reportImportCycles=false
from datetime import datetime
from enum import IntFlag, auto
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from taskflower.db import db
from taskflower.util.time import TAD, TND, time_from_db, time_to_db
if TYPE_CHECKING:
from taskflower.db.model.task import Task
from taskflower.db.model.user import User
class PlanFlags(IntFlag):
# Prefer a few big, high-focus tasks. Avoid interrupting flow states with
# smaller tasks.
SCOPE_BIG = auto()
# Prefer a bunch of smaller tasks. Avoid long blocks of focus-intensive
# tasks.
# Once events are implemented, `SCOPE_SMALL` will be inferred when a bunch
# of events prevent dedicating large blocks of time to single tasks.
SCOPE_SMALL = auto()
# Include a lot of long-term planning and catch-up tasks.
PREFER_PLANNING = auto()
# Avoid tasks requiring high executive function and long-term planning.
AVOID_PLANNING = auto()
# Generate a relatively gentle daily plan, selecting only lower-burn tasks.
LOW_BURN = auto()
# Generate an intense daily plan, including high-burn tasks with lots of
# breaks in between.
HIGH_BURN = auto()
# Prefer social tasks to mental tasks
PREFER_SOCIAL = auto()
# Prefer mental tasks to social tasks
PREFER_MENTAL = auto()
class DailyPlan(db.Model):
__tablename__: str = 'daily_plan'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
user_id : Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE', name='fk_dp_user'))
user : Mapped['User'] = relationship(back_populates='plans')
start_at_raw: Mapped[datetime] = mapped_column(DateTime(timezone=False))
break_time_goal: Mapped[int] = mapped_column(Integer)
break_time_spent: Mapped[int] = mapped_column(Integer, default=0)
total_time_intent: Mapped[int] = mapped_column(Integer)
total_time_goal: Mapped[int] = mapped_column(Integer)
total_time_spent: Mapped[int] = mapped_column(Integer, default=0)
burn_intent: Mapped[int] = mapped_column(Integer)
burn_goal: Mapped[int] = mapped_column(Integer)
flags_raw: Mapped[int] = mapped_column(Integer)
@property
def overtime(self) -> bool:
''' Does the combined total time of tasks in this plan exceed the
budgeted maximum? (can happen with 'urgent' tasks - i.e. high
importance and due very soon).
'''
return self.total_time_goal > self.total_time_intent
@property
def overburn(self) -> bool:
''' Does the combined total burn of tasks in this plan exceed the
budgeted maximum? (can happen with 'urgent' tasks - i.e. high
importance and due very soon).
'''
return self.burn_goal > self.burn_intent
plan_tasks : Mapped[list[DailyPlanTask]] = relationship(back_populates='plan')
@property
def flags(self) -> PlanFlags:
''' The ``PlanFlags`` used when generating this DailyPlan
'''
return PlanFlags(self.flags_raw)
@flags.setter
def flags(self, val: PlanFlags):
self.flags_raw = val.value
@property
def start_at(self) -> TAD:
return time_from_db(self.start_at_raw)
@start_at.setter
def start_at(self, val: TAD|TND):
self.start_at_raw = time_to_db(val)
@property
def break_time_remaining(self) -> int:
return self.break_time_goal - self.break_time_spent
@property
def total_time_remaining(self) -> int:
return self.total_time_goal - self.total_time_spent
def __init__(
self,
user_id: int,
start_at: TAD,
break_time_goal: int,
total_time_goal: int,
total_time_intent: int,
burn_goal: int,
burn_intent: int,
flags: PlanFlags,
) -> None:
self.user_id = user_id
self.start_at = start_at
self.break_time_goal = break_time_goal
self.total_time_goal = total_time_goal
self.total_time_intent = total_time_intent
self.burn_goal = burn_goal
self.burn_intent = burn_intent
self.flags = flags
class DailyPlanTask(db.Model):
__tablename__: str = 'daily_plan_tasks'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
# NOTE: task is nullable specifically because we don't want daily plan
# records to disappear when a task is deleted. In this case, specifics
# on the task won't be available, but user reports can still see that
# *a* task was scheduled, and the user either did or didn't complete
# it, spent X amount of time on it, etc.
task_id : Mapped[int|None] = mapped_column(Integer, ForeignKey('task.id', ondelete='SET NULL'), nullable=True)
task : Mapped['Task|None'] = relationship(back_populates='plans')
plan_id : Mapped[int] = mapped_column(Integer, ForeignKey('daily_plan.id', ondelete='CASCADE'))
plan : Mapped[DailyPlan] = relationship(back_populates='plan_tasks')
# The order this task should be placed in the plan
order : Mapped[int] = mapped_column(Integer)
# Soft due date for the task in this daily plan
target_raw : Mapped[datetime] = mapped_column(DateTime(timezone=False))
# Datetime when the task was actually completed
completed_raw : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
# Time spent actively working on the task
time_spent : Mapped[int] = mapped_column(Integer)
# Time that was supposed to be spent actively working on the task
time_goal : Mapped[int] = mapped_column(Integer)
@property
def target(self) -> TAD:
return time_from_db(self.target_raw)
@target.setter
def target(self, val: TAD|TND):
self.target_raw = time_to_db(val)
@property
def completed(self) -> TAD|None:
return time_from_db(self.completed_raw) if self.completed_raw else None
@completed.setter
def completed(self, val: TAD|TND|None):
self.completed_raw = time_to_db(val) if val else None
def __init__(
self,
task_id: int,
plan_id: int,
order: int,
target: datetime,
time_goal: int
) -> None:
self.task_id = task_id
self.plan_id = plan_id
self.order = order
self.target = target
self.completed = None
self.time_goal = time_goal
self.time_spent = 0

View file

@ -1,28 +1,186 @@
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime
from taskflower.db import db
from taskflower.util.time import now
from taskflower.util.time import TAD, now, time_from_db, time_to_db
if TYPE_CHECKING:
from taskflower.db.model.daily import DailyPlanTask
def importance_str(importance: int) -> str:
match importance:
case 0:
return 'No Importance Whatsoever'
case 1:
return 'Entirely Unimportant'
case 2:
return 'Unimportant'
case 3:
return 'Mostly Unimportant'
case 4:
return 'Low Average'
case 5:
return 'Average'
case 6:
return 'High Average'
case 7:
return 'Slightly Important'
case 8:
return 'Important'
case 9:
return 'Very Important'
case 10:
return 'Absolutely Vital'
case _:
return '[ INVALID ]'
def burn_str(burn: int) -> str:
match burn:
case -2:
return 'Very Fun'
case -1:
return 'Fun'
case 0:
return 'Very easy'
case 1:
return 'Easy'
case 2:
return 'Medium'
case 3:
return 'Hard'
case 4:
return 'Very Hard'
case 5:
return 'Excruciating'
case _:
return '[ INVALID ]'
class Task(db.Model):
__tablename__: str = 'task'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
parent : Mapped[int|None] = mapped_column(Integer, ForeignKey('task.id', name='fk_task_parent'), nullable=True)
category : Mapped[int|None] = mapped_column(Integer, ForeignKey('category.id', ondelete='SET NULL', name='fk_task_cat'), nullable=True)
id : Mapped[int] = mapped_column(Integer, primary_key=True)
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
parent : Mapped[int|None] = mapped_column(Integer, ForeignKey('task.id', name='fk_task_parent'), nullable=True)
category : Mapped[int|None] = mapped_column(Integer, ForeignKey('category.id', ondelete='SET NULL', name='fk_task_cat'), nullable=True)
name : Mapped[str] = mapped_column(String(64))
description : Mapped[str] = mapped_column(String)
name : Mapped[str] = mapped_column(String(64))
description : Mapped[str] = mapped_column(String)
# NOTE: a null due date means the task is 'deferred' - it's an idea for
# something to be done in the indeterminate future. Deferred tasks
# may be randomly selected for user review as a planning task.
due_raw : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
created_raw : Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
complete : Mapped[bool] = mapped_column(Boolean, default=False)
completed_raw : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
owner : Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
soft_due : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
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)
completed : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
# ======== Day Planner ========
plans : Mapped[list['DailyPlanTask']] = relationship(back_populates='task')
# The "burn value" - how mentally strenuous is it? Goes from -2 (very fun) to 5 (excruciating).
# Higher-burn tasks will be scheduled with fewer other tasks alongside, and if possible, they'll be
# scheduled alongside some fun tasks to offset the burn. burn-5 tasks might get a whole day to
# themselves, even if the time estimate is only a few minutes.
#
# Note also that burn is exponential - each level of burn should be twice as hard as the last level.
mental_burn : Mapped[int] = mapped_column(Integer, default=0)
# Social burn is the same as mental burn, but for 'social batteries.' this is particularly important
# for neurodivergent folks (speaking personally: masking around neurotypicals is *exhausting*, and i
# need the day planner to take that into account).
social_burn : Mapped[int] = mapped_column(Integer, default=0)
# The time estimate - how long will it take? Stored in seconds. The day planner will take this into
# account when scheduling a day.
time_estimate : Mapped[int|None] = mapped_column(Integer, nullable=True)
# How long has been spent on this task already? Stored in seconds.
time_spent : Mapped[int] = mapped_column(Integer, default=0)
# The importance - how vital is it that this task gets done? Goes from 0 (totally optional) to 10
# (absolutely vital) More important tasks are scheduled further in advance, and unimportant tasks
# may not be scheduled even if they're due immediately.
importance : Mapped[int] = mapped_column(Integer, default=2)
# Can the task be subdivided into smaller tasks? If the time estimate is large and the task is
# divisible, the day planner might decide to generate a "subdivide this task" task instead of
# scheduling the task itself, to allow it to be divided into manageable chunks.
divisible : Mapped[bool] = mapped_column(Boolean, default=False)
# When was the task last reviewed? Used for deferred tasks: if a task was
# just checked over by the user and is still deferred, we probably don't
# want to ask the user about it again for a while.
last_reviewed_raw : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
@property
def created(self) -> TAD:
return time_from_db(self.created_raw)
@property
def due(self) -> TAD|None:
return (
time_from_db(self.due_raw)
if self.due_raw
else None
)
@due.setter
def due(self, val: TAD|None):
self.due_raw = time_to_db(val) if val else None
@property
def completed(self) -> TAD|None:
return (
time_from_db(self.completed_raw)
if self.completed_raw
else None
)
@completed.setter
def completed(self, val: TAD|None):
self.completed_raw = time_to_db(val) if val else None
def __init__(self,
namespace : int,
name : str,
description: str,
owner : int,
complete : bool = False,
created : datetime|None = None,
parent : int|None = None,
category : int|None = None,
due : datetime|None = None,
completed : datetime|None = None,
mental_burn : int = 0,
social_burn : int = 0,
time_estimate: int|None = None,
time_spent : int = 0,
importance : int = 2,
divisible : bool = False,
last_reviewed: datetime|None = None
):
created = created or now()
self.namespace = namespace
self.name = name
self.description = description
self.complete = complete
self.owner = owner
self.created_raw = time_to_db(created)
self.parent = parent
self.category = category
self.due_raw = time_to_db(due) if due else None
self.completed_raw = time_to_db(completed) if completed else None
self.mental_burn = mental_burn
self.social_burn = social_burn
self.time_estimate = time_estimate
self.time_spent = time_spent
self.importance = importance
self.divisible = divisible
self.last_reviewed_raw = time_to_db(last_reviewed) if last_reviewed else None
owner : Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
class TaskAssignment(db.Model):
__tablename__: str = 'task_assignment'

View file

@ -1,13 +1,14 @@
from datetime import datetime
from typing import override
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from taskflower.db import db
from flask_login import UserMixin # pyright:ignore[reportMissingTypeStubs]
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING, override
from taskflower.db import db
from taskflower.util.time import now
if TYPE_CHECKING:
from taskflower.db.model.daily import DailyPlan
class User(db.Model, UserMixin):
__tablename__: str = 'user'
@ -34,6 +35,9 @@ class User(db.Model, UserMixin):
salt: Mapped[str] = mapped_column(String(256))
hash_params: Mapped[str] = mapped_column(String(256))
# Daily Plans
plans: Mapped[list['DailyPlan']] = relationship(back_populates='user')
@override
def get_id(self) -> str:
return str(self.id)

View file

@ -1,7 +1,7 @@
from datetime import timezone
from typing import final, override
from zoneinfo import ZoneInfo
from wtforms import Field, Form, SelectField, SelectMultipleField, StringField, TextAreaField, ValidationError, validators
from wtforms import Field, Form, IntegerField, SelectField, SelectMultipleField, 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
@ -9,7 +9,7 @@ from taskflower.auth.permission.lookups import namespaces_where_user_can
from taskflower.db import db, db_fetch_by_id
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.tag import Tag, TagToTask, TaskCategory
from taskflower.db.model.task import Task
from taskflower.db.model.task import Task, burn_str, importance_str
from taskflower.db.model.user import User
from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
from taskflower.types import AssertType, CheckType, ann
@ -187,6 +187,49 @@ def task_edit_form_for_task(
],
default='Etc/UTC'
)
time_estimate: IntegerField = IntegerField(
'Time estimate (minutes)',
[
validators.NumberRange(1)
],
default=t.time_estimate//60 if t.time_estimate else None
)
mental_burn: SelectField = SelectField(
'Mental burn',
[
validators.InputRequired()
],
choices=[
(i, f'[{i}] {burn_str(i)}')
for i in range(-2, 6)
],
coerce=int,
default=t.mental_burn
)
social_burn: SelectField = SelectField(
'Social burn',
[
validators.InputRequired()
],
choices=[
(i, f'[{i}] {burn_str(i)}')
for i in range(-2, 6)
],
coerce=int,
default=t.social_burn
)
importance: SelectField = SelectField(
'Importance',
[
validators.InputRequired()
],
choices=[
(i, f'[{i}] {importance_str(i)}')
for i in range(10)
],
coerce=int,
default=t.importance
)
description: TextAreaField = TextAreaField(
'Description',
[
@ -209,6 +252,17 @@ def task_edit_form_for_task(
None if cat_id == -1 else cat_id
)
tsk.time_estimate = self.time_estimate.data*60 if self.time_estimate.data else None
if self.mental_burn.data: # pyright:ignore[reportAny]
tsk.mental_burn = AssertType(int)(self.mental_burn.data) # pyright:ignore[reportAny]
if self.social_burn.data: # pyright:ignore[reportAny]
tsk.social_burn = AssertType(int)(self.social_burn.data) # pyright:ignore[reportAny]
if self.importance.data: # pyright:ignore[reportAny]
tsk.importance = AssertType(int)(self.importance.data) # pyright:ignore[reportAny]
if self.tags.data:
res = CheckType(list[int])(
self.tags.data
@ -317,6 +371,48 @@ def task_form_for_user(
choices=namespace_choices,
coerce=int
)
time_estimate: IntegerField = IntegerField(
'Time estimate (minutes)',
[
validators.NumberRange(1)
]
)
mental_burn: SelectField = SelectField(
'Mental burn',
[
validators.InputRequired()
],
choices=[
(i, f'[{i}] {burn_str(i)}')
for i in range(-2, 6)
],
coerce=int,
default=2
)
social_burn: SelectField = SelectField(
'Social burn',
[
validators.InputRequired()
],
choices=[
(i, f'[{i}] {burn_str(i)}')
for i in range(-2, 6)
],
coerce=int,
default=2
)
importance: SelectField = SelectField(
'Importance',
[
validators.InputRequired()
],
choices=[
(i, f'[{i}] {importance_str(i)}')
for i in range(10)
],
coerce=int,
default=5
)
description: TextAreaField = TextAreaField(
'Description (supports markdown)',
[
@ -335,11 +431,6 @@ def task_form_for_user(
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
@ -358,15 +449,19 @@ def task_form_for_user(
)
).map(
lambda ns: Task(
name=self.name.data, # pyright:ignore[reportCallIssue]
due=due_date_parsed, # pyright:ignore[reportCallIssue]
description=( # pyright:ignore[reportCallIssue]
namespace=ns.id,
name=ann(self.name.data),
description=(
self.description.data
if self.description.data
else ''
),
namespace=ns.id, # pyright:ignore[reportCallIssue]
owner=current_user.id # pyright:ignore[reportCallIssue]
owner=current_user.id,
due=due_date_parsed,
mental_burn = AssertType(int)(self.mental_burn.data), # pyright:ignore[reportAny]
social_burn = AssertType(int)(self.social_burn.data), # pyright:ignore[reportAny]
time_estimate = self.time_estimate.data*60 if self.time_estimate.data else None,
importance = AssertType(int)(self.importance.data), # pyright:ignore[reportAny]
)
)
else:

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from typing import Self
import humanize
@ -14,7 +14,9 @@ from taskflower.db.model.user import User
from taskflower.types.either import Either, gather_successes
from taskflower.util.time import ensure_timezone_aware, now
def _due_str(due: datetime) -> str:
def _due_str(due: datetime|None) -> str:
if not due:
return 'N/A'
due = ensure_timezone_aware(due)
cur_dt = now()
delta = now() - due
@ -32,7 +34,7 @@ class TaskForUser:
id: int
name: str
description: str
due: datetime
due: datetime|None
due_rel: str
created: datetime
complete: bool
@ -42,6 +44,12 @@ class TaskForUser:
category: str|None
tags: list[str]
mental_burn: int
social_burn: int
time_estimate: int|None
time_spent: int
importance: int
can_edit: bool
can_delete: bool
can_complete: bool
@ -136,19 +144,20 @@ class TaskForUser:
tsk.id,
tsk.name,
tsk.description,
tsk.due.replace(tzinfo=timezone.utc),
tsk.due,
_due_str(tsk.due),
tsk.created,
tsk.complete,
(
tsk.completed.replace(tzinfo=timezone.utc)
if tsk.completed is not None
else None
),
tsk.due.replace(tzinfo=timezone.utc) < now(),
tsk.completed,
(tsk.due < now()) if tsk.due else False,
tsk.namespace,
cat,
tags,
tsk.mental_burn,
tsk.social_burn,
tsk.time_estimate,
tsk.time_spent,
tsk.importance,
NPT.EDIT_ALL_TASKS in perms,
NPT.DELETE_ALL_TASKS in perms,
NPT.COMPLETE_ALL_TASKS in perms,

View file

@ -0,0 +1,57 @@
.daily-plan {
display: flex;
width: 100%;
.main-pane {
flex: 3 0 auto;
details {
margin: 1rem;
padding: 0;
background-color: var(--block-grey-bg);
border: 1px solid var(--block-grey-text);
box-shadow: 1px 1px 1px var(--block-grey-text) inset;
summary {
display: flex;
padding: 0.5rem;
border: 1px solid var(--block-grey-text);
span {
text-wrap: nowrap;
overflow: hidden;
&.name {
flex: 1 1 auto;
}
&:not(.name) {
flex: 0 0 auto;
padding: 0 1rem;
background-color: var(--block-grey-text);
color: var(--block-grey-bg);
font-weight: bolder;
margin: 0 0.5rem;
transform: skewX(-15deg);
}
}
}
.main-description {
padding: 0.5rem;
}
}
}
.timer-pane {
flex: 1 0 auto;
padding: 1rem;
.timer-controls {
display: flex;
button {
margin: 0;
padding: 1rem 0;
flex: 1 0 auto;
}
}
}
}

View file

@ -0,0 +1,12 @@
function taskBtnPress() {
console.log('TASK')
}
function breakBtnPress() {
console.log('BREAK')
}
function pauseBtnPress() {
console.log('PAUSE')
}

View file

@ -171,6 +171,7 @@ function check_and_fill_timezone(){
}
}
render_abstimes()
update_reltimes()
check_and_fill_timezone()

View file

@ -0,0 +1,79 @@
{% extends "main.html" %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="daily-plan.css") }} />
<script type="text/javascript" src="{{ url_for("static", filename="daily-plan.js") }}" defer></script>
{% endblock %}
{% block title %}Daily Plan{% endblock %}
{% macro task_entry(tsk) %}
<details class="{{'complete' if tsk.completed else 'incomplete'}}">
<summary>
{% if tsk.task %}
<span class="name">{{tsk.task.name}}</span>
<span
class="import"
data-importance="{{tsk.task.importance}}"
>I {{tsk.task.importance}}</span>
<span
class="mburn"
data-mental-burn="{{tsk.task.mental_burn}}"
>M {{tsk.task.mental_burn}}</span>
<span
class="sburn"
data-social-burn="{{tsk.task.social_burn}}"
>S {{tsk.task.social_burn}}</span>
{% else %}
<span class="name">Unknown Task</span>
{% endif %}
<span
class="time"
data-time-goal="{{tsk.time_goal}}"
data-time-spent="{{tsk.time_spent}}"
></span>
</summary>
<div class="main-description">
{% if tsk.task %}
{{render_as_markdown(tsk.task.description)|safe}}
{% else %}
<p>Details on this task are no longer available. It may have been deleted, or you may have lost access to it.</p>
{% endif %}
</div>
</details>
{% endmacro %}
{% block main_content %}
<div class="daily-plan">
<div class="main-pane">
<h1>Daily Plan</h1>
{% for task in tasks %}
{{task_entry(task)}}
{% endfor %}
</div>
<div class="timer-pane">
<h1>Mode: PAUSE</h1>
<div class="timer-controls">
<button type="button" class="btn green" onclick="taskBtnPress()" id="task-btn">TASK</button>
<button type="button" class="btn yellow" onclick="breakBtnPress()" id="break-btn">BREAK</button>
<button type="button" class="btn red" onclick="pauseBtnPress()" id="pause-btn">PAUSE</button>
</div>
<h2>Current Time</h2>
<p class="current-time"></p>
<h2>Time Left</h2>
{{ rtimer(plan.total_time_remaining)|safe }}
<h2>Break Left</h2>
{{ rtimer(plan.break_time_remaining)|safe }}
<details>
<summary>Plan Details</summary>
<dl>
<dt>Time Goal</dt><dd>{{time_estimate_str(plan.total_time_goal)}}</dd>
<dt>Break Allowance</dt><dd>{{time_estimate_str(plan.break_time_goal)}}</dd>
<dt>Total Burn</dt><dd>{{plan.burn_goal}}</dd>
<dt>Requested Time Goal</dt><dd>{{time_estimate_str(plan.total_time_intent)}}</dd>
<dt>Requested Total Burn</dt><dd>{{plan.burn_intent}}</dd>
</dl>
</details>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="forms.css") }} />
{% endblock %}
{% block title %}Generate Daily Plan{% endblock %}
{% block main_content %}
<form class="default-form" id="create-daily-form" method="POST">
<h1>Generate Daily Plan</h1>
<dl>
{{ render_field(form.scope) }}
{{ render_field(form.planning) }}
{{ render_field(form.burn) }}
{{ render_field(form.burn_type) }}
<div class="time-length-container">
{{ render_field(form.time) }}
{{ render_field(form.time_unit) }}
</div>
<div class="time-length-container">
{{ render_field(form.breaks) }}
{{ render_field(form.break_unit) }}
</div>
</dl>
<button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}Generate Daily Plan</button>
</form>
{% endblock %}

View file

@ -16,6 +16,10 @@
{{ render_field(form.description) }}
<div id="tz-container">{{ render_field(form.timezone) }}</div>
{{ render_field(form.namespace) }}
{{ render_field(form.time_estimate) }}
{{ render_field(form.mental_burn) }}
{{ render_field(form.social_burn) }}
{{ render_field(form.importance) }}
</dl>
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}CREATE TASK</button></p>
</form>

View file

@ -60,6 +60,19 @@
<div class="detail-view-header">
<h1>Task Details: {{ task.name }}</h1>
</div>
<p class="task-importance">Importance: [{{ task.importance }}] {{ importance_str(task.importance) }}</p>
<div class="burn-tray">
<p class="bt-header">Mental Burn: [{{ task.mental_burn }}] {{ burn_str(task.mental_burn) }}</p>
<p class="bt-header">Social Burn: [{{ task.social_burn }}] {{ burn_str(task.social_burn) }}</p>
</div>
{% if task.time_estimate %}
<p class="time-estimate">Time Estimate: {{ time_estimate_str(task.time_estimate) }}</p>
{% endif %}
{% if task.time_spent %}
<p class="time-spent">Time Spent: {{ time_estimate_str(task.time_spent) }}</p>
{% endif %}
{% if task.category %}
<p class="category">Category: {{ task.category }}</p>
{% endif %}

View file

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

View file

@ -90,13 +90,45 @@ def render_reltime(dt: TAD) -> SafeHTML:
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)
dt_aware: TAD = 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>'
def render_timer(seconds: int, running: bool = False, id: str = 'timer') -> SafeHTML:
hours = seconds // (60*60)
minutes = (seconds // 60) % 60
seconds = seconds % 60
return f'<span class="render-timer" id={id} data-time="{seconds}" data-running="{"true" if running else "false"}">{hours:02}:{minutes:02}:{seconds:02}</span>'
def time_to_db(dt: TAD|TND) -> datetime:
''' Prepare a timestamp for storage in the database.
The database does NOT store timezone information, so everything needs to
be converted to UTC before storing it.
'''
return (
(
dt.astimezone(timezone.utc)
) if is_timezone_aware(dt) else (
dt
)
)
def time_from_db(dt: TND) -> TAD:
''' Interpret a timestamp from the database
The database does NOT store timezone information; all timestamps are
always converted to UTC before storage. This function appends the
timezone information, so that the datetime is properly timezone-aware.
'''
return dt.replace(
tzinfo=timezone.utc
)
WEEKDAYS=[
'MONDAY',
'TUESDAY',

View file

@ -1,5 +1,6 @@
from flask import Blueprint
from taskflower.web.auth import web_auth
from taskflower.web.daily import web_daily
from taskflower.web.invite import web_invite
from taskflower.web.namespace import web_namespace
from taskflower.web.task import web_tasks
@ -11,4 +12,5 @@ web_base.register_blueprint(web_tasks)
web_base.register_blueprint(web_user)
web_base.register_blueprint(web_auth)
web_base.register_blueprint(web_namespace)
web_base.register_blueprint(web_invite)
web_base.register_blueprint(web_invite)
web_base.register_blueprint(web_daily)

View file

@ -0,0 +1,293 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal
from functools import reduce
import logging
from typing import Self
from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
from wtforms import DecimalField, Form, SelectField
from wtforms.validators import InputRequired, NumberRange
from taskflower.auth.permission import NPT
from taskflower.auth.permission.lookups import tasks_where_user_can
from taskflower.db import db
from taskflower.db.model.daily import DailyPlan, DailyPlanTask, PlanFlags
from taskflower.db.model.task import Task
from taskflower.db.model.user import User
from taskflower.sanitize.task import TaskForUser
from taskflower.types import CheckType, assert_usr
from taskflower.types.either import Either, Right, reduce_either
from taskflower.util.time import now
from taskflower.web.errors import response_from_exception
from taskflower.web.daily.planner import generate_daily_plan
log = logging.getLogger(__name__)
web_daily = Blueprint(
'daily',
__name__,
'/templates',
url_prefix='/daily'
)
class DailyPlanForm(Form):
scope: SelectField = SelectField(
'Preferred Scope',
[
InputRequired()
],
coerce=lambda raw: PlanFlags(int(raw)), # pyright:ignore[reportAny]
choices=[
(0, 'No preference'),
(PlanFlags.SCOPE_BIG.value, 'Prefer big tasks'),
(PlanFlags.SCOPE_SMALL.value, 'Prefer small tasks')
]
)
planning: SelectField = SelectField(
'Planning preference',
[
InputRequired()
],
coerce=lambda raw: PlanFlags(int(raw)), # pyright:ignore[reportAny]
choices=[
(0, 'No preference'),
(PlanFlags.PREFER_PLANNING.value, 'Prefer planning tasks'),
(PlanFlags.AVOID_PLANNING.value, 'Avoid planning tasks')
]
)
burn: SelectField = SelectField(
'Burn preference',
[
InputRequired()
],
coerce=lambda raw: PlanFlags(int(raw)), # pyright:ignore[reportAny]
choices=[
(0, 'Give me a regular day'),
(PlanFlags.LOW_BURN.value, 'Give me a gentle (low-burn) day'),
(PlanFlags.HIGH_BURN.value, 'Give me an intense (high-burn) day')
]
)
burn_type: SelectField = SelectField(
'Burn type preferences',
[
InputRequired()
],
coerce=lambda raw: PlanFlags(int(raw)), # pyright:ignore[reportAny]
choices=[
(0, 'No preference'),
(PlanFlags.PREFER_MENTAL.value, 'Avoid social exertion'),
(PlanFlags.PREFER_SOCIAL.value, 'Prefer social exertion')
]
)
time: DecimalField = DecimalField(
'Plan Length',
[
NumberRange(1, 60)
],
default=Decimal(8.0)
)
time_unit: SelectField = SelectField(
'Plan Length Unit',
[
InputRequired()
],
coerce=int,
choices=[
(60, 'Minutes'),
(60*60, 'Hours')
],
default=60*60
)
breaks: DecimalField = DecimalField(
'Time to leave aside for breaks',
[
NumberRange(1, 60)
],
default=Decimal(1.0)
)
break_unit: SelectField = SelectField(
'Break time unit',
[
InputRequired()
],
coerce=int,
choices=[
(60, 'Minutes'),
(60*60, 'Hours')
],
default=60*60
)
@property
def time_info(self):
@dataclass(frozen=True)
class _TInfo:
length: float
breaks: float
@property
def effective(self) -> float:
return self.length - self.breaks
return CheckType(Decimal)(
self.time.data
).flat_map(
lambda tdata: CheckType(Decimal)(
self.breaks.data
).flat_map(
lambda bdata: CheckType(int)(
self.time_unit.data # pyright:ignore[reportAny]
).flat_map(
lambda tunit: CheckType(int)(
self.break_unit.data # pyright:ignore[reportAny]
).map(
lambda bunit: _TInfo(
float(tdata)*float(tunit),
float(bdata)*float(bunit)
)
)
)
)
)
def _burn_per_hour(flags: PlanFlags):
return (
32
if PlanFlags.HIGH_BURN in flags else
8 if PlanFlags.LOW_BURN in flags else
16
)
@dataclass(frozen=True)
class PlanTaskForUser:
task: TaskForUser|None
order: int
target: datetime
completed: datetime|None
time_spent: int
time_goal: int
@classmethod
def from_plan(
cls,
plan: DailyPlanTask,
for_user: User
) -> Either[Exception, Self]:
return TaskForUser.from_task(
plan.task,
for_user
).map(
lambda tsk: cls(
tsk,
plan.order,
plan.target,
plan.completed,
plan.time_spent,
plan.time_goal
)
) if plan.task else Right(cls(
None,
plan.order,
plan.target,
plan.completed,
plan.time_spent,
plan.time_goal
))
@web_daily.route('/')
@login_required
def get():
cur_usr = assert_usr(current_user)
most_recent_plan = db.session.query(
DailyPlan
).filter(
DailyPlan.user_id == cur_usr.id
).order_by(
DailyPlan.start_at_raw.desc()
).first()
if ((
not most_recent_plan
) or (
now() - most_recent_plan.start_at > timedelta(days=1)
)):
return redirect(url_for('web.daily.new'))
return reduce_either([
PlanTaskForUser.from_plan(pt, cur_usr)
for pt in most_recent_plan.plan_tasks
]).and_then(
lambda tsks: render_template(
'daily/active_daily.html',
tasks=sorted(
tsks,
key=lambda tsk: tsk.order
),
plan=most_recent_plan
),
lambda exc: response_from_exception(exc)
)
@web_daily.route('/new', methods=['GET', 'POST'])
@login_required
def new():
cur_usr = assert_usr(current_user)
form_data = DailyPlanForm(request.form)
if request.method == 'POST' and form_data.validate():
flags = reduce_either([
CheckType(int)(fl.data).lside_effect( # pyright:ignore[reportAny]
lambda exc: log.warning(
f'Type-check failed while parsing user-provided DailyPlan flags: {exc!s}'
)
)
for fl in [
form_data.scope,
form_data.planning,
form_data.burn,
form_data.burn_type,
]
]).map(
lambda fls: reduce(
lambda a, b: a | PlanFlags(b),
fls,
PlanFlags(0)
)
).lside_effect(
lambda exc: log.warning(f'Failed to parse flags: {exc}')
).and_then(
lambda fl: fl,
lambda _: PlanFlags(0)
)
return Right[Exception, list[Task]](
tasks_where_user_can(
cur_usr,
NPT.COMPLETE_ALL_TASKS
)
).flat_map(
lambda tasks: form_data.time_info.map(
lambda tinfo: (tasks, tinfo)
)
).map(
lambda data: generate_daily_plan(
cur_usr,
data[0],
flags,
int(_burn_per_hour(flags)*(data[1].effective / 60.0)),
int(data[1].length),
int(data[1].breaks),
now()
)
).and_then(
lambda _: redirect(url_for('web.daily.get')),
lambda exc: response_from_exception(exc)
)
else:
return render_template(
'daily/new_daily.html',
form=form_data
)

View file

@ -0,0 +1,361 @@
from dataclasses import dataclass
from datetime import timedelta
from functools import reduce
import logging
from random import sample
from typing import Any, override
from taskflower.db import db
from taskflower.db.model.daily import DailyPlan, DailyPlanTask, PlanFlags
from taskflower.db.model.task import Task
from taskflower.db.model.user import User
from taskflower.util.time import TAD, get_reltime
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
LARGE_SCOPE_THRESH_SEC = 2*60*60 # 2 hours
SMALL_SCOPE_THRESH_SEC = 15*60 # 15 minutes
LARGE_BURN_THRESH = 16
SMALL_BURN_THRESH = 6
def _calc_effective_burn(
task: Task,
flags: PlanFlags
) -> int:
mental_bf: int = (
2**task.mental_burn
) if task.mental_burn > 0 else (
-1*(2**(-1*task.mental_burn))
) if task.mental_burn < 0 else 0
social_bf: int = (
2**task.social_burn
) if task.social_burn > 0 else (
-1*(2**(-1*task.social_burn))
) if task.social_burn < 0 else 0
if PlanFlags.PREFER_MENTAL in flags:
social_bf *= 2
elif PlanFlags.PREFER_SOCIAL in flags:
mental_bf *= 2
return mental_bf + social_bf
def _calc_motivation_for(
task: Task,
flags: PlanFlags
) -> int:
''' Calculate the 'motivation' value for a given task
'''
motivation = 0
# Calculate time values into motivation if requested by
# scope.
time_factor = 0
if task.time_estimate:
if (
(task.time_estimate - task.time_spent)
>= LARGE_SCOPE_THRESH_SEC
):
time_factor = 100
elif (
(task.time_estimate - task.time_spent)
<= SMALL_SCOPE_THRESH_SEC
):
time_factor -= 100
if PlanFlags.SCOPE_BIG in flags:
motivation += time_factor
elif PlanFlags.SCOPE_SMALL in flags:
motivation += -1*time_factor
# Calculate burn values into motivation
burn_sum = _calc_effective_burn(
task,
flags
)
burn_factor = 0
if burn_sum < SMALL_BURN_THRESH:
burn_factor += 100
elif burn_sum > LARGE_BURN_THRESH:
burn_factor -= 100
else:
burn_factor += 10
if PlanFlags.HIGH_BURN:
motivation += -1*burn_factor
elif PlanFlags.LOW_BURN:
motivation += 2*burn_factor
else:
motivation += burn_factor
# Take into account task importance
importance_factor = task.importance*20
motivation += importance_factor
return motivation
def _is_urgent(task: Task) -> bool:
if not task.due:
return False
return (
get_reltime(task.due) < timedelta(seconds=(
(task.time_estimate - task.time_spent + 60*60*24)
if task.time_estimate
else 60*60*24
))
) and (task.importance >= 2)
def _time_for(task: Task) -> int:
return (
min(task.time_estimate, 8*60*60)
) if task.time_estimate else 30*60
def _calc_totals(tlst: list[Task], flags: PlanFlags) -> tuple[int, int]:
total_burn = reduce(
lambda val, tsk: val + _calc_effective_burn(
tsk,
flags
),
tlst,
0
)
total_time = reduce(
lambda val, tsk: val + _time_for(
tsk
),
tlst,
0
)
return total_burn, total_time
def _burn_import(
twm: TaskWithMotivation,
flags: PlanFlags
) -> int:
# A combination of burn and importance, weighted according to flags
return (
_calc_effective_burn(twm.task, flags)
* (
1
if PlanFlags.HIGH_BURN in flags
else -2 if PlanFlags.LOW_BURN in flags
else -1
)
) + twm.motivation
def _time_import(
twm: TaskWithMotivation,
flags: PlanFlags
) -> int:
return (
max(
(twm.task.time_estimate or 0)
- twm.task.time_spent,
5*60*60
) // 60 * (
1
if PlanFlags.SCOPE_BIG in flags
else -2 if PlanFlags.SCOPE_SMALL in flags
else -1
)
) + twm.motivation
@dataclass(frozen=True)
class TaskWithMotivation:
task : Task
# 'motivation' = how much does the planner want to keep this task in the
# scheduler. low-motivation tasks get removed first during the budgeting
# pass.
motivation : int
@override
def __eq__(self, other: TaskWithMotivation|Any, /) -> bool: # pyright:ignore[reportExplicitAny]
if not isinstance(other, TaskWithMotivation):
return False
return (
self.task.id == other.task.id
) and (
self.motivation == other.motivation
)
def generate_daily_plan(
user : User,
tasks : list[Task],
flags : PlanFlags,
burn_budget : int,
time_budget : int,
break_budget : int,
start_at : TAD
) -> tuple[DailyPlan, list[DailyPlanTask]]:
log.debug(f'Generating daily plan for user {user.display_name}')
urgent_tasks : list[Task] = []
non_urgent_tasks : list[Task] = []
deferred_tasks : list[Task] = []
tasks_sorted = sorted(
tasks,
key=lambda task: task.importance,
reverse=True
)
for task in tasks_sorted:
if not task.due:
deferred_tasks.append(task)
elif _is_urgent(task):
urgent_tasks.append(task)
else:
non_urgent_tasks.append(task)
# Initial 'blue-sky' scheduling pass.
# Assuming infinite time and burn, what tasks in the next few weeks
# should we include, and how much do we want to include each one?
preliminary_plan: list[TaskWithMotivation] = []
for task in urgent_tasks:
log.debug(f'Adding task {task.name} to plan because task is urgent.')
# Always schedule urgent tasks.
preliminary_plan.append(TaskWithMotivation(
task,
1000
))
# Pick the first 5 tasks, then 5 random tasks from the next 10.
by_motivation = [
TaskWithMotivation(
task,
_calc_motivation_for(
task,
flags
)
)
for task in non_urgent_tasks
]
to_add = by_motivation[:5]
try:
to_add += sample(
by_motivation[5:15],
5
)
except Exception:
# (if there are less than 10 tasks to sample from)
to_add += by_motivation[5:10]
for twm in to_add:
log.debug(f'Adding non-urgent task {twm.task.name} [motivation {twm.motivation}]')
preliminary_plan += to_add
# ==== SECOND PASS - enforce budgets ====
total_burn, total_time = _calc_totals(
[
twm.task
for twm in preliminary_plan
],
flags
)
log.debug(f'After first pass, daily plan has {len(preliminary_plan)} tasks in it ({total_burn} effective burn; {total_time} seconds).')
if total_burn > burn_budget:
log.debug(f'Total burn {total_burn} exceeds budget of {burn_budget}.')
# Remove low-importance, high-burn tasks
remove_order = sorted(
preliminary_plan,
key=lambda twm: _burn_import(twm, flags)
)
for twm in remove_order:
if _is_urgent(twm.task):
continue
log.debug(f'Removed task {twm.task.name} to reduce burn (motivation {twm.motivation}; effective burn {_calc_effective_burn(twm.task, flags)})')
preliminary_plan.remove(twm)
total_burn, total_time = _calc_totals(
[
twm.task
for twm in preliminary_plan
],
flags
)
log.debug(f'Total burn is now: {total_burn}.')
if total_burn <= burn_budget:
break
if total_time > (time_budget - break_budget):
log.debug(f'Total time {total_time} exceeds time budget ({time_budget} base - {break_budget} break = {time_budget - break_budget} total)')
# Remove low-motivation, high-time tasks
remove_order = sorted(
preliminary_plan,
key=lambda twm: _time_import(twm, flags)
)
for twm in remove_order:
if _is_urgent(twm.task):
continue
log.debug(f'Removed task {twm.task.name} to reduce time (motivation {twm.motivation}; effective time {_time_for(twm.task)})')
preliminary_plan.remove(twm)
total_burn, total_time = _calc_totals(
[
twm.task
for twm in preliminary_plan
],
flags
)
log.debug(f'Total time is now: {total_time}.')
if total_time <= time_budget:
break
log.debug(f'After applying budget constraints, total effective burn is now {total_burn} and total time is {total_time}')
if total_burn > burn_budget:
log.debug(f' > NOTE: total burn {total_burn} exceeds budget of {burn_budget}')
if total_time > time_budget:
log.debug(f' > NOTE: total time {total_time} exceeds budget of {time_budget}')
# ==== SCHEDULING ====
plan = DailyPlan(
user.id,
start_at,
break_budget,
total_time,
time_budget,
total_burn,
burn_budget,
flags,
)
db.session.add(plan)
db.session.commit()
print(plan.id)
pTasks: list[DailyPlanTask] = []
time = start_at
for dex, twm in enumerate(preliminary_plan):
pTasks.append(DailyPlanTask(
twm.task.id,
plan.id,
dex,
time,
_time_for(twm.task)
))
db.session.add_all(pTasks)
db.session.commit()
return plan, pTasks

View file

@ -85,7 +85,7 @@ def get(id: int):
).order_by(
Task.complete.asc()
).order_by(
Task.due.asc()
Task.due_raw.asc()
).all()
return gather_successes(

View file

@ -9,7 +9,7 @@ from taskflower.auth.violations import check_for_auth_err_and_report
from taskflower.db import commit_update, db, db_fetch_by_id, do_delete
from taskflower.db.helpers import add_to_db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.task import Task
from taskflower.db.model.task import Task, burn_str, importance_str
from taskflower.db.model.user import User
from taskflower.form.task import task_edit_form_for_task, task_form_for_user
from taskflower.sanitize.task import TaskForUser
@ -37,8 +37,16 @@ def tasks_processor():
# Generate a unique HTML ``id`` for a component in a task list.
return f'list-{list_id}-{component}-{task_id}'
def _imp_str(importance: int) -> str:
return importance_str(importance)
def _brn_str(burn: int) -> str:
return burn_str(burn)
return dict(
tlist_cid=tlist_cid
tlist_cid=tlist_cid,
importance_str=_imp_str,
burn_str=_brn_str,
)
@web_tasks.route('/')