Complete basic daily plan system
This commit is contained in:
parent
e0063fffdb
commit
a7cff4af57
14 changed files with 1038 additions and 75 deletions
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Add pause time; convert total time spent to a dynamic property
|
||||||
|
|
||||||
|
Revision ID: 5c45e850c43b
|
||||||
|
Revises: a2f8f8dc3ab0
|
||||||
|
Create Date: 2025-12-12 22:03:23.185370
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5c45e850c43b'
|
||||||
|
down_revision = 'a2f8f8dc3ab0'
|
||||||
|
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('pause_time_spent', sa.Integer(), nullable=True))
|
||||||
|
batch_op.drop_column('total_time_spent')
|
||||||
|
op.execute('UPDATE daily_plan SET pause_time_spent = 0')
|
||||||
|
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('pause_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.add_column(sa.Column('total_time_spent', sa.INTEGER(), nullable=False))
|
||||||
|
batch_op.drop_column('pause_time_spent')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Add daily plan completion
|
||||||
|
|
||||||
|
Revision ID: 73745bd370e7
|
||||||
|
Revises: 5c45e850c43b
|
||||||
|
Create Date: 2025-12-13 03:17:38.607148
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '73745bd370e7'
|
||||||
|
down_revision = '5c45e850c43b'
|
||||||
|
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('complete', sa.Boolean(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('completed_raw', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
op.execute('UPDATE daily_plan SET complete = false')
|
||||||
|
|
||||||
|
with op.batch_alter_table('daily_plan', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('complete', 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('completed_raw')
|
||||||
|
batch_op.drop_column('complete')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -8,6 +8,7 @@ from taskflower.auth.startup import startup_checks
|
||||||
from taskflower.config import SignUpMode, config
|
from taskflower.config import SignUpMode, config
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
from taskflower.api import APIBase
|
from taskflower.api import APIBase
|
||||||
|
from taskflower.db.model.task import burn_str, importance_str
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
|
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
|
||||||
from taskflower.tools.icons import get_icon, svg_bp
|
from taskflower.tools.icons import get_icon, svg_bp
|
||||||
|
|
@ -152,7 +153,9 @@ def template_utility_fns():
|
||||||
icon=icon,
|
icon=icon,
|
||||||
cur_page_with_variables=cur_page_with_variables,
|
cur_page_with_variables=cur_page_with_variables,
|
||||||
len=t_len,
|
len=t_len,
|
||||||
time_estimate_str=_render_time_est
|
time_estimate_str=_render_time_est,
|
||||||
|
importance_str=importance_str,
|
||||||
|
burn_str=burn_str
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
# pyright: reportImportCycles=false
|
# pyright: reportImportCycles=false
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import IntFlag, auto
|
from enum import IntFlag, auto
|
||||||
|
from functools import reduce
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
|
|
@ -53,15 +54,19 @@ class DailyPlan(db.Model):
|
||||||
break_time_goal : Mapped[int] = mapped_column(Integer)
|
break_time_goal : Mapped[int] = mapped_column(Integer)
|
||||||
break_time_spent : Mapped[int] = mapped_column(Integer, default=0)
|
break_time_spent : Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
pause_time_spent : Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
total_time_intent: Mapped[int] = mapped_column(Integer)
|
total_time_intent: Mapped[int] = mapped_column(Integer)
|
||||||
total_time_goal : 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_intent: Mapped[int] = mapped_column(Integer)
|
||||||
burn_goal : Mapped[int] = mapped_column(Integer)
|
burn_goal : Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
flags_raw : Mapped[int] = mapped_column(Integer)
|
flags_raw : Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
complete : Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
completed_raw: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def overtime(self) -> bool:
|
def overtime(self) -> bool:
|
||||||
''' Does the combined total time of tasks in this plan exceed the
|
''' Does the combined total time of tasks in this plan exceed the
|
||||||
|
|
@ -98,10 +103,30 @@ class DailyPlan(db.Model):
|
||||||
def start_at(self, val: TAD|TND):
|
def start_at(self, val: TAD|TND):
|
||||||
self.start_at_raw = time_to_db(val)
|
self.start_at_raw = time_to_db(val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completed(self) -> TAD:
|
||||||
|
return time_from_db(self.completed_raw)
|
||||||
|
|
||||||
|
@completed.setter
|
||||||
|
def completed(self, val: TAD|TND):
|
||||||
|
self.completed_raw = time_to_db(val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def break_time_remaining(self) -> int:
|
def break_time_remaining(self) -> int:
|
||||||
return self.break_time_goal - self.break_time_spent
|
return self.break_time_goal - self.break_time_spent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_time_spent(self) -> int:
|
||||||
|
return self.break_time_spent + self.pause_time_spent + self.total_time_on_task
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_time_on_task(self) -> int:
|
||||||
|
return reduce(
|
||||||
|
lambda acc, cur: acc + cur.time_spent,
|
||||||
|
self.plan_tasks,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_time_remaining(self) -> int:
|
def total_time_remaining(self) -> int:
|
||||||
return self.total_time_goal - self.total_time_spent
|
return self.total_time_goal - self.total_time_spent
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,314 @@
|
||||||
|
@keyframes activeTaskBlink {
|
||||||
|
from {background-color: var(--block-grey-text);}
|
||||||
|
50% {background-color: var(--active-glow);}
|
||||||
|
to {background-color: var(--block-grey-text);}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes finishedTaskBorderPulse {
|
||||||
|
from {
|
||||||
|
border-color: var(--complete-bg-glow);
|
||||||
|
box-shadow: 1px 1px 2px 2px var(--complete-bg-glow) inset, 1px 1px 2px 2px var(--complete-bg-glow);
|
||||||
|
}
|
||||||
|
5% {
|
||||||
|
border-color: var(--complete-glow);
|
||||||
|
box-shadow: 1px 1px 2px 4px var(--complete-glow) inset, 1px 1px 2px 4px var(--complete-glow);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
border-color: var(--complete-bg-glow);
|
||||||
|
box-shadow: 1px 1px 2px 2px var(--complete-bg-glow) inset, 1px 1px 2px 2px var(--complete-bg-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btnShadowPulse {
|
||||||
|
from {
|
||||||
|
box-shadow: 0 0 4px 4px inset var(--btn-shadow-color-1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 4px 8px inset var(--btn-shadow-color-2);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
box-shadow: 0 0 4px 4px inset var(--btn-shadow-color-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.daily-plan {
|
.daily-plan {
|
||||||
|
--imp-0: #bad1ff;
|
||||||
|
--imp-1: #babfff;
|
||||||
|
--imp-2: #c9baff;
|
||||||
|
--imp-3: #e0baff;
|
||||||
|
--imp-4: #ebbaff;
|
||||||
|
--imp-5: #f1baff;
|
||||||
|
--imp-6: #fc9aff;
|
||||||
|
--imp-7: #ff86ff;
|
||||||
|
--imp-8: #ff70c3;
|
||||||
|
--imp-9: #ff6794;
|
||||||
|
--imp-10: #ff7070;
|
||||||
|
|
||||||
|
--burn-n2: #64faff;
|
||||||
|
--burn-n1: #64ff98;
|
||||||
|
--burn-0: #64ff64;
|
||||||
|
--burn-1: #c9ff64;
|
||||||
|
--burn-2: #fffc64;
|
||||||
|
--burn-3: #ffb24d;
|
||||||
|
--burn-4: #ff834a;
|
||||||
|
--burn-5: #ff6464;
|
||||||
|
|
||||||
|
--active-glow: #a0fff7;
|
||||||
|
--active-bg-glow: #24253b;
|
||||||
|
--complete-glow: #2bff00;
|
||||||
|
--complete-bg-glow: #243b24;
|
||||||
|
--complete-timer-glow: #a3ffa0;
|
||||||
|
|
||||||
|
--sel-inactive-bg : #383838;
|
||||||
|
--sel-inactive-bg-hover : #2e2e2e;
|
||||||
|
--sel-inactive-bg-active : #242424;
|
||||||
|
--sel-inactive-border : #4d4d4d;
|
||||||
|
--sel-inactive-text : #cfcfcf;
|
||||||
|
--sel-task-active-base : #dbdbdb;
|
||||||
|
--sel-task-bg-glow : #7dff84;
|
||||||
|
--sel-task-border-glow : #243328;
|
||||||
|
--sel-break-bg-glow : #fcff43;
|
||||||
|
--sel-break-border-glow : #3a3a26;
|
||||||
|
--sel-pause-bg-glow : #ff5757;
|
||||||
|
--sel-pause-border-glow : #352525;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&[data-plan-active="0"] button.play-pause-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-plan-active="0"] .complete {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.main-pane {
|
.main-pane {
|
||||||
flex: 3 0 auto;
|
flex: 3 1 auto;
|
||||||
|
max-width: 80%;
|
||||||
details {
|
details {
|
||||||
|
--box-shadow-color: var(--block-grey-text);
|
||||||
|
--box-shadow-outer-color: var(--block-grey-bg);
|
||||||
|
--border-color: var(--block-grey-text);
|
||||||
|
--bg-color: var(--block-grey-bg);
|
||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: var(--block-grey-bg);
|
background-color: var(--bg-color);
|
||||||
border: 1px solid var(--block-grey-text);
|
border: 1px solid var(--border-color);
|
||||||
box-shadow: 1px 1px 1px var(--block-grey-text) inset;
|
box-shadow: 4px 4px 0px var(--box-shadow-outer-color), 1px 1px 2px 1px var(--box-shadow-color) inset;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
--border-color: var(--active-glow);
|
||||||
|
--bg-color: var(--active-bg-glow);
|
||||||
|
--box-shadow-color: var(--active-glow);
|
||||||
|
--box-shadow-outer-color: var(--active-bg-glow);
|
||||||
|
span.time {
|
||||||
|
animation: activeTaskBlink 1s infinite;
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
animation: none;
|
||||||
|
background-color: var(--active-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.complete {
|
||||||
|
--border-color : var(--complete-glow);
|
||||||
|
--bg-color : var(--complete-bg-glow);
|
||||||
|
--box-shadow-color : var(--complete-glow);
|
||||||
|
--box-shadow-outer-color : var(--complete-bg-glow);
|
||||||
|
animation: finishedTaskBorderPulse 30s;
|
||||||
|
|
||||||
|
border-color: var(--complete-bg-glow);
|
||||||
|
box-shadow: 1px 1px 2px 2px var(--complete-bg-glow) inset, 1px 1px 2px 2px var(--complete-bg-glow);
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
animation: none;
|
||||||
|
border-color: var(--complete-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
span.time {
|
||||||
|
animation: none;
|
||||||
|
background-color: var(--complete-timer-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.incomplete button.done-btn-disabled {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.complete button.done-btn, &.complete button.play-pause-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid var(--block-grey-text);
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
|
||||||
|
&.done-btn, &.done-btn-disabled {
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 0.2rem;
|
||||||
&.name {
|
&.name {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
&:not(.name) {
|
&:not(.name) {
|
||||||
|
cursor: default;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
background-color: var(--block-grey-text);
|
background-color: var(--block-grey-text);
|
||||||
color: var(--block-grey-bg);
|
color: var(--block-grey-bg);
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
margin: 0 0.5rem;
|
margin: 0 0.2rem;
|
||||||
transform: skewX(-15deg);
|
transform: skewX(-15deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.imp-0 {background-color: var(--imp-0);}
|
||||||
|
&.imp-1 {background-color: var(--imp-1);}
|
||||||
|
&.imp-2 {background-color: var(--imp-2);}
|
||||||
|
&.imp-3 {background-color: var(--imp-3);}
|
||||||
|
&.imp-4 {background-color: var(--imp-4);}
|
||||||
|
&.imp-5 {background-color: var(--imp-5);}
|
||||||
|
&.imp-6 {background-color: var(--imp-6);}
|
||||||
|
&.imp-7 {background-color: var(--imp-7);}
|
||||||
|
&.imp-8 {background-color: var(--imp-8);}
|
||||||
|
&.imp-9 {background-color: var(--imp-9);}
|
||||||
|
&.imp-10 {background-color: var(--imp-10);}
|
||||||
|
|
||||||
|
&.burn--2 {background-color: var(--burn-n2);}
|
||||||
|
&.burn--1 {background-color: var(--burn-n1);}
|
||||||
|
&.burn-0 {background-color: var(--burn-0);}
|
||||||
|
&.burn-1 {background-color: var(--burn-1);}
|
||||||
|
&.burn-2 {background-color: var(--burn-2);}
|
||||||
|
&.burn-3 {background-color: var(--burn-3);}
|
||||||
|
&.burn-4 {background-color: var(--burn-4);}
|
||||||
|
&.burn-5 {background-color: var(--burn-5);}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-description {
|
.main-description {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
a {
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: dotted underline;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer-pane {
|
.timer-pane {
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--block-grey-bg);
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
background-color: var(--active-glow);
|
||||||
|
color: var(--active-bg-glow);
|
||||||
|
margin: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
background-color: var(--sel-inactive-bg-hover);
|
||||||
|
border-radius: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.timer-controls {
|
.timer-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
--btn-bg-color: var(--sel-inactive-bg);
|
||||||
|
--btn-border-color: var(--sel-inactive-border);
|
||||||
|
--btn-shadow-color: var(--sel-inactive-border);
|
||||||
|
--btn-text-color: var(--sel-inactive-text);
|
||||||
|
|
||||||
|
--btn-bg-color-hover: var(--sel-inactive-bg-hover);
|
||||||
|
--btn-bg-color-active: var(--sel-inactive-bg-active);
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: var(--btn-bg-color);
|
||||||
|
border: 1px solid var(--btn-border-color);
|
||||||
|
box-shadow: 0 0 1px 1px inset var(--btn-shadow-color);
|
||||||
|
color: var(--btn-text-color);
|
||||||
|
font-weight: bolder;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--btn-bg-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--btn-bg-color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2){
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
--btn-text-color : var(--sel-inactive-text);
|
||||||
|
--btn-bg-color : var(--sel-inactive-bg-active);
|
||||||
|
--btn-bg-color-hover : var(--sel-inactive-bg-active);
|
||||||
|
--btn-bg-color-active: var(--sel-inactive-bg-active);
|
||||||
|
animation: btnShadowPulse 4s infinite;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
animation: none;
|
||||||
|
box-shadow: 0 0 4px 8px inset var(--btn-shadow-color-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#task-btn.active {
|
||||||
|
--btn-border-color : var(--sel-task-border-glow);
|
||||||
|
--btn-shadow-color : var(--sel-task-bg-glow);
|
||||||
|
--btn-shadow-color-1 : var(--sel-task-border-glow);
|
||||||
|
--btn-shadow-color-2 : var(--sel-task-bg-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#break-btn.active {
|
||||||
|
--btn-border-color : var(--sel-break-border-glow);
|
||||||
|
--btn-shadow-color : var(--sel-break-bg-glow);
|
||||||
|
--btn-shadow-color-1 : var(--sel-break-border-glow);
|
||||||
|
--btn-shadow-color-2 : var(--sel-break-bg-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pause-btn.active {
|
||||||
|
--btn-border-color : var(--sel-pause-border-glow);
|
||||||
|
--btn-shadow-color : var(--sel-pause-bg-glow);
|
||||||
|
--btn-shadow-color-1 : var(--sel-pause-border-glow);
|
||||||
|
--btn-shadow-color-2 : var(--sel-pause-bg-glow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,308 @@
|
||||||
|
const PLAN_STATE = Object.freeze({
|
||||||
|
PAUSED: 0,
|
||||||
|
BREAK: 1,
|
||||||
|
TASK: 2,
|
||||||
|
CLOSED: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
var activeTask = null
|
||||||
|
var planState = PLAN_STATE.PAUSED
|
||||||
|
|
||||||
|
var modifiedTasks = [];
|
||||||
|
var modifiedBreak = false;
|
||||||
|
var modifiedPause = false;
|
||||||
|
|
||||||
|
function updateActiveTime(spend) {
|
||||||
|
const taskTimer = document.getElementById('task-timer-total')
|
||||||
|
taskTimer.dataset.timeSpent = parseInt(taskTimer.dataset.timeSpent) + spend
|
||||||
|
renderTimer(taskTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBreakTime(spend) {
|
||||||
|
const breakTimer = document.getElementById('break-timer-total')
|
||||||
|
breakTimer.dataset.timeSpent = parseInt(breakTimer.dataset.timeSpent) + spend
|
||||||
|
renderTimer(breakTimer)
|
||||||
|
if(spend != 0){
|
||||||
|
modifiedBreak = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePauseTime(spend) {
|
||||||
|
const pauseTimer = document.getElementById('pause-timer-total')
|
||||||
|
pauseTimer.dataset.timeSpent = parseInt(pauseTimer.dataset.timeSpent) + spend
|
||||||
|
renderTimer(pauseTimer)
|
||||||
|
if(spend != 0){
|
||||||
|
modifiedPause = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTask(newTask) {
|
||||||
|
if(activeTask){
|
||||||
|
activeTask.classList.remove('active')
|
||||||
|
setPPButton(activeTask, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
newTask.classList.add('active')
|
||||||
|
activeTask = newTask
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPauseBtnPress(taskId) {
|
||||||
|
task = document.querySelector(
|
||||||
|
`.task-entry[data-task-id="${taskId}"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
if(task){
|
||||||
|
if(activeTask == task){
|
||||||
|
if(planState == PLAN_STATE.TASK){
|
||||||
|
breakMode()
|
||||||
|
}else{
|
||||||
|
playMode()
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
setActiveTask(task)
|
||||||
|
playMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPPButton(task, play){
|
||||||
|
const playPauseBtn = task.querySelector('.play-pause-btn')
|
||||||
|
if(play){
|
||||||
|
playPauseBtn.innerText = 'BRK'
|
||||||
|
playPauseBtn.classList.remove('green')
|
||||||
|
playPauseBtn.classList.add('yellow')
|
||||||
|
}else{
|
||||||
|
playPauseBtn.innerText = 'RUN'
|
||||||
|
playPauseBtn.classList.remove('yellow')
|
||||||
|
playPauseBtn.classList.add('green')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playMode() {
|
||||||
|
if(activeTask){
|
||||||
|
setPPButton(activeTask, true)
|
||||||
|
}
|
||||||
|
planState = PLAN_STATE.TASK
|
||||||
|
document.getElementById( 'task-btn').classList.add('active')
|
||||||
|
document.getElementById('break-btn').classList.remove('active')
|
||||||
|
document.getElementById('pause-btn').classList.remove('active')
|
||||||
|
syncTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakMode() {
|
||||||
|
if(activeTask){
|
||||||
|
setPPButton(activeTask, false)
|
||||||
|
}
|
||||||
|
planState = PLAN_STATE.BREAK
|
||||||
|
document.getElementById( 'task-btn').classList.remove('active')
|
||||||
|
document.getElementById('break-btn').classList.add('active')
|
||||||
|
document.getElementById('pause-btn').classList.remove('active')
|
||||||
|
syncTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseMode() {
|
||||||
|
if(activeTask){
|
||||||
|
setPPButton(activeTask, false)
|
||||||
|
}
|
||||||
|
planState = PLAN_STATE.PAUSED
|
||||||
|
document.getElementById( 'task-btn').classList.remove('active')
|
||||||
|
document.getElementById('break-btn').classList.remove('active')
|
||||||
|
document.getElementById('pause-btn').classList.add('active')
|
||||||
|
syncTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closedMode() {
|
||||||
|
planState = PLAN_STATE.CLOSED
|
||||||
|
|
||||||
|
document.getElementById( 'task-btn').classList.remove('active')
|
||||||
|
document.getElementById('break-btn').classList.remove('active')
|
||||||
|
document.getElementById('pause-btn').classList.remove('active')
|
||||||
|
|
||||||
|
document.getElementById( 'task-btn').classList.add('disabled')
|
||||||
|
document.getElementById('break-btn').classList.add('disabled')
|
||||||
|
document.getElementById('pause-btn').classList.add('disabled')
|
||||||
|
|
||||||
|
clearInterval(updateTimerInterval)
|
||||||
|
clearInterval(syncTimerInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeTask(task) {
|
||||||
|
if(planState == PLAN_STATE.CLOSED){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task.classList.remove('incomplete')
|
||||||
|
task.classList.add('complete')
|
||||||
|
syncTimers()
|
||||||
|
const playPauseBtn = task.querySelector('.play-pause-btn')
|
||||||
|
if(playPauseBtn){
|
||||||
|
playPauseBtn.remove()
|
||||||
|
}
|
||||||
|
fetch(
|
||||||
|
`/daily/task/${task.dataset.taskId}/finish`,
|
||||||
|
{
|
||||||
|
method: 'POST'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneBtnPress(id) {
|
||||||
|
if(planState == PLAN_STATE.CLOSED){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tsk = document.querySelector(`[data-task-id="${id}"]`)
|
||||||
|
if(tsk){
|
||||||
|
completeTask(tsk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function planFinishBtnPress(){
|
||||||
|
if(planState == PLAN_STATE.CLOSED){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetch(
|
||||||
|
`/daily/finish`,
|
||||||
|
{
|
||||||
|
method: 'POST'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
closedMode()
|
||||||
|
}
|
||||||
|
|
||||||
function taskBtnPress() {
|
function taskBtnPress() {
|
||||||
console.log('TASK')
|
if(planState == PLAN_STATE.CLOSED){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if(!activeTask){
|
||||||
|
setActiveTask(document.querySelector(
|
||||||
|
'.task-entry.incomplete'
|
||||||
|
))
|
||||||
|
}
|
||||||
|
playMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
function breakBtnPress() {
|
function breakBtnPress() {
|
||||||
console.log('BREAK')
|
if(planState == PLAN_STATE.CLOSED){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
breakMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
function pauseBtnPress() {
|
function pauseBtnPress() {
|
||||||
console.log('PAUSE')
|
if(planState == PLAN_STATE.CLOSED){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pauseMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimer(timerNode) {
|
||||||
|
var relativeTime = timerNode.dataset.timeGoal - timerNode.dataset.timeSpent
|
||||||
|
var negative = false
|
||||||
|
if(relativeTime < 0){
|
||||||
|
relativeTime *= -1
|
||||||
|
negative = true
|
||||||
|
}
|
||||||
|
const hours = Math.floor(relativeTime / (60*60)).toString().padStart(2, '0')
|
||||||
|
const minutes = (Math.floor(relativeTime / 60) % 60).toString().padStart(2, '0')
|
||||||
|
const seconds = (relativeTime % 60).toString().padStart(2, '0')
|
||||||
|
const negText = negative ? '-' : ''
|
||||||
|
timerNode.innerText = `${negText}${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
switch(planState){
|
||||||
|
case PLAN_STATE.TASK:
|
||||||
|
if(activeTask){
|
||||||
|
const tid = parseInt(activeTask.dataset.taskId)
|
||||||
|
if(!modifiedTasks.includes(tid)){
|
||||||
|
modifiedTasks.push(tid)
|
||||||
|
}
|
||||||
|
const timerNode = activeTask.querySelector(':scope .time')
|
||||||
|
timerNode.dataset.timeSpent = parseInt(timerNode.dataset.timeSpent) + 1
|
||||||
|
renderTimer(timerNode)
|
||||||
|
updateActiveTime(1)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PLAN_STATE.BREAK:
|
||||||
|
updateBreakTime(1)
|
||||||
|
break;
|
||||||
|
case PLAN_STATE.PAUSED:
|
||||||
|
updatePauseTime(1)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTimers(){
|
||||||
|
if(planState == PLAN_STATE.CLOSED){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modifiedTasks.forEach(
|
||||||
|
(tid) => {
|
||||||
|
const task = document.querySelector(
|
||||||
|
`[data-task-id="${tid}"]`
|
||||||
|
)
|
||||||
|
const timeSpent = parseInt(
|
||||||
|
task.querySelector(':scope .time').dataset.timeSpent
|
||||||
|
)
|
||||||
|
|
||||||
|
fetch(
|
||||||
|
`/daily/task/${tid}/time`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: `${timeSpent}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
modifiedTasks = []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if(modifiedBreak){
|
||||||
|
const breakTime = parseInt(
|
||||||
|
document.getElementById('break-timer-total').dataset.timeSpent
|
||||||
|
)
|
||||||
|
fetch(
|
||||||
|
`/daily/break/time`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: `${breakTime}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
modifiedBreak = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if(modifiedPause){
|
||||||
|
const pauseTime = parseInt(
|
||||||
|
document.getElementById('pause-timer-total').dataset.timeSpent
|
||||||
|
)
|
||||||
|
fetch(
|
||||||
|
`/daily/pause/time`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: `${pauseTime}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
modifiedPause = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(document.querySelectorAll(
|
||||||
|
'.task-entry'
|
||||||
|
)).forEach(
|
||||||
|
(el) => {
|
||||||
|
renderTimer(el.querySelector(':scope .time'))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
renderTimer(document.getElementById('task-timer-total'))
|
||||||
|
renderTimer(document.getElementById('break-timer-total'))
|
||||||
|
renderTimer(document.getElementById('pause-timer-total'))
|
||||||
|
|
||||||
|
const updateTimerInterval = setInterval(updateTimer, 1000)
|
||||||
|
const syncTimerInterval = setInterval(syncTimers, 10000)
|
||||||
|
|
||||||
|
if(document.getElementById('daily-plan').dataset.planActive == '0'){
|
||||||
|
closedMode()
|
||||||
|
}else{
|
||||||
|
pauseMode()
|
||||||
}
|
}
|
||||||
|
|
@ -7,22 +7,28 @@
|
||||||
|
|
||||||
{% block title %}Daily Plan{% endblock %}
|
{% block title %}Daily Plan{% endblock %}
|
||||||
|
|
||||||
{% macro task_entry(tsk) %}
|
{% macro task_entry(plan, tsk) %}
|
||||||
<details class="{{'complete' if tsk.completed else 'incomplete'}}">
|
<details class="task-entry {{'complete' if tsk.task and tsk.task.complete else 'incomplete'}}" data-task-id="{{tsk.task.id if tsk.task.id else ''}}">
|
||||||
<summary>
|
<summary>
|
||||||
{% if tsk.task %}
|
{% if tsk.task %}
|
||||||
|
<button class="btn green play-pause-btn" onClick="playPauseBtnPress({{tsk.task.id}})">RUN</button>
|
||||||
|
<button class="btn {{'disabled' if plan.complete else 'green'}} icon-only-btn done-btn" onClick="doneBtnPress({{tsk.task.id}})">{{icon('check-box-unchecked')|safe}}</button>
|
||||||
|
<button class="btn disabled icon-only-btn done-btn-disabled">{{icon('check-box-checked')|safe}}</button>
|
||||||
<span class="name">{{tsk.task.name}}</span>
|
<span class="name">{{tsk.task.name}}</span>
|
||||||
<span
|
<span
|
||||||
class="import"
|
class="import imp-{{tsk.task.importance}}"
|
||||||
data-importance="{{tsk.task.importance}}"
|
data-importance="{{tsk.task.importance}}"
|
||||||
|
title="Importance: {{importance_str(tsk.task.importance)}}"
|
||||||
>I {{tsk.task.importance}}</span>
|
>I {{tsk.task.importance}}</span>
|
||||||
<span
|
<span
|
||||||
class="mburn"
|
class="mburn burn-{{tsk.task.mental_burn}}"
|
||||||
data-mental-burn="{{tsk.task.mental_burn}}"
|
data-mental-burn="{{tsk.task.mental_burn}}"
|
||||||
|
title="Mental Burn: {{burn_str(tsk.task.mental_burn)}}"
|
||||||
>M {{tsk.task.mental_burn}}</span>
|
>M {{tsk.task.mental_burn}}</span>
|
||||||
<span
|
<span
|
||||||
class="sburn"
|
class="sburn burn-{{tsk.task.social_burn}}"
|
||||||
data-social-burn="{{tsk.task.social_burn}}"
|
data-social-burn="{{tsk.task.social_burn}}"
|
||||||
|
title="Social Burn: {{burn_str(tsk.task.social_burn)}}"
|
||||||
>S {{tsk.task.social_burn}}</span>
|
>S {{tsk.task.social_burn}}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="name">Unknown Task</span>
|
<span class="name">Unknown Task</span>
|
||||||
|
|
@ -44,26 +50,53 @@
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block main_content %}
|
{% block main_content %}
|
||||||
<div class="daily-plan">
|
<div id="daily-plan" class="daily-plan" data-plan-active="{{0 if plan.complete else 1}}">
|
||||||
<div class="main-pane">
|
<div class="main-pane">
|
||||||
<h1>Daily Plan</h1>
|
<h1>Daily Plan</h1>
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
{{task_entry(task)}}
|
{{task_entry(plan, task)}}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="timer-pane">
|
<div class="timer-pane">
|
||||||
<h1>Mode: PAUSE</h1>
|
{% if plan.complete %}
|
||||||
|
{% else %}
|
||||||
|
<h1>Timers</h1>
|
||||||
<div class="timer-controls">
|
<div class="timer-controls">
|
||||||
<button type="button" class="btn green" onclick="taskBtnPress()" id="task-btn">TASK</button>
|
<button type="button" onclick="taskBtnPress()" id="task-btn">
|
||||||
<button type="button" class="btn yellow" onclick="breakBtnPress()" id="break-btn">BREAK</button>
|
<span>TASK</span>
|
||||||
<button type="button" class="btn red" onclick="pauseBtnPress()" id="pause-btn">PAUSE</button>
|
<span
|
||||||
|
id="task-timer-total"
|
||||||
|
data-time-goal="{{plan.total_time_goal}}"
|
||||||
|
data-time-spent="{{plan.total_time_on_task}}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="breakBtnPress()" id="break-btn">
|
||||||
|
<span>BREAK</span>
|
||||||
|
<span
|
||||||
|
id="break-timer-total"
|
||||||
|
data-time-goal="{{plan.break_time_goal}}"
|
||||||
|
data-time-spent="{{plan.break_time_spent}}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="pauseBtnPress()" id="pause-btn">
|
||||||
|
<span>PAUSE</span>
|
||||||
|
<span
|
||||||
|
id="pause-timer-total"
|
||||||
|
data-time-goal="0"
|
||||||
|
data-time-spent="{{plan.pause_time_spent}}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2>Current Time</h2>
|
<button
|
||||||
<p class="current-time"></p>
|
type="button"
|
||||||
<h2>Time Left</h2>
|
class="btn"
|
||||||
{{ rtimer(plan.total_time_remaining)|safe }}
|
style="margin-top: 1.5rem;"
|
||||||
<h2>Break Left</h2>
|
onclick="planFinishBtnPress()"
|
||||||
{{ rtimer(plan.break_time_remaining)|safe }}
|
id="finish-btn"
|
||||||
|
>
|
||||||
|
Complete Plan
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<details>
|
<details>
|
||||||
<summary>Plan Details</summary>
|
<summary>Plan Details</summary>
|
||||||
<dl>
|
<dl>
|
||||||
|
|
@ -74,6 +107,7 @@
|
||||||
<dt>Requested Total Burn</dt><dd>{{plan.burn_intent}}</dd>
|
<dt>Requested Total Burn</dt><dd>{{plan.burn_intent}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</details>
|
</details>
|
||||||
|
<div style="flex: 1 1 auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<div id="sidebar">
|
<div id="sidebar">
|
||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
<a id="sidebar-top" href="/">Taskflower 🌺</a>
|
<a id="sidebar-top" href="/">Taskflower 🌺</a>
|
||||||
<a href={{ url_for("web.task.all") }}>My Tasks</a>
|
<a href={{ url_for("web.daily.get") }}>My Day</a>
|
||||||
<a href={{ url_for("web.task.new") }}>Create Task</a>
|
<a href={{ url_for("web.task.new") }}>Create Task</a>
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
{{ namespace_list(fetch_namespaces(current_user)) }}
|
{{ namespace_list(fetch_namespaces(current_user)) }}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ from abc import ABC, abstractmethod
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from typing import Callable, final, override
|
from typing import Callable, final, override
|
||||||
|
|
||||||
|
from taskflower.types.option import Option
|
||||||
|
|
||||||
|
class OptionIsNothingError(Exception):
|
||||||
|
''' Returned when calling ``Either.from_option()`` on a ``Nothing()``, if no
|
||||||
|
custom error is specified.
|
||||||
|
'''
|
||||||
|
pass
|
||||||
|
|
||||||
class Either[L, R](ABC):
|
class Either[L, R](ABC):
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
@ -103,6 +111,35 @@ class Either[L, R](ABC):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def wrap[**P, T](fn: Callable[P, T]) -> Callable[P, Either[Exception, T]]:
|
||||||
|
def _inner(
|
||||||
|
*args: P.args,
|
||||||
|
**kwargs: P.kwargs
|
||||||
|
) -> Either[Exception, T]:
|
||||||
|
try:
|
||||||
|
return Right(fn(*args, **kwargs))
|
||||||
|
except Exception as e:
|
||||||
|
return Left(e)
|
||||||
|
return _inner
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_option[T](
|
||||||
|
op: Option[T],
|
||||||
|
custom_err: Exception|None = None
|
||||||
|
) -> Either[Exception, T]:
|
||||||
|
''' Turns an ``Option[T]`` into an ``Either[Exception, T]``. If ``op``
|
||||||
|
is ``Nothing()``, the result will be ``custom_err`` (if it exists)
|
||||||
|
or ``OptionIsNothingError()`` if it does not.
|
||||||
|
'''
|
||||||
|
return op.and_then(
|
||||||
|
lambda val: Right[Exception, T](val),
|
||||||
|
lambda: Left[Exception, T](
|
||||||
|
custom_err if custom_err
|
||||||
|
else OptionIsNothingError()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@final
|
@final
|
||||||
class Left[L, R](Either[L, R]):
|
class Left[L, R](Either[L, R]):
|
||||||
def __init__(self, lf: L):
|
def __init__(self, lf: L):
|
||||||
|
|
|
||||||
11
src/taskflower/util/list.py
Normal file
11
src/taskflower/util/list.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from typing import Callable
|
||||||
|
from taskflower.types.option import Nothing, Option, Some
|
||||||
|
|
||||||
|
def list_search[T](
|
||||||
|
ls: list[T]|tuple[T],
|
||||||
|
search_fn: Callable[[T], bool]
|
||||||
|
) -> Option[T]:
|
||||||
|
for l in ls:
|
||||||
|
if search_fn(l):
|
||||||
|
return Some(l)
|
||||||
|
return Nothing()
|
||||||
|
|
@ -2,6 +2,7 @@ from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
from typing import Self
|
from typing import Self
|
||||||
from flask import Blueprint, redirect, render_template, request, url_for
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
|
|
@ -10,16 +11,19 @@ from wtforms import DecimalField, Form, SelectField
|
||||||
from wtforms.validators import InputRequired, NumberRange
|
from wtforms.validators import InputRequired, NumberRange
|
||||||
|
|
||||||
from taskflower.auth.permission import NPT
|
from taskflower.auth.permission import NPT
|
||||||
|
from taskflower.auth.permission.checks import check_user_perms_on_task
|
||||||
from taskflower.auth.permission.lookups import tasks_where_user_can
|
from taskflower.auth.permission.lookups import tasks_where_user_can
|
||||||
from taskflower.db import db
|
from taskflower.db import db, do_commit
|
||||||
from taskflower.db.model.daily import DailyPlan, DailyPlanTask, PlanFlags
|
from taskflower.db.model.daily import DailyPlan, DailyPlanTask, PlanFlags
|
||||||
from taskflower.db.model.task import Task
|
from taskflower.db.model.task import Task
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.sanitize.task import TaskForUser
|
from taskflower.sanitize.task import TaskForUser
|
||||||
from taskflower.types import CheckType, assert_usr
|
from taskflower.types import CheckType, assert_usr
|
||||||
from taskflower.types.either import Either, Right, reduce_either
|
from taskflower.types.either import Either, OptionIsNothingError, Right, reduce_either
|
||||||
|
from taskflower.types.option import Nothing, Option, Some
|
||||||
|
from taskflower.util.list import list_search
|
||||||
from taskflower.util.time import now
|
from taskflower.util.time import now
|
||||||
from taskflower.web.errors import response_from_exception
|
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception
|
||||||
from taskflower.web.daily.planner import generate_daily_plan
|
from taskflower.web.daily.planner import generate_daily_plan
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -31,6 +35,25 @@ web_daily = Blueprint(
|
||||||
url_prefix='/daily'
|
url_prefix='/daily'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_active_daily_plan(user: User) -> Option[DailyPlan]:
|
||||||
|
most_recent_plan = db.session.query(
|
||||||
|
DailyPlan
|
||||||
|
).filter(
|
||||||
|
DailyPlan.user_id == user.id
|
||||||
|
).order_by(
|
||||||
|
DailyPlan.start_at_raw.desc()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return (
|
||||||
|
Nothing()
|
||||||
|
if (
|
||||||
|
not most_recent_plan
|
||||||
|
) or (
|
||||||
|
(now() - most_recent_plan.start_at) > timedelta(days=1)
|
||||||
|
)
|
||||||
|
else Some(most_recent_plan)
|
||||||
|
)
|
||||||
|
|
||||||
class DailyPlanForm(Form):
|
class DailyPlanForm(Form):
|
||||||
scope: SelectField = SelectField(
|
scope: SelectField = SelectField(
|
||||||
'Preferred Scope',
|
'Preferred Scope',
|
||||||
|
|
@ -200,34 +223,29 @@ class PlanTaskForUser:
|
||||||
def get():
|
def get():
|
||||||
cur_usr = assert_usr(current_user)
|
cur_usr = assert_usr(current_user)
|
||||||
|
|
||||||
most_recent_plan = db.session.query(
|
return Either.from_option(
|
||||||
DailyPlan
|
get_active_daily_plan(cur_usr)
|
||||||
).filter(
|
).flat_map(
|
||||||
DailyPlan.user_id == cur_usr.id
|
lambda most_recent_plan: reduce_either([
|
||||||
).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)
|
PlanTaskForUser.from_plan(pt, cur_usr)
|
||||||
for pt in most_recent_plan.plan_tasks
|
for pt in most_recent_plan.plan_tasks
|
||||||
]).and_then(
|
]).map(
|
||||||
lambda tsks: render_template(
|
lambda tsks: (most_recent_plan, tsks)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda data: render_template(
|
||||||
'daily/active_daily.html',
|
'daily/active_daily.html',
|
||||||
tasks=sorted(
|
tasks=sorted(
|
||||||
tsks,
|
data[1],
|
||||||
key=lambda tsk: tsk.order
|
key=lambda tsk: tsk.order
|
||||||
),
|
),
|
||||||
plan=most_recent_plan
|
plan=data[0]
|
||||||
),
|
),
|
||||||
lambda exc: response_from_exception(exc)
|
lambda exc: (
|
||||||
|
redirect(url_for('web.daily.new'))
|
||||||
|
if isinstance(exc, OptionIsNothingError) else
|
||||||
|
response_from_exception(exc)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@web_daily.route('/new', methods=['GET', 'POST'])
|
@web_daily.route('/new', methods=['GET', 'POST'])
|
||||||
|
|
@ -277,7 +295,7 @@ def new():
|
||||||
cur_usr,
|
cur_usr,
|
||||||
data[0],
|
data[0],
|
||||||
flags,
|
flags,
|
||||||
int(_burn_per_hour(flags)*(data[1].effective / 60.0)),
|
int(_burn_per_hour(flags)*(data[1].effective / (60.0*60.0))),
|
||||||
int(data[1].length),
|
int(data[1].length),
|
||||||
int(data[1].breaks),
|
int(data[1].breaks),
|
||||||
now()
|
now()
|
||||||
|
|
@ -291,3 +309,166 @@ def new():
|
||||||
'daily/new_daily.html',
|
'daily/new_daily.html',
|
||||||
form=form_data
|
form=form_data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@web_daily.route('/task/<int:id>/time', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def set_task_time(id: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
|
||||||
|
def _inner(tsk: DailyPlanTask, time: int):
|
||||||
|
tsk.time_spent = time
|
||||||
|
return do_commit(db)
|
||||||
|
|
||||||
|
return Either.from_option(
|
||||||
|
get_active_daily_plan(cur_usr)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.do_assert(
|
||||||
|
not plan.complete
|
||||||
|
).map(lambda _: plan)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.from_option(
|
||||||
|
list_search(
|
||||||
|
plan.plan_tasks,
|
||||||
|
lambda tsk: tsk.task_id == id
|
||||||
|
)
|
||||||
|
).lmap(
|
||||||
|
lambda _: ResponseErrorNotFound(
|
||||||
|
'POST',
|
||||||
|
'set_task_time',
|
||||||
|
f'Task id {id} not found in current daily plan!',
|
||||||
|
'That task doesn\'t seem to be in today\'s plan.'
|
||||||
|
)
|
||||||
|
).flat_map(
|
||||||
|
lambda tsk: Either.wrap(int)(
|
||||||
|
request.data
|
||||||
|
).flat_map(
|
||||||
|
lambda time: _inner(tsk, time)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda _: ('OK', HTTPStatus.OK),
|
||||||
|
lambda exc: response_from_exception(exc, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_daily.route('/break/time', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def set_break_time():
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
|
||||||
|
def _inner(plan: DailyPlan, time: int):
|
||||||
|
plan.break_time_spent = time
|
||||||
|
return do_commit(db)
|
||||||
|
|
||||||
|
return Either.from_option(
|
||||||
|
get_active_daily_plan(cur_usr)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.do_assert(
|
||||||
|
not plan.complete
|
||||||
|
).map(lambda _: plan)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.wrap(int)(
|
||||||
|
request.data
|
||||||
|
).flat_map(
|
||||||
|
lambda time: _inner(plan, time)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda _: ('OK', HTTPStatus.OK),
|
||||||
|
lambda exc: response_from_exception(exc, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_daily.route('/pause/time', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def set_pause_time():
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
|
||||||
|
def _inner(plan: DailyPlan, time: int):
|
||||||
|
plan.pause_time_spent = time
|
||||||
|
return do_commit(db)
|
||||||
|
|
||||||
|
return Either.from_option(
|
||||||
|
get_active_daily_plan(cur_usr)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.do_assert(
|
||||||
|
not plan.complete
|
||||||
|
).map(lambda _: plan)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.wrap(int)(
|
||||||
|
request.data
|
||||||
|
).flat_map(
|
||||||
|
lambda time: _inner(plan, time)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda _: ('OK', HTTPStatus.OK),
|
||||||
|
lambda exc: response_from_exception(exc, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_daily.route('/task/<int:id>/finish', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def finish_task(id: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
|
||||||
|
def _complete_task(ptask: DailyPlanTask):
|
||||||
|
ptask.completed = now()
|
||||||
|
if ptask.task and check_user_perms_on_task(
|
||||||
|
cur_usr,
|
||||||
|
ptask.task,
|
||||||
|
NPT.COMPLETE_ALL_TASKS
|
||||||
|
):
|
||||||
|
# It's possible that the user lost task-complete permission or the
|
||||||
|
# task was deleted between the plan being generated and now. If that
|
||||||
|
# happens, then we complete the task (but we complete the PlanTask
|
||||||
|
# regardless)
|
||||||
|
ptask.task.complete = True
|
||||||
|
ptask.task.completed = now()
|
||||||
|
return do_commit(db)
|
||||||
|
|
||||||
|
return Either.from_option(
|
||||||
|
get_active_daily_plan(cur_usr)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.do_assert(
|
||||||
|
not plan.complete
|
||||||
|
).map(lambda _: plan)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.from_option(
|
||||||
|
list_search(
|
||||||
|
plan.plan_tasks,
|
||||||
|
lambda tsk: tsk.task_id == id
|
||||||
|
)
|
||||||
|
).lmap(
|
||||||
|
lambda _: ResponseErrorNotFound(
|
||||||
|
'POST',
|
||||||
|
'set_task_time',
|
||||||
|
f'Task id {id} not found in current daily plan!',
|
||||||
|
'That task doesn\'t seem to be in today\'s plan.'
|
||||||
|
)
|
||||||
|
).flat_map(
|
||||||
|
lambda ptask: _complete_task(ptask)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda _: ('OK', HTTPStatus.OK),
|
||||||
|
lambda exc: response_from_exception(exc, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_daily.route('/finish', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def finish():
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
|
||||||
|
def _complete(plan: DailyPlan):
|
||||||
|
plan.complete = True
|
||||||
|
plan.completed = now()
|
||||||
|
|
||||||
|
return do_commit(db)
|
||||||
|
|
||||||
|
return Either.from_option(
|
||||||
|
get_active_daily_plan(cur_usr)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: Either.do_assert(
|
||||||
|
not plan.complete
|
||||||
|
).map(lambda _: plan)
|
||||||
|
).flat_map(
|
||||||
|
lambda plan: _complete(plan)
|
||||||
|
).and_then(
|
||||||
|
lambda _: ('OK', HTTPStatus.OK),
|
||||||
|
lambda exc: response_from_exception(exc, False)
|
||||||
|
)
|
||||||
|
|
@ -86,9 +86,9 @@ def _calc_motivation_for(
|
||||||
else:
|
else:
|
||||||
burn_factor += 10
|
burn_factor += 10
|
||||||
|
|
||||||
if PlanFlags.HIGH_BURN:
|
if PlanFlags.HIGH_BURN in flags:
|
||||||
motivation += -1*burn_factor
|
motivation += -1*burn_factor
|
||||||
elif PlanFlags.LOW_BURN:
|
elif PlanFlags.LOW_BURN in flags:
|
||||||
motivation += 2*burn_factor
|
motivation += 2*burn_factor
|
||||||
else:
|
else:
|
||||||
motivation += burn_factor
|
motivation += burn_factor
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,10 @@ def _render_error_page(
|
||||||
err_details=err_details
|
err_details=err_details
|
||||||
)
|
)
|
||||||
|
|
||||||
def status_response(code: HTTPStatus, user_description: str|None = None) -> FlaskViewReturnType:
|
def status_response(code: HTTPStatus, user_description: str|None = None, user_friendly: bool = True) -> FlaskViewReturnType:
|
||||||
|
if not user_friendly:
|
||||||
|
return (f'{user_description if user_description else code.name}', code)
|
||||||
|
|
||||||
match code:
|
match code:
|
||||||
case HTTPStatus.BAD_REQUEST:
|
case HTTPStatus.BAD_REQUEST:
|
||||||
return _render_error_page(
|
return _render_error_page(
|
||||||
|
|
@ -122,7 +125,7 @@ def status_response(code: HTTPStatus, user_description: str|None = None) -> Flas
|
||||||
user_description
|
user_description
|
||||||
)
|
)
|
||||||
|
|
||||||
def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType:
|
def response_from_exception(exc: Exception|None = None, user_friendly: bool = True) -> FlaskViewReturnType:
|
||||||
if isinstance(exc, AuthorizationError):
|
if isinstance(exc, AuthorizationError):
|
||||||
report_authorization_error(exc)
|
report_authorization_error(exc)
|
||||||
return status_response(
|
return status_response(
|
||||||
|
|
@ -130,7 +133,8 @@ def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType:
|
||||||
not_found_or_not_authorized(
|
not_found_or_not_authorized(
|
||||||
type(exc.resource).__name__,
|
type(exc.resource).__name__,
|
||||||
str(exc.resource.id)
|
str(exc.resource.id)
|
||||||
)
|
),
|
||||||
|
user_friendly
|
||||||
)
|
)
|
||||||
|
|
||||||
if exc:
|
if exc:
|
||||||
|
|
@ -139,7 +143,11 @@ def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType:
|
||||||
if isinstance(exc, ResponseErrorType):
|
if isinstance(exc, ResponseErrorType):
|
||||||
return status_response(
|
return status_response(
|
||||||
exc.status,
|
exc.status,
|
||||||
exc.user_reason
|
exc.user_reason,
|
||||||
|
user_friendly
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return status_response(HTTPStatus.INTERNAL_SERVER_ERROR)
|
return status_response(
|
||||||
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
user_friendly=user_friendly
|
||||||
|
)
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
|
from http import HTTPStatus
|
||||||
from flask import Blueprint, redirect, render_template, request, url_for
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
|
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
|
||||||
|
|
||||||
from taskflower.auth.messages import not_found_or_not_authorized
|
from taskflower.auth.messages import not_found_or_not_authorized
|
||||||
from taskflower.auth.permission import NamespacePermissionType
|
from taskflower.auth.permission import NPT, NamespacePermissionType
|
||||||
from taskflower.auth.permission.checks import assert_user_perms_on_task
|
from taskflower.auth.permission.checks import assert_user_perms_on_task
|
||||||
from taskflower.auth.permission.lookups import tasks_where_user_can
|
from taskflower.auth.permission.lookups import tasks_where_user_can
|
||||||
from taskflower.auth.violations import check_for_auth_err_and_report
|
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 import commit_update, db, db_fetch_by_id, do_commit, do_delete
|
||||||
from taskflower.db.helpers import add_to_db
|
from taskflower.db.helpers import add_to_db
|
||||||
from taskflower.db.model.namespace import Namespace
|
from taskflower.db.model.namespace import Namespace
|
||||||
from taskflower.db.model.task import Task, burn_str, importance_str
|
from taskflower.db.model.task import Task, burn_str, importance_str
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.form.task import task_edit_form_for_task, task_form_for_user
|
from taskflower.form.task import task_edit_form_for_task, task_form_for_user
|
||||||
from taskflower.sanitize.task import TaskForUser
|
from taskflower.sanitize.task import TaskForUser
|
||||||
|
from taskflower.types import assert_usr
|
||||||
from taskflower.types.either import Either, Left, Right, reduce_either
|
from taskflower.types.either import Either, Left, Right, reduce_either
|
||||||
from taskflower.types.option import Option
|
from taskflower.types.option import Option
|
||||||
from taskflower.util.time import now
|
from taskflower.util.time import now
|
||||||
|
|
@ -351,3 +353,35 @@ def delete(id: int):
|
||||||
return response_from_exception(
|
return response_from_exception(
|
||||||
lookup_result.assert_left().val
|
lookup_result.assert_left().val
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@web_tasks.route('/<int:id>/time', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def set_spend_time(id: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
|
||||||
|
def _inner(tsk: Task, new_time: int):
|
||||||
|
tsk.time_spent = new_time
|
||||||
|
return do_commit(db)
|
||||||
|
|
||||||
|
return db_fetch_by_id(
|
||||||
|
Task,
|
||||||
|
id,
|
||||||
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda tsk: assert_user_perms_on_task(
|
||||||
|
cur_usr,
|
||||||
|
tsk,
|
||||||
|
NPT.COMPLETE_ALL_TASKS
|
||||||
|
).flat_map(
|
||||||
|
lambda _: Either.wrap(int)(
|
||||||
|
request.data.decode('utf-8')
|
||||||
|
)
|
||||||
|
).flat_map(
|
||||||
|
lambda new_spent_time: _inner(tsk, new_spent_time)
|
||||||
|
)
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).and_then(
|
||||||
|
lambda _: ('OK', HTTPStatus.OK),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue