Compare commits

...

2 Commits

Author SHA1 Message Date
4980b2e7e5 generators WIP 2025-02-08 23:32:25 -06:00
4110d93b1e protect long notes from quick edit 2025-02-08 21:41:05 -06:00
7 changed files with 293 additions and 37 deletions

View File

@ -6,6 +6,7 @@ from .controller.tasks import add_task
from .import_csv import import_tasks_from_csv
from .db import initialize_db
from .tui import tasks
from .tui import recurring
app = typer.Typer()
@ -48,6 +49,12 @@ def browse(
tasks.run(view)
@app.command()
def generators():
initialize_db()
recurring.run()
@app.command()
def import_csv(filename: str):
import_tasks_from_csv(filename)

View File

@ -0,0 +1,40 @@
from ..db import db, TaskGenerator, GeneratorType
import json
def get_generator(item_id: int) -> TaskGenerator:
return TaskGenerator.get_by_id(item_id)
def get_generators() -> list[TaskGenerator]:
query = TaskGenerator.select().where(~TaskGenerator.deleted)
return query.order_by("type", "template")
def add_generator(
template: str,
type: GeneratorType,
val: str,
) -> TaskGenerator:
# JSON for future expansion
config = json.dumps({"val": val})
with db.atomic():
task = TaskGenerator.create(
template=template,
type=type,
config=config,
)
return task
def update_generator(
item_id: int,
**kwargs,
) -> TaskGenerator:
config = {"val": kwargs.pop("val")}
kwargs["config"] = json.dumps(config)
with db.atomic():
query = TaskGenerator.update(kwargs).where(TaskGenerator.id == item_id)
query.execute()
task = TaskGenerator.get_by_id(item_id)
return task

View File

@ -28,6 +28,11 @@ class TaskStatus(Enum):
DONE = "done"
class GeneratorType(Enum):
DAYS_BETWEEN = "days-btwn"
MONTHLY = "monthly"
class BaseModel(Model):
class Meta:
database = db
@ -68,33 +73,28 @@ class SavedSearch(BaseModel):
return self.name
class TaskGenerator(Model):
class TaskGenerator(BaseModel):
template = CharField()
type = CharField()
config = TextField()
config = TextField() # JSON
deleted = BooleanField(default=False)
last_generated_at = DateTimeField(null=True)
created_at = DateTimeField(default=datetime.now)
def should_generate(self) -> bool:
"""
generator types: config keys
recurring: days_between
monthly: day_of_month
"""
if self.deleted:
return False
if not self.last_generated_at:
return True
now = datetime.now()
if self.type == "recurring":
days_between = self.config["days_between"]
if self.type == GeneratorType.DAYS_BETWEEN:
days_between = self.config["val"]
days_since = (now - self.last_generated_at).days
return days_since >= days_between
elif self.type == "monthly":
day_of_month = self.generator_config["day_of_month"]
elif self.type == GeneratorType.MONTHLY:
day_of_month = self.config["val"]
# check each day until now to see if target day occurred
one_day = timedelta(days=1)
@ -111,14 +111,7 @@ class TaskGenerator(Model):
def initialize_db():
db.connect()
db.create_tables(
[
Category,
Task,
SavedSearch,
# TaskGenerator
]
)
db.create_tables([Category, Task, SavedSearch, TaskGenerator])
if not Category.select().exists():
Category.create(name="default")
db.close()

View File

@ -15,11 +15,26 @@ from ..utils import (
)
from .keymodal import KeyModal
ELLIPSIS = ""
class NotifyValidationError(Exception):
"""will notify and continue if raised"""
def _enum_preprocessor(enumCls):
"""generate a default preprocessor to enforce enums"""
def preprocessor(val):
try:
enumCls(val)
return val
except ValueError:
raise NotifyValidationError(
f"Invalid value. Use: {[s.value for s in enumCls]}"
)
class TableColumnConfig:
def __init__(
self,
@ -38,7 +53,12 @@ class TableColumnConfig:
self.enable_editor = enable_editor
self.enum = enum
self.filterable = filterable
self.preprocessor = preprocessor or (lambda x: x)
if preprocessor:
self.preprocessor = preprocessor
elif self.enum:
self.preprocessor = _enum_preprocessor(self.enum)
else:
self.preprocessor = lambda x: x
class TableEditor(App):
@ -193,7 +213,7 @@ class TableEditor(App):
if self.table.cursor_column == 0:
cur_row = self.table.cursor_row
item_id = int(self.table.get_cell_at((cur_row, 0)))
# TODO: deletable items need a delete
# deletable items need a delete
self.update_item_callback(item_id, deleted=True)
self._save_cursor()
self.refresh_data()
@ -213,7 +233,7 @@ class TableEditor(App):
new_item = self.add_item_callback(**prepopulated)
self.refresh_data(restore_cursor=False)
self.move_cursor_to_item(new_item.id)
self.move_cursor_to_item(new_item.id) # TODO: check success here
self.action_start_change()
def _active_column_config(self):
@ -236,8 +256,8 @@ class TableEditor(App):
# trigger item_id to be saved on the next cursor move
# this avoids filtered columns disappearing right away
# and tons of DB writes
# TODO: status hard coded here
self._register_save_on_move(item_id, status=next_val)
update = {cconf.field: next_val}
self._register_save_on_move(item_id, **update)
def _register_save_on_move(self, item_id, **kwargs):
if self.save_on_move and self.save_on_move["item_id"] != item_id:
@ -307,6 +327,10 @@ class TableEditor(App):
current_value = self.table.get_cell_at(
(self.table.cursor_row, self.table.cursor_column)
)
if current_value.endswith(ELLIPSIS):
# TODO: flash message?
# need to edit with e
return
current_value = remove_rich_tag(current_value)
self._show_input("edit", current_value)
@ -363,7 +387,7 @@ class TableEditor(App):
self.update_item_callback(item_id, **update_data)
self.refresh_data()
except NotifyValidationError as e:
self.notify(e.message)
self.notify(str(e))
except Exception as e:
self.notify(f"Error updating item: {str(e)}")
finally:

139
src/tt/tui/overview.py Normal file
View File

@ -0,0 +1,139 @@
from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer, Horizontal
from textual.widgets import DataTable, Static
from textual.binding import Binding
from datetime import datetime
from ..controller.summaries import (
get_category_summary,
get_recently_active,
get_due_soon,
)
class CategoryTable(DataTable):
"""Table showing category summaries"""
def on_mount(self):
self.add_columns(
"Category", "Zero", "WIP", "Blocked", "Done", "Overdue", "Due Soon"
)
self.refresh_data()
def refresh_data(self):
self.clear()
summaries = get_category_summary(10) # Show top 10 categories
for summary in summaries:
self.add_row(
summary["category"],
str(summary["tasks"]["zero"]),
str(summary["tasks"]["wip"]),
str(summary["tasks"]["blocked"]),
str(summary["tasks"]["done"]),
str(summary["tasks"]["overdue"]),
str(summary["tasks"]["due_soon"]),
)
class TaskList(DataTable):
"""Base class for task list tables"""
def on_mount(self):
self.add_columns("Task", "Status", "Category", "Due Date")
def format_due_date(self, due_date: datetime | None) -> str:
if not due_date:
return "No due date"
return due_date.strftime("%Y-%m-%d")
class DueTaskList(TaskList):
"""Table showing upcoming and overdue tasks"""
def on_mount(self):
super().on_mount()
self.refresh_data()
def refresh_data(self):
self.clear()
tasks = get_due_soon(10, all_overdue=True) # Show all overdue + next 10
for task in tasks:
self.add_row(
task["text"],
task["status"],
task["category"] or "No category",
self.format_due_date(task["due"]),
key=str(task["id"]),
)
class RecentTaskList(TaskList):
"""Table showing recently active tasks"""
def on_mount(self):
super().on_mount()
self.refresh_data()
def refresh_data(self):
self.clear()
tasks = get_recently_active(10) # Show 10 most recent
for task in tasks:
self.add_row(
task["text"],
task["status"],
task["category"] or "No category",
self.format_due_date(task["due"]),
key=str(task["id"]),
)
class Overview(App):
"""Task overview application"""
TITLE = "Task Overview"
CSS = """
CategoryTable {
height: 40%;
margin: 1 1;
}
#lists {
height: 60%;
}
TaskList {
width: 50%;
margin: 1 1;
}
Static {
content-align: center middle;
background: $panel;
padding: 1;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("r", "refresh", "Refresh Data"),
]
def compose(self) -> ComposeResult:
yield CategoryTable()
yield Static("Upcoming and Recent Tasks")
with Horizontal(id="lists"):
with ScrollableContainer():
yield DueTaskList()
with ScrollableContainer():
yield RecentTaskList()
def action_refresh(self):
"""Refresh all data in the tables"""
self.query_one(CategoryTable).refresh_data()
self.query_one(DueTaskList).refresh_data()
self.query_one(RecentTaskList).refresh_data()
if __name__ == "__main__":
app = Overview()
app.run()

58
src/tt/tui/recurring.py Normal file
View File

@ -0,0 +1,58 @@
import json
from datetime import datetime
from ..controller.generators import (
get_generator,
get_generators,
add_generator,
update_generator,
)
from ..db import GeneratorType
from .editor import (
TableEditor,
TableColumnConfig,
NotifyValidationError,
)
def due_preprocessor(val):
try:
return datetime.strptime(val, "%Y-%m-%d")
except ValueError:
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
class TaskGenEditor(TableEditor):
TABLE_CONFIG = (
TableColumnConfig("id", "ID"),
TableColumnConfig("template", "Template", default="recur {val}"),
TableColumnConfig(
"type",
"Type",
default=GeneratorType.DAYS_BETWEEN.value,
enum=GeneratorType,
),
TableColumnConfig("val", "Value", default=""),
)
def __init__(self):
super().__init__()
self.update_item_callback = update_generator
self.add_item_callback = add_generator
self.get_item_callback = get_generator
def refresh_items(self):
items = get_generators()
for item in items:
self.table.add_row(
str(item.id), item.template, item.type, json.loads(item.config)["val"]
)
def run():
app = TaskGenEditor()
app.run()
if __name__ == "__main__":
run()

View File

@ -20,6 +20,7 @@ from .editor import (
TableEditor,
TableColumnConfig,
NotifyValidationError,
ELLIPSIS,
)
@ -30,16 +31,6 @@ def due_preprocessor(val):
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
def status_preprocessor(val):
try:
TaskStatus(val)
return val
except ValueError:
raise NotifyValidationError(
f"Invalid status. Use: {[s.value for s in TaskStatus]}"
)
class TT(TableEditor):
TABLE_CONFIG = (
TableColumnConfig("id", "ID"),
@ -49,7 +40,6 @@ class TT(TableEditor):
"Status",
default="zero",
enum=TaskStatus,
preprocessor=status_preprocessor,
),
TableColumnConfig("type", "Type", default=""),
TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor),
@ -112,9 +102,14 @@ class TT(TableEditor):
status = get_colored_status(item.status)
due = get_colored_date(item.due)
if "\n" in item.text:
text = item.text.split("\n")[0] + ELLIPSIS
else:
text = item.text
self.table.add_row(
str(item.id),
item.text.split("\n")[0], # first line
text,
status,
item.type,
due,