diff --git a/src/tt/cli.py b/src/tt/cli.py index 2a6478e..9fb2c07 100644 --- a/src/tt/cli.py +++ b/src/tt/cli.py @@ -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) diff --git a/src/tt/controller/generators.py b/src/tt/controller/generators.py new file mode 100644 index 0000000..943614a --- /dev/null +++ b/src/tt/controller/generators.py @@ -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 diff --git a/src/tt/db.py b/src/tt/db.py index 79eddbe..3407d91 100644 --- a/src/tt/db.py +++ b/src/tt/db.py @@ -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() diff --git a/src/tt/tui/editor.py b/src/tt/tui/editor.py index 64759fa..44dc37f 100644 --- a/src/tt/tui/editor.py +++ b/src/tt/tui/editor.py @@ -22,6 +22,19 @@ 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, @@ -40,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): @@ -195,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() @@ -238,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: @@ -369,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: diff --git a/src/tt/tui/overview.py b/src/tt/tui/overview.py new file mode 100644 index 0000000..3278818 --- /dev/null +++ b/src/tt/tui/overview.py @@ -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() diff --git a/src/tt/tui/recurring.py b/src/tt/tui/recurring.py new file mode 100644 index 0000000..7135263 --- /dev/null +++ b/src/tt/tui/recurring.py @@ -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() diff --git a/src/tt/tui/tasks.py b/src/tt/tui/tasks.py index 309fdc8..97acbca 100644 --- a/src/tt/tui/tasks.py +++ b/src/tt/tui/tasks.py @@ -31,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"), @@ -50,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),