diff --git a/src/tt/controller.py b/src/tt/controller.py index a1eca29..6451357 100644 --- a/src/tt/controller.py +++ b/src/tt/controller.py @@ -38,11 +38,35 @@ def update_task( return task +def _parse_sort_string(sort_string, model_class): + """ + Convert sort string like 'field1,-field2' to peewee order_by expressions. + """ + sort_expressions = [] + + if not sort_string: + return sort_expressions + + for field in sort_string.split(","): + is_desc = field.startswith("-") + field_name = field[1:] if is_desc else field + + # special handling for due_date with COALESCE + if field_name == "due_date": + expr = fn.COALESCE(getattr(model_class, field_name), datetime(3000, 12, 31)) + sort_expressions.append(expr.desc() if is_desc else expr) + else: + field_expr = getattr(model_class, field_name) + sort_expressions.append(field_expr.desc() if is_desc else field_expr) + + return sort_expressions + + def get_tasks( search_text: str | None = None, category: int | None = None, - status: str | None = None, - include_done: bool = False, + statuses: tuple[str] | None = None, + sort: str = "", ) -> list[Task]: query = Task.select().where(~Task.deleted) @@ -50,17 +74,11 @@ def get_tasks( query = query.where(fn.Lower(Task.text).contains(search_text.lower())) if category: query = query.where(Task.category == Category.get(name=category)) - if status: - query = query.where(Task.status == status) + if statuses: + query = query.where(Task.status.in_(statuses)) - if not include_done: - # by default, exclude done tasks - query = query.where(Task.status != TaskStatus.DONE.value) - - # order by due date (null last) and created date - query = query.order_by( - fn.COALESCE(Task.due, datetime(3000, 12, 31)), Task.created_at - ) + sort_expressions = _parse_sort_string(sort, Task) + query = query.order_by(*sort_expressions) return list(query) diff --git a/src/tt/tui.py b/src/tt/tui.py index eeae98a..6354ddf 100644 --- a/src/tt/tui.py +++ b/src/tt/tui.py @@ -17,11 +17,12 @@ from .utils import ( advance_enum_val, get_colored_category, get_colored_status, + get_colored_date, ) + +# Parity # TODO: add filtering on status -# TODO: sorting # TODO: saved searches -# TODO: date coloring rules (days away, days of week, week of year) # Nice to Have # TODO: CLI # TODO: config- default category, types, colors @@ -97,6 +98,7 @@ class TT(App): # filtering & editing ("/", "start_search", "search tasks by name"), ("f", "start_filter", "filter tasks by category"), + ("s", "start_sort", "sort tasks"), ("escape", "cancel_edit", "Cancel Edit"), # edits ("c", "start_edit", "edit current cell"), @@ -112,6 +114,7 @@ class TT(App): super().__init__() self.search_query = "" self.search_category = "" + self.sort_string = "due,status" self.saved_cursor_pos = (0, 0) self.save_on_move = None @@ -123,7 +126,7 @@ class TT(App): self.input_bar = Container(id="input_bar") self.status_bar = Container(id="status_bar") self.left_status = Static("LEFT", id="left_status") - self.right_status = Static("RIGHT", id="right_status") + self.right_status = Static(self.sort_string, id="right_status") yield self.header yield Container(self.table) with Container(id="footer"): @@ -170,20 +173,22 @@ class TT(App): # show table self.table.clear() - tasks = get_tasks(self.search_query, category=self.search_category) + tasks = get_tasks( + self.search_query, category=self.search_category, sort=self.sort_string + ) for task in tasks: - due_str = task.due.strftime("%Y-%m-%d") if task.due else " - " category = get_colored_category( task.category.name if task.category else " - " ) status = get_colored_status(task.status) + due = get_colored_date(task.due) self.table.add_row( str(task.id), task.text, status, task.type, - due_str, + due, category, key=str(task.id), ) @@ -269,6 +274,8 @@ class TT(App): self.input_label.update("search: ") elif mode == "edit": self.input_label.update("edit: ") + elif mode == "sort": + self.input_label.update("sort: ") elif mode == "filter": self.input_label.update("filter: ") else: @@ -288,6 +295,9 @@ class TT(App): def action_start_filter(self): self._show_input("filter", self.search_category) + def action_start_sort(self): + self._show_input("sort", self.sort_string) + def _save_cursor(self): self.saved_cursor_pos = (self.table.cursor_row, self.table.cursor_column) @@ -309,6 +319,10 @@ class TT(App): elif self.mode == "filter": self.search_category = event.value self.refresh_tasks(restore_cursor=False) + elif self.mode == "sort": + self.sort_string = event.value + self.refresh_tasks(restore_cursor=False) + self.right_status.update(self.sort_string) elif self.mode == "edit": self.apply_change(event.value) else: @@ -383,7 +397,7 @@ class KeyBindingsScreen(ModalScreen): if binding[0] not in ["h", "j", "k", "l", "g", "G", "escape"]: table.add_row(binding[0], binding[2]) - yield Static("Keybindings", classes="title") + yield Static("tt keybindings", classes="title") yield Static(table) yield Static("Press any key to dismiss", classes="footer") diff --git a/src/tt/utils.py b/src/tt/utils.py index 7f108c8..baa3add 100644 --- a/src/tt/utils.py +++ b/src/tt/utils.py @@ -1,4 +1,5 @@ import re +import datetime def remove_rich_tag(text): @@ -30,3 +31,33 @@ def get_colored_category(category: str) -> str: hue = hash_val % 360 color = f"rgb({hue},200,200) on default" return f"[{color}]{category}[/]" + + +def get_colored_date(date: datetime.date) -> str: + if not date: + return "" + as_str = date.strftime("%Y-%m-%d") + today = datetime.date.today() + if date.date() < today: + color = "#FF0000" + else: + # Calculate weeks into future + delta = date.date() - today + weeks = delta.days // 7 + + colors = [ + "#FF4000", + "#FF8000", + "#FFA533", + "#FFD24D", + "#FFE680", + "#FFF4B3", + "#FFFFFF", + ] + + if weeks >= len(colors): + color_index = -1 + else: + color_index = weeks + color = colors[color_index] + return f"[{color}]{as_str}[/]"