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.db import db
|
||||
from taskflower.api import APIBase
|
||||
from taskflower.db.model.task import burn_str, importance_str
|
||||
from taskflower.db.model.user import User
|
||||
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
|
||||
from taskflower.tools.icons import get_icon, svg_bp
|
||||
|
|
@ -152,7 +153,9 @@ def template_utility_fns():
|
|||
icon=icon,
|
||||
cur_page_with_variables=cur_page_with_variables,
|
||||
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('/')
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# pyright: reportImportCycles=false
|
||||
from datetime import datetime
|
||||
from enum import IntFlag, auto
|
||||
from functools import reduce
|
||||
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 taskflower.db import db
|
||||
|
|
@ -50,17 +51,21 @@ class DailyPlan(db.Model):
|
|||
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)
|
||||
break_time_goal : Mapped[int] = mapped_column(Integer)
|
||||
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_goal: Mapped[int] = mapped_column(Integer)
|
||||
total_time_spent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_time_goal : 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
|
||||
def overtime(self) -> bool:
|
||||
|
|
@ -98,10 +103,30 @@ class DailyPlan(db.Model):
|
|||
def start_at(self, val: TAD|TND):
|
||||
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
|
||||
def break_time_remaining(self) -> int:
|
||||
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
|
||||
def total_time_remaining(self) -> int:
|
||||
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 {
|
||||
--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;
|
||||
width: 100%;
|
||||
|
||||
&[data-plan-active="0"] button.play-pause-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-plan-active="0"] .complete {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.main-pane {
|
||||
flex: 3 0 auto;
|
||||
flex: 3 1 auto;
|
||||
max-width: 80%;
|
||||
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;
|
||||
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;
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
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 {
|
||||
display: flex;
|
||||
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 {
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
border-radius: 0.2rem;
|
||||
&.name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
&:not(.name) {
|
||||
cursor: default;
|
||||
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;
|
||||
margin: 0 0.2rem;
|
||||
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 {
|
||||
padding: 0.5rem;
|
||||
a {
|
||||
color: var(--fg);
|
||||
text-decoration: dotted underline;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timer-pane {
|
||||
flex: 1 0 auto;
|
||||
padding: 1rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
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;
|
||||
padding: 1rem 0;
|
||||
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() {
|
||||
console.log('TASK')
|
||||
if(planState == PLAN_STATE.CLOSED){
|
||||
return
|
||||
}
|
||||
if(!activeTask){
|
||||
setActiveTask(document.querySelector(
|
||||
'.task-entry.incomplete'
|
||||
))
|
||||
}
|
||||
playMode()
|
||||
}
|
||||
|
||||
function breakBtnPress() {
|
||||
console.log('BREAK')
|
||||
if(planState == PLAN_STATE.CLOSED){
|
||||
return
|
||||
}
|
||||
breakMode()
|
||||
}
|
||||
|
||||
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 %}
|
||||
|
||||
{% macro task_entry(tsk) %}
|
||||
<details class="{{'complete' if tsk.completed else 'incomplete'}}">
|
||||
{% macro task_entry(plan, tsk) %}
|
||||
<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>
|
||||
{% 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="import"
|
||||
class="import imp-{{tsk.task.importance}}"
|
||||
data-importance="{{tsk.task.importance}}"
|
||||
title="Importance: {{importance_str(tsk.task.importance)}}"
|
||||
>I {{tsk.task.importance}}</span>
|
||||
<span
|
||||
class="mburn"
|
||||
class="mburn 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>
|
||||
<span
|
||||
class="sburn"
|
||||
class="sburn 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>
|
||||
{% else %}
|
||||
<span class="name">Unknown Task</span>
|
||||
|
|
@ -44,26 +50,53 @@
|
|||
{% endmacro %}
|
||||
|
||||
{% 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">
|
||||
<h1>Daily Plan</h1>
|
||||
{% for task in tasks %}
|
||||
{{task_entry(task)}}
|
||||
{{task_entry(plan, task)}}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="timer-pane">
|
||||
<h1>Mode: PAUSE</h1>
|
||||
{% if plan.complete %}
|
||||
{% else %}
|
||||
<h1>Timers</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>
|
||||
<button type="button" onclick="taskBtnPress()" id="task-btn">
|
||||
<span>TASK</span>
|
||||
<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>
|
||||
<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 }}
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
style="margin-top: 1.5rem;"
|
||||
onclick="planFinishBtnPress()"
|
||||
id="finish-btn"
|
||||
>
|
||||
Complete Plan
|
||||
</button>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary>Plan Details</summary>
|
||||
<dl>
|
||||
|
|
@ -74,6 +107,7 @@
|
|||
<dt>Requested Total Burn</dt><dd>{{plan.burn_intent}}</dd>
|
||||
</dl>
|
||||
</details>
|
||||
<div style="flex: 1 1 auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<div id="sidebar">
|
||||
{% block sidebar %}
|
||||
<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>
|
||||
{% if current_user.is_authenticated %}
|
||||
{{ namespace_list(fetch_namespaces(current_user)) }}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ from abc import ABC, abstractmethod
|
|||
from functools import reduce
|
||||
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):
|
||||
@property
|
||||
@abstractmethod
|
||||
|
|
@ -102,6 +110,35 @@ class Either[L, R](ABC):
|
|||
) if desc else ''
|
||||
)
|
||||
)
|
||||
|
||||
@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
|
||||
class Left[L, R](Either[L, R]):
|
||||
|
|
|
|||
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 decimal import Decimal
|
||||
from functools import reduce
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Self
|
||||
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 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.db import db
|
||||
from taskflower.db import db, do_commit
|
||||
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.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.web.errors import response_from_exception
|
||||
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception
|
||||
from taskflower.web.daily.planner import generate_daily_plan
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -31,6 +35,25 @@ web_daily = Blueprint(
|
|||
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):
|
||||
scope: SelectField = SelectField(
|
||||
'Preferred Scope',
|
||||
|
|
@ -200,34 +223,29 @@ class PlanTaskForUser:
|
|||
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(
|
||||
return Either.from_option(
|
||||
get_active_daily_plan(cur_usr)
|
||||
).flat_map(
|
||||
lambda most_recent_plan: reduce_either([
|
||||
PlanTaskForUser.from_plan(pt, cur_usr)
|
||||
for pt in most_recent_plan.plan_tasks
|
||||
]).map(
|
||||
lambda tsks: (most_recent_plan, tsks)
|
||||
)
|
||||
).and_then(
|
||||
lambda data: render_template(
|
||||
'daily/active_daily.html',
|
||||
tasks=sorted(
|
||||
tsks,
|
||||
data[1],
|
||||
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'])
|
||||
|
|
@ -277,7 +295,7 @@ def new():
|
|||
cur_usr,
|
||||
data[0],
|
||||
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].breaks),
|
||||
now()
|
||||
|
|
@ -290,4 +308,167 @@ def new():
|
|||
return render_template(
|
||||
'daily/new_daily.html',
|
||||
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:
|
||||
burn_factor += 10
|
||||
|
||||
if PlanFlags.HIGH_BURN:
|
||||
if PlanFlags.HIGH_BURN in flags:
|
||||
motivation += -1*burn_factor
|
||||
elif PlanFlags.LOW_BURN:
|
||||
elif PlanFlags.LOW_BURN in flags:
|
||||
motivation += 2*burn_factor
|
||||
else:
|
||||
motivation += burn_factor
|
||||
|
|
|
|||
|
|
@ -92,7 +92,10 @@ def _render_error_page(
|
|||
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:
|
||||
case HTTPStatus.BAD_REQUEST:
|
||||
return _render_error_page(
|
||||
|
|
@ -122,7 +125,7 @@ def status_response(code: HTTPStatus, user_description: str|None = None) -> Flas
|
|||
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):
|
||||
report_authorization_error(exc)
|
||||
return status_response(
|
||||
|
|
@ -130,7 +133,8 @@ def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType:
|
|||
not_found_or_not_authorized(
|
||||
type(exc.resource).__name__,
|
||||
str(exc.resource.id)
|
||||
)
|
||||
),
|
||||
user_friendly
|
||||
)
|
||||
|
||||
if exc:
|
||||
|
|
@ -139,7 +143,11 @@ def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType:
|
|||
if isinstance(exc, ResponseErrorType):
|
||||
return status_response(
|
||||
exc.status,
|
||||
exc.user_reason
|
||||
exc.user_reason,
|
||||
user_friendly
|
||||
)
|
||||
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_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
|
||||
|
||||
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.lookups import tasks_where_user_can
|
||||
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.model.namespace import Namespace
|
||||
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
|
||||
from taskflower.types import assert_usr
|
||||
from taskflower.types.either import Either, Left, Right, reduce_either
|
||||
from taskflower.types.option import Option
|
||||
from taskflower.util.time import now
|
||||
|
|
@ -350,4 +352,36 @@ def delete(id: int):
|
|||
else:
|
||||
return response_from_exception(
|
||||
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