Compare commits
2 Commits
525f29aa7a
...
4980b2e7e5
Author | SHA1 | Date | |
---|---|---|---|
4980b2e7e5 | |||
4110d93b1e |
@ -6,6 +6,7 @@ from .controller.tasks import add_task
|
|||||||
from .import_csv import import_tasks_from_csv
|
from .import_csv import import_tasks_from_csv
|
||||||
from .db import initialize_db
|
from .db import initialize_db
|
||||||
from .tui import tasks
|
from .tui import tasks
|
||||||
|
from .tui import recurring
|
||||||
|
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
|
|
||||||
@ -48,6 +49,12 @@ def browse(
|
|||||||
tasks.run(view)
|
tasks.run(view)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def generators():
|
||||||
|
initialize_db()
|
||||||
|
recurring.run()
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def import_csv(filename: str):
|
def import_csv(filename: str):
|
||||||
import_tasks_from_csv(filename)
|
import_tasks_from_csv(filename)
|
||||||
|
40
src/tt/controller/generators.py
Normal file
40
src/tt/controller/generators.py
Normal 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
|
31
src/tt/db.py
31
src/tt/db.py
@ -28,6 +28,11 @@ class TaskStatus(Enum):
|
|||||||
DONE = "done"
|
DONE = "done"
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorType(Enum):
|
||||||
|
DAYS_BETWEEN = "days-btwn"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(Model):
|
class BaseModel(Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
@ -68,33 +73,28 @@ class SavedSearch(BaseModel):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class TaskGenerator(Model):
|
class TaskGenerator(BaseModel):
|
||||||
template = CharField()
|
template = CharField()
|
||||||
type = CharField()
|
type = CharField()
|
||||||
config = TextField()
|
config = TextField() # JSON
|
||||||
deleted = BooleanField(default=False)
|
deleted = BooleanField(default=False)
|
||||||
last_generated_at = DateTimeField(null=True)
|
last_generated_at = DateTimeField(null=True)
|
||||||
created_at = DateTimeField(default=datetime.now)
|
created_at = DateTimeField(default=datetime.now)
|
||||||
|
|
||||||
def should_generate(self) -> bool:
|
def should_generate(self) -> bool:
|
||||||
"""
|
|
||||||
generator types: config keys
|
|
||||||
recurring: days_between
|
|
||||||
monthly: day_of_month
|
|
||||||
"""
|
|
||||||
if self.deleted:
|
if self.deleted:
|
||||||
return False
|
return False
|
||||||
if not self.last_generated_at:
|
if not self.last_generated_at:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if self.type == "recurring":
|
if self.type == GeneratorType.DAYS_BETWEEN:
|
||||||
days_between = self.config["days_between"]
|
days_between = self.config["val"]
|
||||||
days_since = (now - self.last_generated_at).days
|
days_since = (now - self.last_generated_at).days
|
||||||
return days_since >= days_between
|
return days_since >= days_between
|
||||||
|
|
||||||
elif self.type == "monthly":
|
elif self.type == GeneratorType.MONTHLY:
|
||||||
day_of_month = self.generator_config["day_of_month"]
|
day_of_month = self.config["val"]
|
||||||
|
|
||||||
# check each day until now to see if target day occurred
|
# check each day until now to see if target day occurred
|
||||||
one_day = timedelta(days=1)
|
one_day = timedelta(days=1)
|
||||||
@ -111,14 +111,7 @@ class TaskGenerator(Model):
|
|||||||
|
|
||||||
def initialize_db():
|
def initialize_db():
|
||||||
db.connect()
|
db.connect()
|
||||||
db.create_tables(
|
db.create_tables([Category, Task, SavedSearch, TaskGenerator])
|
||||||
[
|
|
||||||
Category,
|
|
||||||
Task,
|
|
||||||
SavedSearch,
|
|
||||||
# TaskGenerator
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if not Category.select().exists():
|
if not Category.select().exists():
|
||||||
Category.create(name="default")
|
Category.create(name="default")
|
||||||
db.close()
|
db.close()
|
||||||
|
@ -15,11 +15,26 @@ from ..utils import (
|
|||||||
)
|
)
|
||||||
from .keymodal import KeyModal
|
from .keymodal import KeyModal
|
||||||
|
|
||||||
|
ELLIPSIS = "…"
|
||||||
|
|
||||||
|
|
||||||
class NotifyValidationError(Exception):
|
class NotifyValidationError(Exception):
|
||||||
"""will notify and continue if raised"""
|
"""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:
|
class TableColumnConfig:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -38,7 +53,12 @@ class TableColumnConfig:
|
|||||||
self.enable_editor = enable_editor
|
self.enable_editor = enable_editor
|
||||||
self.enum = enum
|
self.enum = enum
|
||||||
self.filterable = filterable
|
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):
|
class TableEditor(App):
|
||||||
@ -193,7 +213,7 @@ class TableEditor(App):
|
|||||||
if self.table.cursor_column == 0:
|
if self.table.cursor_column == 0:
|
||||||
cur_row = self.table.cursor_row
|
cur_row = self.table.cursor_row
|
||||||
item_id = int(self.table.get_cell_at((cur_row, 0)))
|
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.update_item_callback(item_id, deleted=True)
|
||||||
self._save_cursor()
|
self._save_cursor()
|
||||||
self.refresh_data()
|
self.refresh_data()
|
||||||
@ -213,7 +233,7 @@ class TableEditor(App):
|
|||||||
|
|
||||||
new_item = self.add_item_callback(**prepopulated)
|
new_item = self.add_item_callback(**prepopulated)
|
||||||
self.refresh_data(restore_cursor=False)
|
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()
|
self.action_start_change()
|
||||||
|
|
||||||
def _active_column_config(self):
|
def _active_column_config(self):
|
||||||
@ -236,8 +256,8 @@ class TableEditor(App):
|
|||||||
# trigger item_id to be saved on the next cursor move
|
# trigger item_id to be saved on the next cursor move
|
||||||
# this avoids filtered columns disappearing right away
|
# this avoids filtered columns disappearing right away
|
||||||
# and tons of DB writes
|
# and tons of DB writes
|
||||||
# TODO: status hard coded here
|
update = {cconf.field: next_val}
|
||||||
self._register_save_on_move(item_id, status=next_val)
|
self._register_save_on_move(item_id, **update)
|
||||||
|
|
||||||
def _register_save_on_move(self, item_id, **kwargs):
|
def _register_save_on_move(self, item_id, **kwargs):
|
||||||
if self.save_on_move and self.save_on_move["item_id"] != item_id:
|
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(
|
current_value = self.table.get_cell_at(
|
||||||
(self.table.cursor_row, self.table.cursor_column)
|
(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)
|
current_value = remove_rich_tag(current_value)
|
||||||
self._show_input("edit", current_value)
|
self._show_input("edit", current_value)
|
||||||
|
|
||||||
@ -363,7 +387,7 @@ class TableEditor(App):
|
|||||||
self.update_item_callback(item_id, **update_data)
|
self.update_item_callback(item_id, **update_data)
|
||||||
self.refresh_data()
|
self.refresh_data()
|
||||||
except NotifyValidationError as e:
|
except NotifyValidationError as e:
|
||||||
self.notify(e.message)
|
self.notify(str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.notify(f"Error updating item: {str(e)}")
|
self.notify(f"Error updating item: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
|
139
src/tt/tui/overview.py
Normal file
139
src/tt/tui/overview.py
Normal 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
58
src/tt/tui/recurring.py
Normal 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()
|
@ -20,6 +20,7 @@ from .editor import (
|
|||||||
TableEditor,
|
TableEditor,
|
||||||
TableColumnConfig,
|
TableColumnConfig,
|
||||||
NotifyValidationError,
|
NotifyValidationError,
|
||||||
|
ELLIPSIS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -30,16 +31,6 @@ def due_preprocessor(val):
|
|||||||
raise NotifyValidationError("Invalid date format. Use YYYY-MM-DD")
|
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):
|
class TT(TableEditor):
|
||||||
TABLE_CONFIG = (
|
TABLE_CONFIG = (
|
||||||
TableColumnConfig("id", "ID"),
|
TableColumnConfig("id", "ID"),
|
||||||
@ -49,7 +40,6 @@ class TT(TableEditor):
|
|||||||
"Status",
|
"Status",
|
||||||
default="zero",
|
default="zero",
|
||||||
enum=TaskStatus,
|
enum=TaskStatus,
|
||||||
preprocessor=status_preprocessor,
|
|
||||||
),
|
),
|
||||||
TableColumnConfig("type", "Type", default=""),
|
TableColumnConfig("type", "Type", default=""),
|
||||||
TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor),
|
TableColumnConfig("due", "Due", default="", preprocessor=due_preprocessor),
|
||||||
@ -112,9 +102,14 @@ class TT(TableEditor):
|
|||||||
status = get_colored_status(item.status)
|
status = get_colored_status(item.status)
|
||||||
due = get_colored_date(item.due)
|
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(
|
self.table.add_row(
|
||||||
str(item.id),
|
str(item.id),
|
||||||
item.text.split("\n")[0], # first line
|
text,
|
||||||
status,
|
status,
|
||||||
item.type,
|
item.type,
|
||||||
due,
|
due,
|
||||||
|
Loading…
Reference in New Issue
Block a user