Complete basic daily plan system

This commit is contained in:
digimint 2025-12-13 03:52:05 -06:00
parent e0063fffdb
commit a7cff4af57
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
14 changed files with 1038 additions and 75 deletions

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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('/')

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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()
}

View file

@ -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 %}

View file

@ -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)) }}

View file

@ -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]):

View 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()

View file

@ -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)
)

View file

@ -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

View file

@ -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
)

View file

@ -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)
)