Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h

## Unreleased

### Added

* `plann interactive check-pinned` goes through the past week's events pinning tasks to the calendar (as made by the panic planning in `interactive manage-tasks`) and asks if each task was done. A done task is completed (with its subtasks), and its event is marked as confirmed; its other pins may be cancelled. If not done, the event is cancelled. It is also run first in `interactive manage-tasks`. https://github.com/pycalendar/plann/pull/89

### Fixed

* `interactive manage-tasks`: a parent task postponed together with its child could be postponed once more in its own turn, and with caldav 3.6 this crashed with a "412 Precondition Failed" error. It is now skipped, with a note. A task changed on the server in the meantime is reloaded before it is postponed.
Expand Down
8 changes: 5 additions & 3 deletions TASK_MANAGEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,15 @@ There is another interactive edit function, `plann select --summary 'clean' edit

`plann interactive manage-tasks` will do the following:

* `plann interactive check-pinned` - asks if the tasks pinned to the calendar through events the last week were done
* `plann interactive split-huge-tasks`
* `plan interactive split-high-pri-tasks`
* `plann interactive split-high-pri-tasks`
* `plann interactive set-task-attribs`
* `plann agenda`
* `plann interactive dismiss-panic --hours-per-day=24`
* `plann interactive dismiss-panic --hours-per-day=14`
* `plann interactive check-due`
* `plann interactive dismiss-panic --hours-per-day=8`
* pins tasks to the calendar for the next five days, through the panic planning
* `plann agenda`

### Completing tasks

Expand Down
34 changes: 23 additions & 11 deletions plann/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
_cats,
_check_due,
_check_for_panic,
_check_pinned,
_dismiss_panic,
_edit,
_select,
Expand Down Expand Up @@ -666,8 +667,8 @@ def interactive(ctx):
experimenting with his own task list and testing out daily
procedures, hence it's also quite optimized towards whatever
work-flows that seems to work out for the maintainer of the
plann. Things are changed rapidly without warnings and the
interactive stuff is not covered by any test code whatsoever.
plann. Things are changed rapidly without warnings and most of
the interactive stuff is not covered by test code.
"""


Expand All @@ -676,18 +677,17 @@ def interactive(ctx):
def manage_tasks(ctx):
"""
This goes through the daily task management procedures:
* set-task-attribs
* agenda
* check-due
* check-pinned
* split-huge-tasks
* split-high-pri-tasks
* dismiss-panic
* interactive-fix-panic
* set-task-attribs
* dismiss-panic, with 24 and then 14 hours per day
* check-due
* pin tasks to the calendar (check-for-panic, interactive fix of the timeline)
* agenda
"""
## TODO: pinned tasks for the previous week.
## * Go through the events ... for each of them, ask if it was done or not
## * If the event was done, mark the task as completed
## * If the event wasn't done, mark the event as cancelled
click.echo("Checking if the tasks pinned to your calendar the last week were done")
_check_pinned(ctx)
click.echo("Checking if there are any huge tasks that may need to be split")
_split_huge_tasks(ctx)
click.echo("Checking if there are any high-priority tasks ... it's adviceable to split them up into subtasks with a shorter due-date")
Expand Down Expand Up @@ -715,6 +715,18 @@ def check_due(ctx, limit, lookahead):
"""
return _check_due(ctx, limit, lookahead)

@interactive.command()
@click.option('--lookback', help='how far back to look for events pinning tasks to the calendar', default='7d')
@click.pass_context
def check_pinned(ctx, lookback):
"""
Go through past events pinning tasks to the calendar, and ask if the tasks were done

Done tasks are completed, and the event is confirmed. If a task
was not done, the event is cancelled.
"""
return _check_pinned(ctx, lookback)

@interactive.command()
@click.option('--hours-per-day', help='how many hours per day you expect to be able to dedicate to those tasks/events', default=4)
@click.option('--lookahead', help='timespan to investigate', default='60d')
Expand Down
102 changes: 99 additions & 3 deletions plann/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

## TODO: can we remove the click-dependency?
import click
from caldav.lib.error import ETagMismatchError, NotFoundError, PutError, ScheduleTagMismatchError
from icalendar_searcher import Searcher

from plann.interactive import (
Expand Down Expand Up @@ -192,11 +193,11 @@ def __select(ctx, extend_objects=False, all=None, uid=[], abort_on_missing_uid=N
for obj in ctx.obj['objs']:
if isinstance(obj, caldav.Event):
if obj.icalendar_component.get('STATUS', '') != 'CANCELLED':
parents = _relships_by_type(obj, 'PARENT').get('PARENT',[])
if any(x for x in parents if isinstance(x, caldav.Todo) and x.icalendar_component.get('STATUS', 'NEEDS-ACTION') == 'NEEDS-ACTION') == pinned_tasks:
open_parents = _pinned_task_parents(obj)
if bool(open_parents) == pinned_tasks:
if kwargs_.get('todo'):
## TODO: special handling for recurring tasks
ret_objs.extend([x for x in parents if isinstance(x, caldav.Todo) and x.icalendar_component.get('STATUS', 'NEEDS-ACTION') == 'NEEDS-ACTION'])
ret_objs.extend(open_parents)
else:
ret_objs.append(obj)
if isinstance(obj, caldav.Todo) and not pinned_tasks:
Expand Down Expand Up @@ -477,6 +478,7 @@ def _add_event(ctx, timespec, **kwargs):
(dtstart, dtend) = parse_timespec(timespec, for_storage=True)
event = cal.save_event(dtstart=dtstart, dtend=dtend, **ctx.obj['set_args'], no_overwrite=True)
click.echo(f"uid={event.id}")
return event

def _agenda(ctx):
start = datetime.datetime.now()
Expand All @@ -501,6 +503,100 @@ def _check_due(ctx, limit=16, lookahead='24h'):
continue
_interactive_edit(obj)

def _pinned_task_parents(event):
"""the open tasks an event pins to the calendar"""
return [x for x in _relships_by_type(event, 'PARENT').get('PARENT', [])
if isinstance(x, caldav.Todo) and x.icalendar_component.get('STATUS', 'NEEDS-ACTION') == 'NEEDS-ACTION']

## errors that should not stop the questions about the remaining events
_save_errors = (ETagMismatchError, ScheduleTagMismatchError, PutError, NotFoundError)

def _set_event_status(event, status):
event.icalendar_component['STATUS'] = status
event.save()

def _cancel_other_pins(task, event):
"""offer to cancel the other events pinning a completed task to the
calendar - unless they also pin some task that is still open"""
if task.icalendar_component.get('RRULE'):
## the next recurrence is still to be done
return
pins = [x for x in _relships_by_type(task, 'CHILD').get('CHILD', [])
if isinstance(x, caldav.Event)
and x.icalendar_component.get('STATUS', '') not in ('CANCELLED', 'CONFIRMED')
and x.icalendar_component['UID'] != event.icalendar_component['UID']
and not _pinned_task_parents(x)]
if pins and click.confirm(f"Cancel {len(pins)} other pinned event(s) for '{_summary(task)}'?"):
for pin in pins:
try:
_set_event_status(pin, 'CANCELLED')
except _save_errors as e:
click.echo(f"Could not cancel '{_summary(pin)}': {e}")

def _check_pinned(ctx, lookback='7d'):
"""Go through past events pinning a task to the calendar, and ask
if the task was done. A done task is completed, the event is
marked as CONFIRMED. If the task wasn't done, the event is
cancelled.
"""
now = _now()
_select(ctx=ctx, event=True, start=now-parse_add_dur(None, lookback), end=now, pinned_tasks=True)
## sorted on the timestamps, events may be stored in different timezones
for event in sorted(ctx.obj['objs'], key=lambda x: _ensure_ts(x.icalendar_component.get('DTSTART'))):
comp = event.icalendar_component
if comp.get('STATUS', '') == 'CONFIRMED':
## already dealt with
continue
dtstart = _ensure_ts(comp.get('DTSTART')).astimezone(tz.implicit_timezone)
dtend = _ensure_ts(comp.get('DTEND')).astimezone(tz.implicit_timezone) if comp.get('DTEND') else dtstart + event.get_duration()
if dtend > now:
## still ongoing
continue
tasks = _pinned_task_parents(event)
if not tasks:
## e.g. completed when answering for an earlier pin
continue
end_format = '%H:%M' if dtend.date() == dtstart.date() else '%F %H:%M'
click.echo(f"{dtstart:%F %H:%M} - {dtend:{end_format}}: {_summary(event)}")
edited = []
while True:
answer = click.prompt("Was it done? y(es) / n(o) / p(artially) / e(dit task) / s(kip)", default='s')[:1].lower()
if answer == 'e':
for task in tasks:
_interactive_edit(task)
edited.extend(tasks)
tasks = _pinned_task_parents(event)
if not tasks:
break
continue
break
try:
if answer == 'e':
## every task was closed in the edit
done = any(x.icalendar_component.get('STATUS') == 'COMPLETED' for x in edited)
_set_event_status(event, 'CONFIRMED' if done else 'CANCELLED')
elif answer == 'y':
## one cache for all, so a subtask pinned along with its parent is completed once
cache = _RelativeCache()
cache.add_objects(tasks)
completed = set()
for task in tasks:
_complete(task, err_callback=click.echo, cache=cache, _seen=completed)
## before confirming the event: if that fails, the event
## is not asked about again, as the task is done
for task in tasks:
_cancel_other_pins(task, event)
_set_event_status(event, 'CONFIRMED')
elif answer == 'n':
_set_event_status(event, 'CANCELLED')
elif answer == 'p':
split = [interactive_split_task(task, partially_complete=True, too_big=False) for task in tasks]
if any(split):
_set_event_status(event, 'CONFIRMED')
except _save_errors as e:
## i.e. 423 Locked from a busy server - don't abort the rest of the list
click.echo(f"Could not save the changes for '{_summary(event)}': {e}")

def _dismiss_panic(ctx, hours_per_day, lookahead='60d'):
## TODO: fetch both events and tasks
lookahead=f"+{lookahead}"
Expand Down
3 changes: 3 additions & 0 deletions plann/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ def _mass_interactive_edit(objs, default='ignore'):
_command_line_edit(line, interactive=True, calendar=objs[0].parent, complete_cache=complete_cache, completed=completed)

def interactive_split_task(obj, partially_complete=False, too_big=True):
"""returns True if any subtask was split out"""
comp = obj.icalendar_component
summary = _summary(obj)
estimate = obj.get_duration()
Expand Down Expand Up @@ -350,6 +351,8 @@ def interactive_split_task(obj, partially_complete=False, too_big=True):
if postpone not in ('0h', '0'): ## TODO: regexp?
_procrastinate([obj], postpone, check_dependent='interactive', err_callback=click.echo, confirm_callback=click.confirm)
obj.save()
return True
return False

def _editor(sometext):
with tempfile.NamedTemporaryFile(mode='w', encoding='UTF-8', delete=False) as tmpfile:
Expand Down
Loading
Loading