Partial daily plan implementation
This commit is contained in:
parent
c7f6f3f4f1
commit
e0063fffdb
27 changed files with 1687 additions and 53 deletions
74
src/migrations/versions/4a2deb2e7bda_daily_tasks_setup.py
Normal file
74
src/migrations/versions/4a2deb2e7bda_daily_tasks_setup.py
Normal 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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
37
src/migrations/versions/f1bc3cfb815d_rename_task_columns.py
Normal file
37
src/migrations/versions/f1bc3cfb815d_rename_task_columns.py
Normal 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 ###
|
||||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
186
src/taskflower/db/model/daily.py
Normal file
186
src/taskflower/db/model/daily.py
Normal 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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
57
src/taskflower/static/daily-plan.css
Normal file
57
src/taskflower/static/daily-plan.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/taskflower/static/daily-plan.js
Normal file
12
src/taskflower/static/daily-plan.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
function taskBtnPress() {
|
||||
console.log('TASK')
|
||||
}
|
||||
|
||||
function breakBtnPress() {
|
||||
console.log('BREAK')
|
||||
}
|
||||
|
||||
function pauseBtnPress() {
|
||||
console.log('PAUSE')
|
||||
}
|
||||
|
|
@ -171,6 +171,7 @@ function check_and_fill_timezone(){
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
render_abstimes()
|
||||
update_reltimes()
|
||||
check_and_fill_timezone()
|
||||
|
|
|
|||
79
src/taskflower/templates/daily/active_daily.html
Normal file
79
src/taskflower/templates/daily/active_daily.html
Normal 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 %}
|
||||
29
src/taskflower/templates/daily/new_daily.html
Normal file
29
src/taskflower/templates/daily/new_daily.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
293
src/taskflower/web/daily/__init__.py
Normal file
293
src/taskflower/web/daily/__init__.py
Normal 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
|
||||
)
|
||||
361
src/taskflower/web/daily/planner.py
Normal file
361
src/taskflower/web/daily/planner.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue