diff --git a/README.md b/README.md index 23614855..7ee3eb0d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ What you can safely pay yourself each month, after taxes and recurring expenses. ### Timeline & Tasks -Key events in chronological order, and a to-do list of what needs your attention. +Key events in chronological order, and a to-do list that fills itself from rules you set, such as "30 days before a contract ends" or "every quarter". ### AI Document Import diff --git a/assets/images/task-rules/composer-dark.png b/assets/images/task-rules/composer-dark.png new file mode 100644 index 00000000..bb84ab5c Binary files /dev/null and b/assets/images/task-rules/composer-dark.png differ diff --git a/assets/images/task-rules/composer-date.png b/assets/images/task-rules/composer-date.png new file mode 100644 index 00000000..57148e30 Binary files /dev/null and b/assets/images/task-rules/composer-date.png differ diff --git a/assets/images/task-rules/composer-event.png b/assets/images/task-rules/composer-event.png new file mode 100644 index 00000000..3119bf3f Binary files /dev/null and b/assets/images/task-rules/composer-event.png differ diff --git a/assets/images/task-rules/composer-final-status.png b/assets/images/task-rules/composer-final-status.png new file mode 100644 index 00000000..38bda0c0 Binary files /dev/null and b/assets/images/task-rules/composer-final-status.png differ diff --git a/assets/images/task-rules/composer-open-status.png b/assets/images/task-rules/composer-open-status.png new file mode 100644 index 00000000..7194ef14 Binary files /dev/null and b/assets/images/task-rules/composer-open-status.png differ diff --git a/assets/images/task-rules/rules.png b/assets/images/task-rules/rules.png new file mode 100644 index 00000000..9e350f66 Binary files /dev/null and b/assets/images/task-rules/rules.png differ diff --git a/assets/images/task-rules/tasks.png b/assets/images/task-rules/tasks.png new file mode 100644 index 00000000..f25f4ddc Binary files /dev/null and b/assets/images/task-rules/tasks.png differ diff --git a/tuttle/app/tasks/generator.py b/tuttle/app/tasks/generator.py index bea990ee..a90c9e42 100644 --- a/tuttle/app/tasks/generator.py +++ b/tuttle/app/tasks/generator.py @@ -1,16 +1,21 @@ """Task generator — inspects business state and upserts Task rows. -Called by TasksIntent.get_all() before returning results. Each rule -produces a (key, title, description) tuple; the generator upserts by key -so duplicates are never created. +Called by TasksIntent.get_all() before returning results. Tasks are keyed, +so the generator never creates one twice. -Tutorial tasks auto-resolve (mark done) when their condition no longer -holds. Business tasks require explicit user action. +Tutorial tasks are built in and auto-resolve (mark done) when their condition +no longer holds. Every other task comes from a rule the user can edit (see +rules.py): it appears when the rule's day comes, and closes by itself when +it's no longer worth doing, such as a reminder for an invoice that got paid. """ +import datetime +from typing import Optional + from sqlmodel import Session, select -from ...model import Client, Contact, Contract, Invoice, Project, Task +from ...model import Client, Contact, Contract, Invoice, Project, Task, TaskRule, TaskRuleNotice +from .rules import Evaluation, Records, evaluate, is_known # --------------------------------------------------------------------------- # Tutorial rule definitions @@ -71,11 +76,12 @@ # --------------------------------------------------------------------------- -def generate_tasks(session: Session) -> None: +def generate_tasks(session: Session, today: Optional[datetime.date] = None) -> None: """Refresh task rows based on current business state.""" + today = today or datetime.date.today() _generate_tutorial_tasks(session) _generate_manual_tutorial_tasks(session) - _generate_overdue_tasks(session) + _generate_rule_tasks(session, today) session.commit() @@ -101,7 +107,8 @@ def _upsert_tutorial_task(session: Session, key: str, title: str, description: s def _generate_tutorial_tasks(session: Session) -> None: """Auto-resolving tutorial tasks (condition-based).""" for key, title, description, model_cls in TUTORIAL_RULES: - has_entity = session.exec(select(model_cls)).first() is not None + # Only the id: a whole record loads its relationships too, and this runs on every view change. + has_entity = session.exec(select(model_cls.id).limit(1)).first() is not None existing = _upsert_tutorial_task(session, key, title, description) # Resolve or reopen based on current state @@ -120,26 +127,75 @@ def _generate_manual_tutorial_tasks(session: Session) -> None: # --------------------------------------------------------------------------- -# Overdue invoice tasks +# Tasks from rules # --------------------------------------------------------------------------- -def _generate_overdue_tasks(session: Session) -> None: - invoices = session.exec(select(Invoice)).all() - for inv in invoices: - if inv.status != "overdue": - continue - # Skip if a reminder already exists for this invoice - if inv.reminders: +def _generate_rule_tasks(session: Session, today: datetime.date) -> None: + records = Records(session) + for rule in session.exec(select(TaskRule).where(TaskRule.paused == False)).all(): # noqa: E712 + if not is_known(rule): + # Its tasks are left as they are rather than taken for orphans. continue - - key = f"overdue:{inv.id}" - existing = session.exec(select(Task).where(Task.key == key)).first() - if not existing: - session.add( - Task( - key=key, - title=f"Invoice {inv.number} is overdue — send a reminder", - description=f"This invoice was due on {inv.effective_due_date}.", + notices = None + if rule.follows_state: + notices = { + n.subject_id: n for n in session.exec(select(TaskRuleNotice).where(TaskRuleNotice.rule_id == rule.id)).all() + } + ev = evaluate(rule, session, today, notices, records) + _apply(session, rule, ev, today) + if notices is not None: + _note_changes(session, rule, ev, notices, today) + + +def _apply(session: Session, rule: TaskRule, ev: Evaluation, today: datetime.date) -> None: + """Make the rule's tasks match what it asks for today.""" + tasks = session.exec(select(Task).where(Task.rule_id == rule.id)).all() + by_key = {t.key: t for t in tasks} + gone = set() + for item in ev.items: + task = by_key.get(item.key) + if task is not None and task.due_date is not None and task.due_date != item.due_date: + # The date moved, so the task was about another day. + if not item.is_due(today): + session.delete(task) + gone.add(task.id) + continue + task.status = "pending" + task.created_at = datetime.datetime.now() + if task is None: + if item.is_due(today): + session.add( + Task( + key=item.key, + title=item.title, + description=item.note, + rule_id=rule.id, + due_date=item.due_date, + subject_type=item.subject_type, + subject_id=item.subject_id, + ) ) - ) + continue + task.due_date = item.due_date + if task.status == "pending": + task.title, task.description = item.title, item.note + session.add(task) + for task in tasks: + if task.id in gone or task.subject_id is None or task.status != "pending": + continue + if task.subject_id in ev.settled: + task.status = "done" + session.add(task) + elif ev.subjects is not None and task.subject_id not in ev.subjects: + # Its contract, project or invoice is gone, or no longer the rule's client's. + session.delete(task) + + +def _note_changes(session: Session, rule: TaskRule, ev: Evaluation, notices: dict, today: datetime.date) -> None: + """Remember the day a status change was first seen, and forget ones that were undone.""" + for subject_id in ev.noticed: + session.add(TaskRuleNotice(rule_id=rule.id, subject_id=subject_id, noticed_on=today)) + for subject_id, notice in notices.items(): + if subject_id in ev.settled or (ev.subjects is not None and subject_id not in ev.subjects): + session.delete(notice) diff --git a/tuttle/app/tasks/intent.py b/tuttle/app/tasks/intent.py index f2a21b42..1547ca58 100644 --- a/tuttle/app/tasks/intent.py +++ b/tuttle/app/tasks/intent.py @@ -1,7 +1,19 @@ -from ...model import Task +import datetime +from typing import Optional + +from loguru import logger +from sqlmodel import Session, select + +from ...model import Client, Task, TaskRule, TaskRuleNotice from ..core.abstractions import Intent, SQLModelDataSourceMixin from ..core.intent_result import IntentResult from .generator import generate_tasks +from .rules import EVENTS, Records, evaluate, fmt_date, occurrences, rule_options, start_watching, validate_rule + +# The fields that decide which tasks a rule makes. Changing one starts its pending tasks afresh. +_TRIGGER_FIELDS = ("kind", "start_date", "every", "unit", "event", "offset", "offset_unit", "before", "client_id") +# The fields that decide which records a status rule watches. +_WATCH_FIELDS = ("kind", "event", "client_id") class TasksIntent(SQLModelDataSourceMixin, Intent): @@ -16,8 +28,6 @@ def get_all(self) -> IntentResult: with self.create_session() as session: generate_tasks(session) with self.create_session() as session: - from sqlmodel import select - tasks = session.exec( select(Task).where(Task.status.in_(["pending", "done"])) # type: ignore[union-attr] ).all() @@ -30,6 +40,17 @@ def get_all(self) -> IntentResult: exception=e, ) + def pending_count(self) -> int: + """How many tasks are pending, after refreshing them from business state. For the sidebar.""" + try: + with self.create_session() as session: + generate_tasks(session) + return len(session.exec(select(Task.id).where(Task.status == "pending")).all()) + except Exception: + # The count is a convenience; the Tasks view reports a real failure. + logger.exception("TasksIntent.pending_count failed") + return 0 + def mark_done(self, id: int) -> IntentResult: """Mark a task as done.""" try: @@ -95,3 +116,199 @@ def reopen(self, id: int) -> IntentResult: log_message=f"TasksIntent.reopen({id}): {e}", exception=e, ) + + # -- Rules ------------------------------------------------------------- + + def rule_options(self) -> IntentResult: + """What the rule composer offers: kinds of record, their events, and the clients to narrow to.""" + try: + with self.create_session() as session: + clients = session.exec(select(Client).order_by(Client.name)).all() + options = rule_options() + options["clients"] = [{"id": c.id, "name": c.name} for c in clients] + return IntentResult(was_intent_successful=True, data=options) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to load the rule options.", + log_message=f"TasksIntent.rule_options: {e}", + exception=e, + ) + + def get_rules(self) -> IntentResult: + """Every rule, each with the day it next creates a task.""" + try: + today = datetime.date.today() + with self.create_session() as session: + rules = session.exec(select(TaskRule).order_by(TaskRule.id)).all() + records = Records(session) + data = [] + for rule in rules: + ev = evaluate(rule, session, today, self._notices(session, rule), records) + d = rule.to_rpc_dict() + d["next_date"] = ev.next.trigger.isoformat() if ev.next else None + data.append(d) + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to load the task rules.", + log_message=f"TasksIntent.get_rules: {e}", + exception=e, + ) + + def preview_rule(self, rule: dict) -> IntentResult: + """What a rule being composed would do: the tasks it makes today, and the next one. + + A rule that can't be saved yet is not an error here: the preview says what's missing. + """ + try: + today = datetime.date.today() + with self.create_session() as session: + existing = session.get(TaskRule, rule.get("id")) if rule.get("id") else None + draft = TaskRule(title="", created_at=existing.created_at if existing else datetime.datetime.now()) + try: + _fill(draft, rule) + validate_rule(draft) + except ValueError as e: + return IntentResult(was_intent_successful=True, data={"error": str(e)}) + draft.id = existing.id if existing else None + watched = existing is not None and _same(existing, draft, _WATCH_FIELDS) + ev = evaluate(draft, session, today, self._notices(session, existing) if watched else None) + data = { + "error": None, + "now": [{"title": i.title, "date": i.due_date.isoformat()} for i in ev.items if i.is_due(today)], + "next": {"title": ev.next.title, "date": ev.next.trigger.isoformat()} if ev.next else None, + "later": [], + # A new rule on a final status (paid, completed) counts only what reaches it from today. + "from_today": draft.follows_state and EVENTS[draft.event].final and not watched, + } + if draft.kind == "date" and ev.next: + after = [ + d for d in occurrences(draft, ev.next.trigger + datetime.timedelta(days=400)) if d > ev.next.trigger + ] + data["later"] = [fmt_date(d) for d in after[:2]] + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Couldn't preview the rule. Details are in the application log.", + log_message=f"TasksIntent.preview_rule: {e}", + exception=e, + ) + + def save_rule(self, rule: dict) -> IntentResult: + """Create or update a rule, and refresh the tasks it makes.""" + try: + today = datetime.date.today() + with self.create_session() as session: + existing = session.get(TaskRule, rule.get("id")) if rule.get("id") else None + old = TaskRule(**existing.model_dump()) if existing else None + target = existing or TaskRule(title="") + try: + _fill(target, rule) + validate_rule(target) + except ValueError as e: + return IntentResult(was_intent_successful=False, error_msg=str(e)) + session.add(target) + session.flush() + if old is None or not _same(old, target, _WATCH_FIELDS): + start_watching(session, target, today) + if old is not None and not _same(old, target, _TRIGGER_FIELDS): + for task in session.exec(select(Task).where(Task.rule_id == target.id, Task.status == "pending")).all(): + session.delete(task) + session.commit() + session.refresh(target) + return IntentResult(was_intent_successful=True, data=target.to_rpc_dict()) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Couldn't save the rule. Details are in the application log.", + log_message=f"TasksIntent.save_rule: {e}", + exception=e, + ) + + def set_rule_paused(self, id: int, paused: bool) -> IntentResult: + """Pause or resume a rule.""" + try: + with self.create_session() as session: + rule = session.get(TaskRule, id) + if not rule: + return IntentResult(was_intent_successful=False, error_msg="That rule no longer exists.") + rule.paused = bool(paused) + # Resumed, a rule on a final status ignores what reached it while paused. + # One on an open status keeps its notes, so nothing it already followed up comes twice. + if not rule.paused and rule.follows_state and EVENTS[rule.event].final: + start_watching(session, rule, datetime.date.today()) + session.add(rule) + session.commit() + session.refresh(rule) + return IntentResult(was_intent_successful=True, data=rule.to_rpc_dict()) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Couldn't change the rule. Details are in the application log.", + log_message=f"TasksIntent.set_rule_paused({id}): {e}", + exception=e, + ) + + def delete_rule(self, id: int) -> IntentResult: + """Delete a rule and the tasks it made.""" + try: + with self.create_session() as session: + rule = session.get(TaskRule, id) + if not rule: + return IntentResult(was_intent_successful=True, data=None) + for task in session.exec(select(Task).where(Task.rule_id == id)).all(): + session.delete(task) + for notice in session.exec(select(TaskRuleNotice).where(TaskRuleNotice.rule_id == id)).all(): + session.delete(notice) + session.delete(rule) + session.commit() + return IntentResult(was_intent_successful=True, data=None) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Couldn't delete the rule. Details are in the application log.", + log_message=f"TasksIntent.delete_rule({id}): {e}", + exception=e, + ) + + @staticmethod + def _notices(session: Session, rule: Optional[TaskRule]) -> Optional[dict]: + if rule is None or not rule.follows_state: + return None + return {n.subject_id: n for n in session.exec(select(TaskRuleNotice).where(TaskRuleNotice.rule_id == rule.id)).all()} + + +def _same(a: TaskRule, b: TaskRule, fields) -> bool: + return all(getattr(a, f) == getattr(b, f) for f in fields) + + +def _int(value, message: str) -> int: + try: + return int(value) + except (TypeError, ValueError): + raise ValueError(message) from None + + +def _fill(rule: TaskRule, data: dict) -> None: + """Copy the composer's fields onto ``rule``. Raises ``ValueError`` with a message for the user.""" + rule.title = str(data.get("title") or "") + rule.note = data.get("note") or None + rule.kind = data.get("kind") or "date" + start = data.get("start_date") + try: + rule.start_date = datetime.date.fromisoformat(start[:10]) if start else None + except ValueError: + raise ValueError("Enter the date as a calendar date.") from None + rule.every = _int(data.get("every", 1), "Enter how often the task repeats as a whole number.") + rule.unit = data.get("unit") or None + rule.event = data.get("event") or None + rule.offset = _int(data.get("offset", 0), "Enter how many days, weeks or months as a whole number.") + rule.offset_unit = data.get("offset_unit") or "day" + rule.before = bool(data.get("before")) + client_id = data.get("client_id") + rule.client_id = _int(client_id, "Choose a client from the list.") if client_id not in (None, "") else None + if "paused" in data: + rule.paused = bool(data["paused"]) diff --git a/tuttle/app/tasks/rules.py b/tuttle/app/tasks/rules.py new file mode 100644 index 00000000..4d20d809 --- /dev/null +++ b/tuttle/app/tasks/rules.py @@ -0,0 +1,651 @@ +"""Task rules: what each rule asks for on a given day. + +A rule either falls on a date, once or repeating, or follows something that +happens to a contract, project, invoice or financial goal. ``evaluate`` reads +the records and says, for one day, which tasks the rule wants, which subjects' +tasks are settled, and when it next creates one. The generator applies that to +the task table; the rule composer shows it as a preview. + +A date can be anticipated, so a task may come before it. A state change, such +as a project becoming an offer, has no date of its own: the rule notes the +day it first sees it (``TaskRuleNotice``), and only reacts to changes from the +day it was made. +""" + +import calendar +import datetime +import re +from dataclasses import dataclass, field +from typing import Callable, Optional + +from sqlmodel import Session, select + +from ...model import ( + Contract, + FinancialGoal, + Invoice, + Project, + TaskRule, + TaskRuleNotice, +) +from ...time import Cycle + +# --------------------------------------------------------------------------- +# Dates +# --------------------------------------------------------------------------- + +MONTHS = ( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +) + + +def fmt_date(d: datetime.date) -> str: + return f"{MONTHS[d.month - 1][:3]} {d.day}, {d.year}" + + +def add_months(d: datetime.date, n: int, day: Optional[int] = None) -> datetime.date: + """``d`` moved by ``n`` months, on ``day`` (default: d's day), kept within the month.""" + m = d.month - 1 + n + year, month = d.year + m // 12, m % 12 + 1 + return datetime.date(year, month, min(day or d.day, calendar.monthrange(year, month)[1])) + + +def shift(d: datetime.date, amount: int, unit: str) -> datetime.date: + if unit == "day": + return d + datetime.timedelta(days=amount) + if unit == "week": + return d + datetime.timedelta(weeks=amount) + return add_months(d, amount) + + +# --------------------------------------------------------------------------- +# What a rule can follow +# +# Each kind of record declares its dates and the statuses Tuttle already shows +# for it (the project board's stage, a contract's or an invoice's status). The +# events a rule can follow are derived from that: "a contract ends", "a +# project becomes an offer". +# --------------------------------------------------------------------------- + + +def _client_name(client) -> str: + return client.name if client else "" + + +@dataclass(frozen=True) +class DateField: + """A date on a record that a task can come before, on or after.""" + + label: str # reads after the record: "a contract ends" + titles: tuple[Optional[str], str] # default task titles: before or on the day, and after + get: Callable[[object, datetime.date], list] # the date, or dates up to a horizon + is_open: Callable[[object], bool] = lambda _: True # whether a task about it is still worth doing + known_ahead: bool = True # False: Tuttle learns the date only once it has passed + # Open invoices and goals: tasks catch up on ones open before the rule was + # made, and close once paid or reached, however late the task. + lasting: bool = False + repeats: bool = False + + +@dataclass(frozen=True) +class State: + """A status a record can enter. A rule reacts when it does.""" + + label: str # "becomes an offer" + title: str # default task title + # A final status (paid, completed) is where records end up, so it holds for + # every old one: a rule counts only records that reach it from the day it's + # made. An open one (overdue, an offer) is something to act on, so records + # already in it count too. + final: bool = False + + +@dataclass(frozen=True) +class Record: + model: type + label: str + placeholders: tuple[str, ...] + values: Callable[[object], dict] + client_id: Callable[[object], Optional[int]] + status: Callable[[object], str] # the status Tuttle shows for it + dates: dict[str, DateField] + states: dict[str, State] # the statuses a rule can follow, by status + + +def _contract_open(c: Contract) -> bool: + return not c.is_completed + + +def _project_open(p: Project) -> bool: + return not p.is_completed and p.stage != "Completed" + + +def _invoice_unsent(i: Invoice) -> bool: + return not i.sent and not i.cancelled + + +def _invoice_unpaid(i: Invoice) -> bool: + """Sent and still waiting for payment. Once a reminder is made, the reminder is what's open.""" + return bool(i.sent) and not i.paid and not i.cancelled and not i.reminders + + +_CYCLE_MONTHS = {Cycle.monthly: 1, Cycle.quarterly: 3, Cycle.yearly: 12} + + +def _period_ends(c: Contract, until: datetime.date) -> list[datetime.date]: + """The last days of the contract's billing periods, up to ``until`` and its end date. + + Hourly and daily cycles have no period worth a task. + """ + if c.billing_cycle == Cycle.weekly: + + def period_start(k: int) -> datetime.date: + return c.start_date + datetime.timedelta(weeks=k) + + elif c.billing_cycle in _CYCLE_MONTHS: + months = _CYCLE_MONTHS[c.billing_cycle] + + def period_start(k: int) -> datetime.date: + return add_months(c.start_date, k * months, c.start_date.day) + + else: + return [] + ends = [] + k = 1 + while True: + end = period_start(k) - datetime.timedelta(days=1) + if c.end_date and end >= c.end_date: + if c.end_date <= until: + ends.append(c.end_date) + return ends + if end > until: + return ends + ends.append(end) + k += 1 + + +RECORDS: dict[str, Record] = { + "contract": Record( + model=Contract, + label="a contract", + placeholders=("contract", "client", "date"), + values=lambda c: {"contract": c.title, "client": _client_name(c.client)}, + client_id=lambda c: c.client_id, + status=lambda c: c.status, + dates={ + "signed": DateField( + "is signed", + ("{contract} is to be signed on {date}", "{contract} was signed on {date}"), + get=lambda c, _: [c.signature_date], + is_open=_contract_open, + ), + "start": DateField( + "starts", + ("{contract} starts on {date}", "{contract} started on {date}"), + get=lambda c, _: [c.start_date], + is_open=_contract_open, + ), + "end": DateField( + "ends", + ("{contract} ends on {date}", "{contract} ended on {date}"), + get=lambda c, _: [c.end_date], + is_open=_contract_open, + ), + "period_end": DateField( + "ends a billing period", + ("Billing period of {contract} ends on {date}", "Invoice {contract} for the period ending {date}"), + get=_period_ends, + is_open=_contract_open, + repeats=True, + ), + }, + states={ + "Upcoming": State("is agreed", "Prepare the start of {contract}"), + "Completed": State("is completed", "Wrap up {contract} with {client}", final=True), + }, + ), + "project": Record( + model=Project, + label="a project", + placeholders=("project", "contract", "client", "date"), + values=lambda p: { + "project": p.title, + "contract": p.contract.title if p.contract else "", + "client": _client_name(p.client), + }, + client_id=lambda p: p.contract.client_id if p.contract else None, + status=lambda p: p.board_stage(), + dates={ + "start": DateField( + "starts", + ("Project {project} starts on {date}", "Project {project} started on {date}"), + get=lambda p, _: [p.start_date], + is_open=_project_open, + ), + "end": DateField( + "ends", + ("Project {project} ends on {date}", "Project {project} ended on {date}"), + get=lambda p, _: [p.end_date], + is_open=_project_open, + ), + }, + states={ + "Lead": State("becomes a lead", "Follow up on the lead {project}"), + "Offer": State("becomes an offer", "Follow up on the offer for {project}"), + "Upcoming": State("is agreed", "Plan the start of {project}"), + "Completed": State("is completed", "Ask {client} for feedback on {project}", final=True), + }, + ), + "invoice": Record( + model=Invoice, + label="an invoice", + placeholders=("invoice", "client", "contract", "project", "date"), + values=lambda i: { + "invoice": i.number or "", + "client": _client_name(i.contract.client) if i.contract else "", + "contract": i.contract.title if i.contract else "", + "project": i.project.title if i.project else "", + }, + client_id=lambda i: i.contract.client_id if i.contract else None, + status=lambda i: i.status, + dates={ + "created": DateField( + "is created", + (None, "Send invoice {invoice} to {client}"), + get=lambda i, _: [i.date], + is_open=_invoice_unsent, + known_ahead=False, + lasting=True, + ), + "sent": DateField( + "is sent", + (None, "Check that {client} received invoice {invoice}"), + get=lambda i, _: [i.sent_date if i.sent else None], + is_open=_invoice_unpaid, + known_ahead=False, + lasting=True, + ), + "due": DateField( + "is due", + ("Invoice {invoice} to {client} is due on {date}", "Invoice {invoice} is overdue — send a reminder"), + get=lambda i, _: [i.effective_due_date], + is_open=_invoice_unpaid, + lasting=True, + ), + }, + states={ + "overdue": State("becomes overdue", "Invoice {invoice} is overdue — send a reminder"), + "paid": State("is paid", "Thank {client} for paying invoice {invoice}", final=True), + "cancelled": State("is cancelled", "Invoice {invoice} was cancelled — issue a new one", final=True), + }, + ), + "goal": Record( + model=FinancialGoal, + label="a financial goal", + placeholders=("goal", "date"), + values=lambda g: {"goal": g.title}, + client_id=lambda g: None, + status=lambda g: "reached" if g.is_reached else "open", + dates={ + "target": DateField( + "reaches its target date", + ("{goal} is due on {date}", "{goal} was due on {date}"), + get=lambda g, _: [g.target_date], + is_open=lambda g: not g.is_reached, + lasting=True, + ), + }, + states={"reached": State("is reached", "{goal} is reached", final=True)}, + ), +} + + +@dataclass(frozen=True) +class Event: + """Something a rule follows: a date on a record ("contract.end") or a status it enters ("project=Offer").""" + + key: str + record: str + label: str + titles: tuple[Optional[str], str] + date: Optional[DateField] = None + state: Optional[str] = None + + @property + def is_state(self) -> bool: + return self.state is not None + + @property + def after_only(self) -> bool: + """Known only once it has happened, so a task can only come after it.""" + return self.is_state or not self.date.known_ahead + + @property + def final(self) -> bool: + """A status records end up in, which a rule counts only from the day it's made.""" + return self.is_state and RECORDS[self.record].states[self.state].final + + def holds(self, obj) -> bool: + return RECORDS[self.record].status(obj) == self.state + + +def _derive_events() -> dict[str, Event]: + events = {} + for rk, record in RECORDS.items(): + for dk, f in record.dates.items(): + events[f"{rk}.{dk}"] = Event(f"{rk}.{dk}", rk, f.label, f.titles, date=f) + for status, st in record.states.items(): + events[f"{rk}={status}"] = Event(f"{rk}={status}", rk, st.label, (None, st.title), state=status) + return events + + +EVENTS = _derive_events() + + +def rule_options() -> dict: + """What the rule composer offers: each kind of record and the events it has.""" + return { + "records": [ + { + "key": rk, + "label": record.label, + "placeholders": list(record.placeholders), + "events": [ + { + "key": e.key, + "label": e.label, + "state": e.is_state, + "final": e.final, + "after_only": e.after_only, + "title_before": e.titles[0], + "title_after": e.titles[1], + } + for e in EVENTS.values() + if e.record == rk + ], + } + for rk, record in RECORDS.items() + ] + } + + +def validate_rule(rule: TaskRule) -> None: + """``TaskRule.validate_rule``, plus what only the event catalog knows. Raises ``ValueError``.""" + rule.validate_rule() + if rule.kind != "event": + return + event = EVENTS.get(rule.event or "") + if event is None: + raise ValueError("Choose what the task follows, such as a contract ending.") + if rule.before and event.after_only: + raise ValueError("Tuttle learns of this only once it has happened. Choose a time after it.") + if event.record == "goal": + rule.client_id = None + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +_PLACEHOLDER = re.compile(r"\{(\w+)\}") + + +def render(template: Optional[str], values: dict) -> Optional[str]: + """Fill ``{placeholders}``. Anything else, including unknown placeholders, stays as typed.""" + if not template: + return template + return _PLACEHOLDER.sub(lambda m: str(values[m.group(1)]) if m.group(1) in values else m.group(0), template) + + +def period_before(occurrence: datetime.date, rule: TaskRule) -> str: + """The period a scheduled task looks back on: the month before a monthly one, and so on.""" + if rule.unit == "week": + monday = occurrence - datetime.timedelta(days=occurrence.weekday() + 7) + return f"the week of {fmt_date(monday)}" + if rule.unit == "month" and rule.every == 12: + return str(occurrence.year - 1) + if rule.unit == "month" and rule.every == 6: + p = add_months(occurrence, -6, 1) + return f"H{1 if p.month <= 6 else 2} {p.year}" + if rule.unit == "month" and rule.every == 3: + p = add_months(occurrence, -3, 1) + return f"Q{(p.month - 1) // 3 + 1} {p.year}" + p = add_months(occurrence, -1, 1) + return f"{MONTHS[p.month - 1]} {p.year}" + + +# --------------------------------------------------------------------------- +# Evaluation +# --------------------------------------------------------------------------- + + +@dataclass +class Item: + """A task a rule wants, or knows it will want.""" + + key: str + title: str + note: Optional[str] + due_date: datetime.date # what the task is about: the event's date, or the scheduled day + trigger: datetime.date # the day the task appears + subject_type: Optional[str] = None + subject_id: Optional[int] = None + # False for a date the rule leaves alone: an existing task keeps its title, but none appears. + wanted: bool = True + + def is_due(self, today: datetime.date) -> bool: + return self.wanted and self.trigger <= today + + def is_upcoming(self, today: datetime.date) -> bool: + return self.wanted and self.trigger > today + + +@dataclass +class Evaluation: + # Tasks that should exist today (trigger reached), and known ones still to come. + items: list[Item] = field(default_factory=list) + # Subjects whose pending tasks are no longer worth doing. + settled: set[int] = field(default_factory=set) + # Every subject the rule covers; None for a rule on a date. + subjects: Optional[set[int]] = None + # A state rule: subjects it noticed today. + noticed: list[int] = field(default_factory=list) + next: Optional[Item] = None + + +def created_on(rule: TaskRule, today: datetime.date) -> datetime.date: + return rule.created_at.date() if rule.created_at else today + + +def occurrences(rule: TaskRule, until: datetime.date): + """The days a rule on a date falls on, up to ``until``.""" + k = 0 + while True: + if rule.unit == "week": + d = rule.start_date + datetime.timedelta(weeks=k * rule.every) + elif rule.unit == "month": + d = add_months(rule.start_date, k * rule.every, rule.start_date.day) + else: + d = rule.start_date if k == 0 else None + if d is None or d > until: + return + yield d + k += 1 + + +def _evaluate_date(rule: TaskRule, today: datetime.date) -> Evaluation: + ev = Evaluation() + start = created_on(rule, today) + latest = None + for d in occurrences(rule, add_months(today, 25)): + if d < start: + continue + if d > today: + ev.next = _scheduled_item(rule, d) + break + latest = d + if latest: + ev.items.append(_scheduled_item(rule, latest)) + return ev + + +def _scheduled_item(rule: TaskRule, d: datetime.date) -> Item: + values = {"period": period_before(d, rule), "date": fmt_date(d)} + return Item( + key=f"rule:{rule.id}:{d.isoformat()}", + title=render(rule.title, values), + note=render(rule.note, values), + due_date=d, + trigger=d, + ) + + +def is_known(rule: TaskRule) -> bool: + """Whether Tuttle can follow the rule: a date, or an event it knows (a newer version may know more).""" + return rule.kind == "date" or rule.event in EVENTS + + +class Records: + """Each kind of record, loaded once and shared by every rule that looks at it.""" + + def __init__(self, session: Session): + self.session = session + self._loaded: dict[type, list] = {} + + def of(self, model: type) -> list: + if model not in self._loaded: + self._loaded[model] = self.session.exec(select(model)).all() + return self._loaded[model] + + +def evaluate( + rule: TaskRule, + session: Session, + today: datetime.date, + notices: Optional[dict[int, TaskRuleNotice]] = None, + records: Optional[Records] = None, +) -> Evaluation: + """What ``rule`` asks for on ``today``. + + ``notices`` are a state rule's notes, by subject id. Without them the rule + is taken as made today: a final status counts only from tomorrow's changes, + and records in an open one count as noticed today. + Pass ``records`` to share loaded records between rules. + """ + if rule.kind == "date": + return _evaluate_date(rule, today) + if not is_known(rule): + return Evaluation() + event = EVENTS[rule.event] + record = RECORDS[event.record] + records = records or Records(session) + objs = [o for o in records.of(record.model) if not rule.client_id or record.client_id(o) == rule.client_id] + ev = Evaluation(subjects={o.id for o in objs}) + if event.is_state: + _evaluate_state(rule, event, record, objs, today, notices, ev) + else: + _evaluate_dates(rule, event, record, objs, today, ev) + upcoming = [i for i in ev.items if i.is_upcoming(today)] + if upcoming: + ev.next = min(upcoming, key=lambda i: i.trigger) + return ev + + +def _item(rule, event, record, obj, d, trigger, key_suffix="", wanted=True) -> Item: + values = {**record.values(obj), "date": fmt_date(d)} + return Item( + key=f"rule:{rule.id}:{event.record}:{obj.id}{key_suffix}", + title=render(rule.title, values), + note=render(rule.note, values), + due_date=d, + trigger=trigger, + subject_type=event.record, + subject_id=obj.id, + wanted=wanted, + ) + + +def _evaluate_dates(rule, event, record, objs, today, ev: Evaluation) -> None: + field_ = event.date + start = created_on(rule, today) + offset = -rule.offset if rule.before else rule.offset + # A task after a contract or project date is a follow-up: it stays, however the + # contract or project goes on. Before it, or on the day, it's a heads-up. + guarded = field_.lasting or rule.before or rule.offset == 0 + horizon = shift(today + datetime.timedelta(days=400), rule.offset, rule.offset_unit) + + def creatable(d: datetime.date) -> bool: + # Contract and project dates count from the day the rule was made; + # a heads-up for a day that has passed is pointless. + return (field_.lasting or d >= start) and not (rule.before and d < today) + + for obj in objs: + if guarded and not field_.is_open(obj): + ev.settled.add(obj.id) + continue + dates = [d for d in field_.get(obj, horizon) if d] + if not field_.repeats: + for d in dates: + trigger = shift(d, offset, rule.offset_unit) + ev.items.append(_item(rule, event, record, obj, d, trigger, wanted=creatable(d))) + continue + # A billing period recurs: the latest one due gets a task, older ones keep theirs. + due, upcoming = None, None + for d in dates: + if not creatable(d): + continue + trigger = shift(d, offset, rule.offset_unit) + if trigger <= today: + due = (d, trigger) + elif upcoming is None or trigger < upcoming[1]: + upcoming = (d, trigger) + for found in (due, upcoming): + if found: + ev.items.append(_item(rule, event, record, obj, *found, key_suffix=f":{found[0].isoformat()}")) + + +def _evaluate_state(rule, event, record, objs, today, notices, ev: Evaluation) -> None: + for obj in objs: + if not event.holds(obj): + ev.settled.add(obj.id) + continue + if notices is None and event.final: + continue + notice = (notices or {}).get(obj.id) + if notice is None: + ev.noticed.append(obj.id) + noticed_on = today + elif notice.already: + continue + else: + noticed_on = notice.noticed_on + trigger = shift(noticed_on, rule.offset, rule.offset_unit) + ev.items.append(_item(rule, event, record, obj, noticed_on, trigger, key_suffix=f":{noticed_on.isoformat()}")) + + +def start_watching(session: Session, rule: TaskRule, today: datetime.date) -> None: + """Start a state rule afresh. For a final status, note every record already in it, so only later ones get a task.""" + for notice in session.exec(select(TaskRuleNotice).where(TaskRuleNotice.rule_id == rule.id)).all(): + session.delete(notice) + session.flush() + if not rule.follows_state or not is_known(rule) or not EVENTS[rule.event].final: + return + event = EVENTS[rule.event] + record = RECORDS[event.record] + for obj in session.exec(select(record.model)).all(): + if rule.client_id and record.client_id(obj) != rule.client_id: + continue + if event.holds(obj): + session.add(TaskRuleNotice(rule_id=rule.id, subject_id=obj.id, noticed_on=today, already=True)) diff --git a/tuttle/demo.py b/tuttle/demo.py index b9e7115a..568ec487 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -12,7 +12,7 @@ import numpy from loguru import logger from pandas import DataFrame -from sqlmodel import Session, create_engine +from sqlmodel import Session, create_engine, select from tuttle import invoicing, rendering, timetracking from tuttle.calendar import ICSCalendar @@ -36,6 +36,8 @@ PaymentMilestone, Project, RecurringExpense, + TaskRule, + TaskRuleNotice, TaxCategory, Timesheet, TimeTrackingItem, @@ -1260,6 +1262,82 @@ def create_demo_manual_entries(today: date) -> List[TimeTrackingItem]: ] +def create_demo_task_rules(session: Session, today: datetime.date) -> None: + """Harry's own task rules, besides the two every user starts with. + + One of each kind: repeating and one-off dates, a status change that has + already produced a task, one that is only watching, a rule for one client, + and a paused one. + """ + + def made(days_ago: int) -> datetime.datetime: + return datetime.datetime.combine(today - timedelta(days=days_ago), datetime.time(9)) + + first_of_month = today.replace(day=1) + renewal = date(today.year if today.month < 12 else today.year + 1, 12, 1) + rules = [ + TaskRule( + title="Send invoices for {period}", + kind="date", + start_date=first_of_month, + unit="month", + created_at=made((today - first_of_month).days + 1), + ), + TaskRule( + title="File the VAT return for {period}", + note="Due by the 10th of the month after the quarter.", + kind="date", + start_date=date(today.year, 1, 10), + unit="month", + every=3, + created_at=made((today - date(today.year, 1, 1)).days), + ), + TaskRule( + title="Renew the professional liability insurance", + note="The policy runs out at the end of the year.", + kind="date", + start_date=renewal, + created_at=made(20), + ), + TaskRule( + title="Follow up on the offer for {project}", + kind="event", + event="project=Offer", + offset=1, + offset_unit="week", + created_at=made(30), + ), + TaskRule( + title="Ask {client} for feedback on {project}", + kind="event", + event="project=Completed", + created_at=made(30), + ), + TaskRule( + title="Invoice {contract} for the period ending {date}", + kind="event", + event="contract.period_end", + paused=True, + created_at=made(30), + ), + ] + central_services = session.exec(select(Client).where(Client.name == "Central Services")).first() + if central_services: + rules[4].client_id = central_services.id + for rule in rules: + session.add(rule) + session.flush() + + # The offer went out nine days ago, so its follow-up is due. Projects + # already completed when the feedback rule was made get no task. + for project in session.exec(select(Project)).all(): + stage = project.board_stage(today) + if stage == "Offer": + session.add(TaskRuleNotice(rule_id=rules[3].id, subject_id=project.id, noticed_on=today - timedelta(days=9))) + if stage == "Completed" and (not central_services or project.client and project.client.id == central_services.id): + session.add(TaskRuleNotice(rule_id=rules[4].id, subject_id=project.id, noticed_on=today, already=True)) + + def _render_demo_documents(user: User, invoice: Invoice, language: str, template: str): """Render *invoice* and its timesheet to PDF, in the client's language if it has one.""" language = invoice.contract.client.language or language @@ -1333,5 +1411,10 @@ def install_demo_data( _render_demo_documents(user, invoice, invoice_language, invoice_template) session.commit() + logger.info("Adding task rules...") + with Session(db_engine) as session: + create_demo_task_rules(session, today) + session.commit() + db_engine.dispose() logger.info("Demo data installed.") diff --git a/tuttle/migrations/versions/d9313ebf3426_task_rules.py b/tuttle/migrations/versions/d9313ebf3426_task_rules.py new file mode 100644 index 00000000..c95566dd --- /dev/null +++ b/tuttle/migrations/versions/d9313ebf3426_task_rules.py @@ -0,0 +1,173 @@ +"""task rules + +Revision ID: d9313ebf3426 +Revises: 100b18f437fb +Create Date: 2026-10-15 21:36:57.003911 + +====================================================================== +FROZEN HISTORICAL SNAPSHOT — NOT THE SCHEMA SOURCE OF TRUTH. + +The source of truth is tuttle/model.py. This file captures the schema +DELTA from the previous revision to this point in history. It is +APPEND-ONLY: once committed, never edit it. To change the schema, edit +tuttle/model.py and run `just migrate ""` to ADD a new revision. + +Reading this file to learn the current schema is a MISTAKE — it is a +point-in-time snapshot. Read tuttle/model.py instead. +====================================================================== + +MANDATORY REVIEW CHECKLIST before committing this file: + +1. RENAMES — autogenerate emits drop_column + add_column for renames, + which DESTROYS DATA. If you intended a rename, replace the pair with + op.alter_column(, , new_column_name=). + +2. NO MODEL IMPORTS — never `from tuttle.model import ...` here. + Model classes drift over time; this script must be pinned to the + schema at this point in history. For data transformations, declare + a local sa.table(...) snapshot with only the columns this revision + touches. + +3. BATCH MODE — render_as_batch=True rebuilds tables for SQLite. After + a batch op on a table with foreign keys, verify integrity inside the + migration: op.execute("PRAGMA foreign_key_check"). + +See tuttle/migrations/README.md. +---------------------------------------------------------------------- +""" + +# pyright: reportAttributeAccessIssue=false +# sqlmodel.sql.sqltypes is a submodule resolved at runtime; basedpyright +# does not statically expose `sql` as an attribute of `sqlmodel`. +import datetime +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +import sqlmodel.sql.sqltypes # noqa: F401 — ensures runtime resolution of AutoString +from alembic import op + +revision: str = "d9313ebf3426" +down_revision: Union[str, Sequence[str], None] = "100b18f437fb" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # SQLite commits CREATE TABLE as it runs, so a run of this revision that + # failed halfway leaves its new tables, and Alembic's half-built copy of + # task, behind without recording the revision. Those can only be this + # revision's leftovers, and hold nothing yet: clear them and start over. + leftovers = set(sa.inspect(op.get_bind()).get_table_names()) + for table in ("_alembic_tmp_task", "taskrulenotice", "taskrule"): + if table in leftovers: + op.drop_table(table) + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "taskrule", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("note", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("kind", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("start_date", sa.Date(), nullable=True), + sa.Column("every", sa.Integer(), nullable=False), + sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("event", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("offset", sa.Integer(), nullable=False), + sa.Column("offset_unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("before", sa.Boolean(), nullable=False), + sa.Column("client_id", sa.Integer(), nullable=True), + sa.Column("paused", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["client_id"], ["client.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "taskrulenotice", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("rule_id", sa.Integer(), nullable=False), + sa.Column("subject_id", sa.Integer(), nullable=False), + sa.Column("noticed_on", sa.Date(), nullable=False), + sa.Column("already", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["rule_id"], ["taskrule.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("rule_id", "subject_id"), + ) + with op.batch_alter_table("task", schema=None) as batch_op: + batch_op.add_column(sa.Column("rule_id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("due_date", sa.Date(), nullable=True)) + batch_op.add_column(sa.Column("subject_type", sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column("subject_id", sa.Integer(), nullable=True)) + batch_op.create_foreign_key("fk_task_rule_id", "taskrule", ["rule_id"], ["id"], ondelete="CASCADE") + + # ### end Alembic commands ### + + # Two rules everyone starts with, to edit, pause or delete: the + # overdue-invoice task Tuttle has always made, and a heads-up before a + # contract ends. + taskrule = sa.table( + "taskrule", + sa.column("id", sa.Integer), + sa.column("title", sa.String), + sa.column("kind", sa.String), + sa.column("every", sa.Integer), + sa.column("event", sa.String), + sa.column("offset", sa.Integer), + sa.column("offset_unit", sa.String), + sa.column("before", sa.Boolean), + sa.column("paused", sa.Boolean), + sa.column("created_at", sa.DateTime), + ) + conn = op.get_bind() + common = dict(kind="event", every=1, offset_unit="day", paused=False, created_at=datetime.datetime.now()) + conn.execute( + taskrule.insert().values( + title="Invoice {invoice} is overdue — send a reminder", + event="invoice.due", + offset=1, + before=False, + **common, + ) + ) + conn.execute( + taskrule.insert().values( + title="{contract} ends on {date}", + event="contract.end", + offset=30, + before=True, + **common, + ) + ) + + overdue_id = conn.execute(sa.text("SELECT id FROM taskrule WHERE event = 'invoice.due'")).scalar_one() + + # Existing overdue tasks become the overdue rule's, so one already done + # or dismissed stays that way. + conn.execute( + sa.text( + "UPDATE task SET rule_id = :rule_id, subject_type = 'invoice', " + "subject_id = CAST(substr(key, 9) AS INTEGER), " + "key = 'rule:' || :rule_id || ':invoice:' || substr(key, 9) " + "WHERE key LIKE 'overdue:%'" + ), + {"rule_id": overdue_id}, + ) + + op.execute("PRAGMA foreign_key_check") + + +def downgrade() -> None: + """Downgrades are not supported. + + Tuttle is a single-user desktop app. Rolling back schema is destructive + (data in dropped columns is lost) and offers nothing over restoring a + timestamped backup from ensure_schema()'s pre-upgrade snapshot. + + If you need to iterate on a migration during development: + 1. Delete this revision file (versions/d9313ebf3426_*.py) + 2. Run `just reset` to wipe ~/.tuttle + 3. Edit model.py, run `just migrate` again + """ + raise NotImplementedError("Downgrades are not supported. Restore from a .bak- snapshot instead.") diff --git a/tuttle/model.py b/tuttle/model.py index 3e8fe12c..395a417c 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -1863,6 +1863,91 @@ class RecurringExpense(SQLModel, table=True): ) +TASK_RULE_REPEAT_UNITS = ("week", "month") +TASK_RULE_OFFSET_UNITS = ("day", "week", "month") + + +class TaskRule(RpcMixin, SQLModel, table=True): + """A rule the user composed that creates tasks. + + Either on a date, once or repeating ("every month from Nov 1"), or around + something that happens to a contract, project, invoice or financial goal + ("30 days before a contract ends", "when a project becomes an offer"). + """ + + __rpc_computed__ = ("follows_state",) + + id: Optional[int] = Field(default=None, primary_key=True) + title: str = Field( + description="Title of the tasks it creates. May name placeholders such as {contract}, {date} or {period}.", + ) + note: Optional[str] = Field(default=None, description="Shown under each task it creates.") + kind: str = Field(default="date", description="date | event") + # On a date + start_date: Optional[datetime.date] = Field( + default=None, + description="The day of the first task; repeats count from it.", + ) + every: int = Field(default=1, description="Repeat every this many units: every 3 months is quarterly.") + unit: Optional[str] = Field(default=None, description="week | month, or None for a one-off task.") + # When something happens + event: Optional[str] = Field( + default=None, + description="A date on a record, such as 'contract.end', or a status it enters, such as 'project=Offer'. " + "The events are listed in tuttle/app/tasks/rules.py.", + ) + offset: int = Field(default=0, description="How long before or after the event, in offset units.") + offset_unit: str = Field(default="day", description="day | week | month") + before: bool = Field(default=False, description="Whether the task comes before the event rather than after it.") + client_id: Optional[int] = Field( + default=None, + foreign_key="client.id", + ondelete="CASCADE", + description="Only for this client's contracts, projects and invoices. None means every client.", + ) + paused: bool = Field(default=False) + created_at: datetime.datetime = Field( + default_factory=datetime.datetime.now, + description="Contract and project dates before this day are left alone, and so are dates the rule has passed.", + ) + + @property + def follows_state(self) -> bool: + """Whether the rule follows a status change (a project becoming an offer) rather than a date.""" + return self.kind == "event" and "=" in (self.event or "") + + def validate_rule(self) -> None: + """Reject a rule that cannot create tasks. Raises ``ValueError`` with a message for the user.""" + self.title = (self.title or "").strip() + self.note = (self.note or "").strip() or None + if not self.title: + raise ValueError("Give the task a title.") + if self.kind == "date": + if self.start_date is None: + raise ValueError("Choose the date of the task.") + if self.unit is not None and self.unit not in TASK_RULE_REPEAT_UNITS: + raise ValueError("Choose how often the task repeats.") + if self.every < 1: + raise ValueError("Repeat the task at least once per period.") + self.event = None + self.client_id = None + elif self.kind == "event": + if not self.event: + raise ValueError("Choose what the task follows, such as a contract ending.") + if self.offset_unit not in TASK_RULE_OFFSET_UNITS: + raise ValueError("Choose days, weeks or months.") + if self.offset < 0: + raise ValueError(f"Enter how many {self.offset_unit}s before or after, as 0 or more.") + if self.offset == 0: + self.before = False + if self.before and self.follows_state: + raise ValueError("Tuttle learns of this only once it has happened. Choose a time after it.") + self.start_date = None + self.unit = None + else: + raise ValueError("Choose whether the task comes on a date or when something happens.") + + class Task(RpcMixin, SQLModel, table=True): """A user-facing action item (tutorial step or business task).""" @@ -1878,3 +1963,33 @@ class Task(RpcMixin, SQLModel, table=True): description="pending | done | dismissed", ) created_at: datetime.datetime = Field(default_factory=datetime.datetime.now) + rule_id: Optional[int] = Field( + default=None, + foreign_key="taskrule.id", + ondelete="CASCADE", + description="The rule that created this task, if any.", + ) + due_date: Optional[datetime.date] = Field( + default=None, + description="The date the task is about: the event's date, or the day a scheduled task falls on.", + ) + subject_type: Optional[str] = Field(default=None, description="contract | project | invoice | goal") + subject_id: Optional[int] = Field(default=None, description="Id of the contract, project, invoice or goal.") + + +class TaskRuleNotice(SQLModel, table=True): + """The day a task rule first saw a state hold, such as a project becoming an offer. + + State changes carry no date of their own, so the rule counts from this day. + """ + + __table_args__ = (sqlalchemy.UniqueConstraint("rule_id", "subject_id"),) + + id: Optional[int] = Field(default=None, primary_key=True) + rule_id: int = Field(foreign_key="taskrule.id", ondelete="CASCADE") + subject_id: int = Field(description="Id of the contract, project or invoice.") + noticed_on: datetime.date + already: bool = Field( + default=False, + description="The state already held when the rule was made or resumed, so it gets no task.", + ) diff --git a/tuttle_tests/conftest.py b/tuttle_tests/conftest.py index e96bafa9..d6d39eb1 100644 --- a/tuttle_tests/conftest.py +++ b/tuttle_tests/conftest.py @@ -9,7 +9,11 @@ from pypdf import PdfWriter import tuttle +import tuttle.app.core.abstractions as abstractions +import tuttle.app_db as app_db_mod from tuttle import fx, rendering +from tuttle.app.core.dispatch import _intents, dispatch +from tuttle.app.core.rpc_utils import reset_all from tuttle.model import Address, BankAccount, Client, Contact, Contract, Project, User # Every non-EUR amount in the suite converts at this rate. A fixed number keeps @@ -229,3 +233,27 @@ def demo_calendar_timetracking(): timetracking_calendar_path = Path("tuttle_tests/data/TuttleDemo-TimeTracking.ics") cal = tuttle.calendar.ICSCalendar(path=timetracking_calendar_path, name="TimeTracking") return cal + + +@pytest.fixture(scope="module") +def harry_tuttle_db(tmp_path_factory): + """Harry Tuttle's demo data in a fresh data directory, as the active user. Yields his database's path.""" + tmp = tmp_path_factory.mktemp("harry_tuttle") + orig_app_init = app_db_mod.AppDatabase.__init__ + + def _patched_init(self, app_dir=None): + orig_app_init(self, app_dir=tmp) + + previous_db = abstractions._active_db_path + app_db_mod.AppDatabase.__init__ = _patched_init + abstractions._active_db_path = tmp / "tuttle.db" + try: + assert dispatch("db.ensure", {})["ok"] + assert dispatch("users.ensure_demo", {})["ok"] + assert dispatch("users.switch", {"db_file": "harry-tuttle.db"})["ok"] + yield abstractions.get_active_db() + finally: + app_db_mod.AppDatabase.__init__ = orig_app_init + abstractions._active_db_path = previous_db + reset_all() + _intents.clear() diff --git a/tuttle_tests/test_migrations.py b/tuttle_tests/test_migrations.py index 4ea32271..5b3798ed 100644 --- a/tuttle_tests/test_migrations.py +++ b/tuttle_tests/test_migrations.py @@ -675,3 +675,78 @@ def test_project_without_contract_is_removed_and_reported(tmp_db: tuple[Path, st assert con.execute("SELECT project_id FROM invoice WHERE id = 1").fetchone() == (None,) finally: con.close() + + +# -- Task rules (revision d9313ebf3426) ---------------------------------------- + +_BEFORE_TASK_RULES = "100b18f437fb" +_TASK_RULES_REVISION = "d9313ebf3426" + + +def test_task_rules_start_with_overdue_and_contract_end(tmp_db: tuple[Path, str]) -> None: + """Every user starts with the overdue-invoice rule and a heads-up before a + contract ends. Overdue tasks made before rules existed become the overdue + rule's, keeping their status, so a dismissed one stays dismissed.""" + db, url = tmp_db + cfg = _alembic_config_for(url) + command.upgrade(cfg, _BEFORE_TASK_RULES) + + con = sqlite3.connect(db) + cur = con.cursor() + _insert(cur, "task", id=1, key="overdue:7", title="Invoice 2026-007 is overdue — send a reminder", status="dismissed") + _insert(cur, "task", id=2, key="tutorial:first_contact", title="Add your first contact", status="done") + con.commit() + con.close() + + command.upgrade(cfg, _TASK_RULES_REVISION) + con = sqlite3.connect(db) + try: + rules = con.execute( + 'SELECT id, kind, event, "offset", offset_unit, before, paused FROM taskrule ORDER BY id' + ).fetchall() + assert [r[1:] for r in rules] == [ + ("event", "invoice.due", 1, "day", 0, 0), + ("event", "contract.end", 30, "day", 1, 0), + ] + overdue_id = rules[0][0] + assert con.execute("SELECT key, status, rule_id, subject_type, subject_id FROM task WHERE id = 1").fetchone() == ( + f"rule:{overdue_id}:invoice:7", + "dismissed", + overdue_id, + "invoice", + 7, + ) + assert con.execute("SELECT key, rule_id FROM task WHERE id = 2").fetchone() == ("tutorial:first_contact", None) + finally: + con.close() + + +def test_task_rules_start_over_after_a_run_that_failed_halfway(tmp_db: tuple[Path, str]) -> None: + """SQLite commits CREATE TABLE as it runs: a run that failed after creating the + new tables, and Alembic's copy of task, left them behind without recording the + revision. The next run must clear them rather than fail on "already exists".""" + db, url = tmp_db + cfg = _alembic_config_for(url) + command.upgrade(cfg, _BEFORE_TASK_RULES) + + con = sqlite3.connect(db) + cur = con.cursor() + _insert(cur, "task", id=1, key="overdue:7", title="Invoice 2026-007 is overdue — send a reminder", status="done") + # What the interrupted run had committed: empty tables, and the temp copy of task. + cur.execute("CREATE TABLE taskrule (id INTEGER NOT NULL PRIMARY KEY, title VARCHAR NOT NULL)") + cur.execute("CREATE TABLE taskrulenotice (id INTEGER NOT NULL PRIMARY KEY, rule_id INTEGER NOT NULL)") + cur.execute("CREATE TABLE _alembic_tmp_task (id INTEGER NOT NULL PRIMARY KEY, key VARCHAR NOT NULL)") + con.commit() + con.close() + + assert ensure_schema(url) == [] + + assert _get_current_revision(url) == _heads(url)[1] + con = sqlite3.connect(db) + try: + tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + assert "_alembic_tmp_task" not in tables + assert con.execute("SELECT COUNT(*) FROM taskrule").fetchone() == (2,) + assert con.execute("SELECT status, subject_id FROM task WHERE id = 1").fetchone() == ("done", 7) + finally: + con.close() diff --git a/tuttle_tests/test_task_rules.py b/tuttle_tests/test_task_rules.py new file mode 100644 index 00000000..13106ff4 --- /dev/null +++ b/tuttle_tests/test_task_rules.py @@ -0,0 +1,874 @@ +"""Tests for task rules (tuttle/app/tasks/rules.py) and the tasks the generator makes from them.""" + +import datetime +from decimal import Decimal + +import pytest +import sqlmodel +from sqlmodel import select + +from tuttle.app.tasks.generator import generate_tasks +from tuttle.app.tasks.rules import ( + EVENTS, + RECORDS, + add_months, + evaluate, + occurrences, + period_before, + render, + rule_options, + shift, + start_watching, + validate_rule, +) +from tuttle.model import ( + Client, + Contract, + FinancialGoal, + Invoice, + InvoiceItem, + Project, + Task, + TaskRule, + TaskRuleNotice, +) +from tuttle.time import Cycle, TimeUnit + +# Statuses (an invoice being overdue, a project's board stage) are read off the +# real calendar, so the tests build their records around the real today. +TODAY = datetime.date.today() + + +def days(n: int) -> datetime.date: + return TODAY + datetime.timedelta(days=n) + + +def made(n_days_ago: int) -> datetime.datetime: + return datetime.datetime.combine(days(-n_days_ago), datetime.time(9)) + + +@pytest.fixture +def session(): + engine = sqlmodel.create_engine("sqlite://", echo=False) + sqlmodel.SQLModel.metadata.create_all(engine) + with sqlmodel.Session(engine) as s: + yield s + + +# --------------------------------------------------------------------------- +# Factories +# --------------------------------------------------------------------------- + + +def client(session, name="Acme"): + c = Client(name=name) + session.add(c) + session.commit() + return c + + +def contract(session, cl, title="Retainer", start=None, end=None, cycle=Cycle.monthly, completed=False, signed=None): + c = Contract( + title=title, + client=cl, + rate=Decimal("100"), + currency="EUR", + VAT_rate=Decimal("0.19"), + unit=TimeUnit.hour, + units_per_workday=8, + term_of_payment=14, + billing_cycle=cycle, + start_date=start or days(-100), + end_date=end, + is_completed=completed, + signature_date=signed, + ) + session.add(c) + session.commit() + return c + + +def project(session, ct, title="Build", start=None, end=None, stage=None): + p = Project( + title=title, + tag="#" + title.lower().replace(" ", ""), + description=title, + contract=ct, + start_date=start or days(-50), + end_date=end, + stage=stage, + ) + session.add(p) + session.commit() + return p + + +def invoice(session, ct, number="2026-001", date=None, sent=True, paid=False, cancelled=False, sent_date=None): + """An invoice dated ``date``, due 14 days later (the contract's terms).""" + inv = Invoice( + number=number, + date=date or days(-20), + contract=ct, + sent=sent, + sent_date=sent_date if sent_date is not None else (date or days(-20)) if sent else None, + paid=paid, + cancelled=cancelled, + items=[InvoiceItem(quantity=1, unit="h", unit_price=Decimal("100"), description="Work", VAT_rate=Decimal("0.19"))], + ) + session.add(inv) + session.commit() + return inv + + +def rule(session, created=None, **fields): + """A rule saved the way the composer saves it: validated, and watching from the day it's made.""" + fields.setdefault("title", "Task about {contract}") + fields.setdefault("kind", "event") + r = TaskRule(created_at=created or made(365), **fields) + validate_rule(r) + session.add(r) + session.flush() + start_watching(session, r, r.created_at.date()) + session.commit() + return r + + +def tasks_of(session, r, status=None): + query = select(Task).where(Task.rule_id == r.id) + if status: + query = query.where(Task.status == status) + return session.exec(query.order_by(Task.due_date)).all() + + +# --------------------------------------------------------------------------- +# Dates and text +# --------------------------------------------------------------------------- + + +class TestDates: + def test_add_months_keeps_within_the_month(self): + assert add_months(datetime.date(2026, 1, 31), 1) == datetime.date(2026, 2, 28) + assert add_months(datetime.date(2028, 1, 31), 1) == datetime.date(2028, 2, 29) + assert add_months(datetime.date(2026, 11, 15), 3) == datetime.date(2027, 2, 15) + assert add_months(datetime.date(2026, 3, 31), -1) == datetime.date(2026, 2, 28) + + def test_add_months_returns_to_the_anchor_day(self): + assert add_months(datetime.date(2026, 2, 28), 1, day=31) == datetime.date(2026, 3, 31) + + def test_shift(self): + d = datetime.date(2026, 10, 15) + assert shift(d, -30, "day") == datetime.date(2026, 9, 15) + assert shift(d, 2, "week") == datetime.date(2026, 10, 29) + assert shift(d, -1, "month") == datetime.date(2026, 9, 15) + + +class TestRender: + def test_fills_placeholders(self): + assert render("{contract} ends on {date}", {"contract": "Retainer", "date": "Nov 6, 2026"}) == ( + "Retainer ends on Nov 6, 2026" + ) + + def test_keeps_unknown_placeholders(self): + assert render("Send {invoice} for {period}", {"invoice": "2026-001"}) == "Send 2026-001 for {period}" + + @pytest.mark.parametrize("template", ["Pay { now", "Odd {0} index", "{contract.title}", "close } brace"]) + def test_keeps_text_that_is_not_a_template(self, template): + assert render(template, {"contract": "Retainer"}) == template + + def test_none_and_empty(self): + assert render(None, {}) is None + assert render("", {}) == "" + + +class TestSchedules: + def _rule(self, start, unit=None, every=1): + return TaskRule(title="t", kind="date", start_date=start, unit=unit, every=every) + + def test_once(self): + r = self._rule(datetime.date(2026, 12, 1)) + assert list(occurrences(r, datetime.date(2027, 12, 31))) == [datetime.date(2026, 12, 1)] + assert list(occurrences(r, datetime.date(2026, 11, 30))) == [] + + def test_every_two_weeks(self): + r = self._rule(datetime.date(2026, 10, 5), "week", 2) + assert list(occurrences(r, datetime.date(2026, 11, 3))) == [ + datetime.date(2026, 10, 5), + datetime.date(2026, 10, 19), + datetime.date(2026, 11, 2), + ] + + def test_monthly_from_the_31st_keeps_to_month_ends(self): + r = self._rule(datetime.date(2026, 1, 31), "month") + assert list(occurrences(r, datetime.date(2026, 4, 30))) == [ + datetime.date(2026, 1, 31), + datetime.date(2026, 2, 28), + datetime.date(2026, 3, 31), + datetime.date(2026, 4, 30), + ] + + def test_quarterly(self): + r = self._rule(datetime.date(2026, 1, 10), "month", 3) + assert list(occurrences(r, datetime.date(2026, 12, 31))) == [ + datetime.date(2026, 1, 10), + datetime.date(2026, 4, 10), + datetime.date(2026, 7, 10), + datetime.date(2026, 10, 10), + ] + + @pytest.mark.parametrize( + "unit, every, occurrence, expected", + [ + ("month", 1, datetime.date(2026, 11, 1), "October 2026"), + ("month", 1, datetime.date(2027, 1, 1), "December 2026"), + ("month", 3, datetime.date(2027, 1, 10), "Q4 2026"), + ("month", 3, datetime.date(2026, 10, 10), "Q3 2026"), + ("month", 6, datetime.date(2026, 7, 1), "H1 2026"), + ("month", 12, datetime.date(2027, 5, 31), "2026"), + ("week", 1, datetime.date(2026, 10, 15), "the week of Oct 5, 2026"), + (None, 1, datetime.date(2026, 12, 1), "November 2026"), + ], + ) + def test_period_before(self, unit, every, occurrence, expected): + assert period_before(occurrence, self._rule(occurrence, unit, every)) == expected + + +# --------------------------------------------------------------------------- +# The catalog +# --------------------------------------------------------------------------- + + +class TestCatalog: + def test_events_come_from_each_records_dates_and_statuses(self): + for rk, record in RECORDS.items(): + for dk in record.dates: + assert f"{rk}.{dk}" in EVENTS + for status in record.states: + assert f"{rk}={status}" in EVENTS + assert len(EVENTS) == sum(len(r.dates) + len(r.states) for r in RECORDS.values()) + + def test_statuses_a_rule_follows_are_ones_tuttle_shows(self): + assert set(RECORDS["project"].states) <= {"Lead", "Offer", "Upcoming", "Active", "Completed"} + assert set(RECORDS["contract"].states) <= {"Lead", "Offer", "Upcoming", "Active", "Completed"} + assert set(RECORDS["invoice"].states) <= {"draft", "sent", "overdue", "paid", "cancelled"} + + def test_only_anticipated_dates_allow_a_task_before(self): + after_only = {k for k, e in EVENTS.items() if e.after_only} + assert {"invoice.created", "invoice.sent"} <= after_only + assert all(k in after_only for k, e in EVENTS.items() if e.is_state) + assert "contract.end" not in after_only and "invoice.due" not in after_only + + def test_every_event_has_a_default_title_using_its_records_placeholders(self): + for e in EVENTS.values(): + placeholders = set(RECORDS[e.record].placeholders) + for title in filter(None, e.titles): + used = {part.split("}")[0] for part in title.split("{")[1:]} + assert used <= placeholders, (e.key, used - placeholders) + + def test_options_for_the_composer(self): + options = rule_options() + records = {r["key"]: r for r in options["records"]} + assert set(records) == {"contract", "project", "invoice", "goal"} + offer = next(e for e in records["project"]["events"] if e["key"] == "project=Offer") + assert offer == { + "key": "project=Offer", + "label": "becomes an offer", + "state": True, + "final": False, + "after_only": True, + "title_before": None, + "title_after": "Follow up on the offer for {project}", + } + + +class TestValidation: + @pytest.mark.parametrize( + "fields, message", + [ + ({"title": " ", "kind": "date", "start_date": TODAY}, "Give the task a title."), + ({"title": "t", "kind": "date"}, "Choose the date of the task."), + ({"title": "t", "kind": "date", "start_date": TODAY, "unit": "fortnight"}, "Choose how often"), + ({"title": "t", "kind": "date", "start_date": TODAY, "unit": "month", "every": 0}, "at least once"), + ({"title": "t", "kind": "event"}, "Choose what the task follows"), + ({"title": "t", "kind": "event", "event": "contract.vanishes"}, "Choose what the task follows"), + ({"title": "t", "kind": "event", "event": "contract.end", "offset": -3}, "as 0 or more"), + ({"title": "t", "kind": "event", "event": "contract.end", "offset_unit": "year"}, "days, weeks or months"), + ( + {"title": "t", "kind": "event", "event": "invoice.sent", "offset": 2, "before": True}, + "only once it has happened", + ), + ( + {"title": "t", "kind": "event", "event": "project=Offer", "offset": 2, "before": True}, + "only once it has happened", + ), + ({"title": "t", "kind": "sometimes"}, "on a date or when something happens"), + ], + ) + def test_rejects_with_a_message_for_the_user(self, fields, message): + with pytest.raises(ValueError, match=message): + validate_rule(TaskRule(**fields)) + + def test_tidies_fields_the_kind_does_not_use(self): + r = TaskRule(title=" Renew ", note=" ", kind="date", start_date=TODAY, event="contract.end", client_id=3) + validate_rule(r) + assert (r.title, r.note, r.event, r.client_id) == ("Renew", None, None, None) + r = TaskRule( + title="t", kind="event", event="goal.target", client_id=3, offset=0, before=True, start_date=TODAY, unit="month" + ) + validate_rule(r) + assert (r.client_id, r.before, r.start_date, r.unit) == (None, False, None, None) + + +# --------------------------------------------------------------------------- +# Rules on a date +# --------------------------------------------------------------------------- + + +class TestDateRules: + def test_monthly_task_for_the_latest_day_only(self, session): + # Made four months ago on the 1st: three or four 1sts have passed, one task. + start = add_months(TODAY, -4, 1) + r = rule( + session, + created=made((TODAY - start).days), + title="Send invoices for {period}", + kind="date", + start_date=start, + unit="month", + ) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + latest = TODAY.replace(day=1) + assert task.due_date == latest + assert task.title == f"Send invoices for {period_before(latest, r)}" + assert task.key == f"rule:{r.id}:{latest.isoformat()}" + + def test_next_month_adds_a_task_and_keeps_the_last(self, session): + r = rule( + session, + created=made(40), + title="Send invoices for {period}", + kind="date", + start_date=add_months(TODAY, -1, 1), + unit="month", + ) + generate_tasks(session, TODAY) + generate_tasks(session, add_months(TODAY, 1, 1)) + assert [t.status for t in tasks_of(session, r)] == ["pending", "pending"] + + def test_days_before_the_rule_was_made_are_left_alone(self, session): + r = rule(session, created=made(0), title="Call the tax office", kind="date", start_date=days(-10)) + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + assert evaluate(r, session, TODAY).next is None + + def test_once_today_and_once_later(self, session): + today_rule = rule(session, created=made(0), title="Today's thing", kind="date", start_date=TODAY) + later_rule = rule(session, created=made(0), title="Renew insurance", kind="date", start_date=days(45)) + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, today_rule)] == ["Today's thing"] + assert tasks_of(session, later_rule) == [] + assert evaluate(later_rule, session, TODAY).next.trigger == days(45) + generate_tasks(session, days(45)) + assert [t.due_date for t in tasks_of(session, later_rule)] == [days(45)] + + def test_idempotent_and_done_stays_done(self, session): + r = rule(session, created=made(0), title="Today's thing", kind="date", start_date=TODAY) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + task.status = "done" + session.add(task) + session.commit() + generate_tasks(session, TODAY) + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["done"] + + def test_paused_rule_makes_nothing(self, session): + r = rule(session, created=made(0), title="Today's thing", kind="date", start_date=TODAY, paused=True) + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + + def test_note_is_rendered_too(self, session): + r = rule( + session, + created=made(0), + title="VAT", + note="For {period}, by {date}", + kind="date", + start_date=TODAY, + unit="month", + every=3, + ) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert task.description == f"For {period_before(TODAY, r)}, by {TODAY:%b} {TODAY.day}, {TODAY.year}" + + +# --------------------------------------------------------------------------- +# Rules around a date on a record +# --------------------------------------------------------------------------- + + +class TestContractEnd: + def _rule(self, session, **kw): + return rule(session, title="{contract} ends on {date}", event="contract.end", offset=30, before=True, **kw) + + def test_heads_up_within_the_window(self, session): + ct = contract(session, client(session), title="Retainer", end=days(10)) + r = self._rule(session) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert task.title == f"Retainer ends on {days(10):%b} {days(10).day}, {days(10).year}" + assert (task.due_date, task.subject_type, task.subject_id) == (days(10), "contract", ct.id) + assert task.key == f"rule:{r.id}:contract:{ct.id}" + + def test_outside_the_window_it_waits(self, session): + contract(session, client(session), end=days(40)) + r = self._rule(session) + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + assert evaluate(r, session, TODAY).next.trigger == days(10) + + @pytest.mark.parametrize( + "kw", + [{"end": days(-5)}, {"end": days(10), "completed": True}, {"end": None}], + ids=["ended already", "completed", "open-ended"], + ) + def test_no_heads_up(self, session, kw): + contract(session, client(session), **kw) + r = self._rule(session) + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + + def test_closes_when_the_contract_is_completed(self, session): + ct = contract(session, client(session), end=days(10)) + r = self._rule(session) + generate_tasks(session, TODAY) + ct.is_completed = True + session.add(ct) + session.commit() + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["done"] + + def test_a_moved_end_date_moves_the_task(self, session): + ct = contract(session, client(session), title="Retainer", end=days(10)) + r = self._rule(session) + generate_tasks(session, TODAY) + ct.end_date = days(20) + session.add(ct) + session.commit() + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert task.due_date == days(20) + assert task.title.endswith(f"{days(20).day}, {days(20).year}") + + def test_an_extended_contract_drops_the_task_and_warns_again_later(self, session): + ct = contract(session, client(session), end=days(10)) + r = self._rule(session) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + task.status = "done" + session.add(task) + ct.end_date = days(200) + session.add(ct) + session.commit() + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + generate_tasks(session, days(175)) + assert [(t.status, t.due_date) for t in tasks_of(session, r)] == [("pending", days(200))] + + def test_a_task_from_before_due_dates_existed_is_adopted(self, session): + """Tasks moved over by the migration have no due date yet; they keep their status.""" + ct = contract(session, client(session), end=days(10)) + r = self._rule(session) + session.add( + Task( + key=f"rule:{r.id}:contract:{ct.id}", + title="old", + status="dismissed", + rule_id=r.id, + subject_type="contract", + subject_id=ct.id, + ) + ) + session.commit() + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert (task.status, task.due_date, task.title) == ("dismissed", days(10), "old") + + def test_a_deleted_contract_takes_its_pending_task(self, session): + ct = contract(session, client(session), end=days(10)) + r = self._rule(session) + generate_tasks(session, TODAY) + session.delete(ct) + session.commit() + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + + def test_renaming_the_contract_or_the_rule_updates_the_pending_title(self, session): + ct = contract(session, client(session), title="Retainer", end=days(10)) + r = self._rule(session) + generate_tasks(session, TODAY) + ct.title = "Big Retainer" + r.title = "Talk about extending {contract}" + session.add_all([ct, r]) + session.commit() + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["Talk about extending Big Retainer"] + + +class TestFollowUps: + """After a contract or project date, a task is a follow-up: it counts only dates after the rule was made.""" + + def test_dates_before_the_rule_was_made_are_left_alone(self, session): + contract(session, client(session), start=days(-10)) + r = rule(session, created=made(0), event="contract.start", offset=1, offset_unit="week") + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + + def test_a_later_date_gets_its_follow_up(self, session): + ct = contract(session, client(session), start=days(3)) + r = rule(session, created=made(0), event="contract.start", offset=1, offset_unit="week") + assert evaluate(r, session, TODAY).next.trigger == days(10) + generate_tasks(session, days(10)) + assert [t.subject_id for t in tasks_of(session, r)] == [ct.id] + + def test_a_follow_up_stays_when_the_contract_is_completed(self, session): + ct = contract(session, client(session), start=days(-200), end=days(-10)) + r = rule(session, created=made(30), title="Ask for a testimonial", event="contract.end", offset=1, offset_unit="week") + generate_tasks(session, TODAY) + ct.is_completed = True + session.add(ct) + session.commit() + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["pending"] + + def test_project_end_heads_up_closes_on_completion(self, session): + p = project(session, contract(session, client(session)), end=days(5)) + r = rule(session, title="{project} ends", event="project.end", offset=1, offset_unit="week", before=True) + generate_tasks(session, TODAY) + assert len(tasks_of(session, r, "pending")) == 1 + p.is_completed = True + session.add(p) + session.commit() + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["done"] + + +class TestInvoices: + def _overdue_rule(self, session, **kw): + return rule( + session, + title="Invoice {invoice} is overdue — send a reminder", + note="It was due on {date}.", + event="invoice.due", + offset=1, + **kw, + ) + + def test_overdue_invoice_gets_a_reminder_task(self, session): + inv = invoice(session, contract(session, client(session)), number="2026-007", date=days(-30)) + r = self._overdue_rule(session) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert task.title == "Invoice 2026-007 is overdue — send a reminder" + assert task.description == f"It was due on {inv.due_date:%b} {inv.due_date.day}, {inv.due_date.year}." + assert (task.subject_type, task.subject_id, task.due_date) == ("invoice", inv.id, inv.due_date) + + def test_open_invoices_from_before_the_rule_are_caught_up_on(self, session): + invoice(session, contract(session, client(session)), date=days(-400)) + r = self._overdue_rule(session, created=made(0)) + generate_tasks(session, TODAY) + assert len(tasks_of(session, r)) == 1 + + @pytest.mark.parametrize("kw", [{"sent": False}, {"paid": True}, {"cancelled": True}], ids=["draft", "paid", "cancelled"]) + def test_no_task_for_a_closed_or_unsent_invoice(self, session, kw): + invoice(session, contract(session, client(session)), date=days(-30), **kw) + r = self._overdue_rule(session) + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + + def test_payment_closes_the_task(self, session): + inv = invoice(session, contract(session, client(session)), date=days(-30)) + r = self._overdue_rule(session) + generate_tasks(session, TODAY) + inv.paid = True + session.add(inv) + session.commit() + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["done"] + + def test_a_reminder_takes_over_from_the_invoice(self, session): + ct = contract(session, client(session)) + inv = invoice(session, ct, number="2026-007", date=days(-60)) + r = self._overdue_rule(session) + generate_tasks(session, TODAY) + reminder = Invoice( + number="2026-007-M1", + date=days(-20), + contract=ct, + document_type="reminder", + reminder_for_id=inv.id, + reminder_level=1, + sent=True, + sent_date=days(-20), + items=[], + ) + session.add(reminder) + session.commit() + generate_tasks(session, TODAY) + by_subject = {t.subject_id: t for t in tasks_of(session, r)} + assert by_subject[inv.id].status == "done" + assert by_subject[reminder.id].status == "pending" + assert by_subject[reminder.id].title == "Invoice 2026-007-M1 is overdue — send a reminder" + + def test_heads_up_before_it_is_due(self, session): + ct = contract(session, client(session), title="Retainer") + invoice(session, ct, number="A", date=days(-12)) # due in 2 days + invoice(session, ct, number="B", date=days(-4)) # due in 10 days + r = rule(session, title="{invoice} due {date}", event="invoice.due", offset=3, before=True) + generate_tasks(session, TODAY) + assert [t.title.split()[0] for t in tasks_of(session, r)] == ["A"] + assert evaluate(r, session, TODAY).next.trigger == days(7) + + def test_unsent_invoice_reminder_closes_once_sent(self, session): + inv = invoice(session, contract(session, client(session)), date=days(-5), sent=False) + r = rule(session, title="Send invoice {invoice} to {client}", event="invoice.created", offset=3) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert task.title == "Send invoice 2026-001 to Acme" + inv.sent, inv.sent_date = True, TODAY + session.add(inv) + session.commit() + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["done"] + + def test_follow_up_after_sending(self, session): + ct = contract(session, client(session)) + invoice(session, ct, number="A", date=days(-10), sent_date=days(-10)) + invoice(session, ct, number="B", date=days(-3), sent_date=days(-3)) + r = rule(session, title="Check {invoice}", event="invoice.sent", offset=1, offset_unit="week") + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["Check A"] + + +class TestBillingPeriods: + def test_the_latest_period_end_gets_a_task(self, session): + ct = contract(session, client(session), title="Retainer", start=add_months(TODAY, -6, 1)) + r = rule( + session, created=made(45), title="Invoice {contract} for the period ending {date}", event="contract.period_end" + ) + generate_tasks(session, TODAY) + last_end = TODAY.replace(day=1) - datetime.timedelta(days=1) + (task,) = tasks_of(session, r) + assert task.due_date == last_end + assert task.key == f"rule:{r.id}:contract:{ct.id}:{last_end.isoformat()}" + next_end = add_months(TODAY.replace(day=1), 1) - datetime.timedelta(days=1) + assert evaluate(r, session, TODAY).next.trigger == next_end + + def test_each_period_keeps_its_own_task(self, session): + contract(session, client(session), start=add_months(TODAY, -6, 1)) + r = rule(session, created=made(45), event="contract.period_end") + generate_tasks(session, TODAY) + generate_tasks(session, add_months(TODAY, 1)) + assert len(tasks_of(session, r, "pending")) == 2 + + def test_the_last_period_ends_with_the_contract(self, session): + ct = contract(session, client(session), start=add_months(TODAY, -6, 1), end=days(5), cycle=Cycle.monthly) + r = rule(session, created=made(0), event="contract.period_end") + generate_tasks(session, days(5)) + assert [t.due_date for t in tasks_of(session, r)] == [ct.end_date] + assert evaluate(r, session, days(6)).next is None + + def test_weekly_cycle(self, session): + contract(session, client(session), start=days(-20), cycle=Cycle.weekly) + r = rule(session, created=made(30), event="contract.period_end") + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert task.due_date == days(-20) + datetime.timedelta(days=20) + + @pytest.mark.parametrize("cycle", [Cycle.hourly, Cycle.daily]) + def test_no_periods_for_hourly_or_daily_billing(self, session, cycle): + contract(session, client(session), start=days(-20), cycle=cycle) + r = rule(session, created=made(30), event="contract.period_end") + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + + +class TestScopeAndGoals: + def test_only_the_rules_client(self, session): + acme, umbrella = client(session, "Acme"), client(session, "Umbrella") + contract(session, acme, title="A", end=days(5)) + contract(session, umbrella, title="U", end=days(5)) + r = rule( + session, + title="{contract} for {client}", + event="contract.end", + offset=1, + offset_unit="week", + before=True, + client_id=umbrella.id, + ) + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["U for Umbrella"] + + def test_a_task_out_of_a_narrowed_scope_goes(self, session): + acme, umbrella = client(session, "Acme"), client(session, "Umbrella") + contract(session, acme, title="A", end=days(5)) + r = rule(session, event="contract.end", offset=1, offset_unit="week", before=True) + generate_tasks(session, TODAY) + assert len(tasks_of(session, r)) == 1 + r.client_id = umbrella.id + session.add(r) + session.commit() + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + + def test_goal_date(self, session): + goal = FinancialGoal(title="Van fund", target_amount=Decimal("1000"), target_date=days(20)) + session.add(goal) + session.commit() + r = rule(session, title="{goal} is due on {date}", event="goal.target", offset=1, offset_unit="month", before=True) + generate_tasks(session, TODAY) + (task,) = tasks_of(session, r) + assert task.title.startswith("Van fund is due on") + goal.is_reached = True + session.add(goal) + session.commit() + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["done"] + + def test_a_rule_tuttle_does_not_know_is_left_alone(self, session): + r = TaskRule(title="From a newer Tuttle", kind="event", event="invoice.disputed", created_at=made(1)) + session.add(r) + session.commit() + session.add(Task(key=f"rule:{r.id}:invoice:1", title="kept", rule_id=r.id, subject_type="invoice", subject_id=1)) + session.commit() + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["kept"] + + +# --------------------------------------------------------------------------- +# Rules on status changes +# --------------------------------------------------------------------------- + + +class TestStatusChanges: + def _offer_rule(self, session, offset=1, **kw): + return rule( + session, + created=made(0), + title="Follow up on the offer for {project}", + event="project=Offer", + offset=offset, + offset_unit="week", + **kw, + ) + + def test_offers_already_out_count(self, session): + """An offer is something to act on: one already out when the rule is made gets its follow-up too.""" + project(session, contract(session, client(session)), title="Old offer", stage="Offer") + r = self._offer_rule(session, offset=0) + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["Follow up on the offer for Old offer"] + + def test_projects_completed_before_the_rule_get_no_task(self, session): + """Completed is where projects end up: only ones completed from the day the rule is made count.""" + project(session, contract(session, client(session)), title="Old job", stage="Completed") + r = rule(session, created=made(0), title="{project}", event="project=Completed") + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + assert session.exec(select(TaskRuleNotice).where(TaskRuleNotice.rule_id == r.id)).one().already + + def test_a_new_offer_is_noticed_and_followed_up(self, session): + r = self._offer_rule(session) + p = project(session, contract(session, client(session)), title="Underfloor Heating", stage="Offer") + generate_tasks(session, TODAY) + assert tasks_of(session, r) == [] + notice = session.exec(select(TaskRuleNotice).where(TaskRuleNotice.rule_id == r.id)).one() + assert (notice.subject_id, notice.noticed_on, notice.already) == (p.id, TODAY, False) + generate_tasks(session, days(7)) + (task,) = tasks_of(session, r) + assert task.title == "Follow up on the offer for Underfloor Heating" + assert (task.due_date, task.key) == (TODAY, f"rule:{r.id}:project:{p.id}:{TODAY.isoformat()}") + + def test_as_soon_as(self, session): + r = self._offer_rule(session, offset=0) + project(session, contract(session, client(session)), stage="Offer") + generate_tasks(session, TODAY) + assert len(tasks_of(session, r, "pending")) == 1 + + def test_an_offer_won_before_the_follow_up_gets_none(self, session): + r = self._offer_rule(session) + p = project(session, contract(session, client(session)), stage="Offer") + generate_tasks(session, TODAY) + p.stage = "Upcoming" + session.add(p) + session.commit() + generate_tasks(session, days(1)) + generate_tasks(session, days(8)) + assert tasks_of(session, r) == [] + assert session.exec(select(TaskRuleNotice).where(TaskRuleNotice.rule_id == r.id)).all() == [] + + def test_an_offer_won_after_the_follow_up_closes_it(self, session): + r = self._offer_rule(session, offset=0) + p = project(session, contract(session, client(session)), stage="Offer") + generate_tasks(session, TODAY) + p.stage = "Upcoming" + session.add(p) + session.commit() + generate_tasks(session, TODAY) + assert [t.status for t in tasks_of(session, r)] == ["done"] + + def test_an_offer_made_again_is_followed_up_again(self, session): + r = self._offer_rule(session, offset=0) + p = project(session, contract(session, client(session)), stage="Offer") + generate_tasks(session, days(-3)) + p.stage = "Lead" + session.add(p) + session.commit() + generate_tasks(session, days(-2)) + p.stage = "Offer" + session.add(p) + session.commit() + generate_tasks(session, TODAY) + assert [(t.status, t.due_date) for t in tasks_of(session, r)] == [("done", days(-3)), ("pending", TODAY)] + + def test_payment_thank_you(self, session): + inv = invoice(session, contract(session, client(session, "Umbrella")), number="2026-009", date=days(-10)) + old = invoice(session, contract(session, client(session, "Acme"), title="Old"), number="2025-100", paid=True) + r = rule(session, created=made(0), title="Thank {client} for paying invoice {invoice}", event="invoice=paid") + inv.paid = True + session.add(inv) + session.commit() + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["Thank Umbrella for paying invoice 2026-009"] + assert old.id not in {t.subject_id for t in tasks_of(session, r)} + + def test_contract_completed(self, session): + ct = contract(session, client(session), title="Retainer") + r = rule(session, created=made(0), title="Wrap up {contract} with {client}", event="contract=Completed") + ct.is_completed = True + session.add(ct) + session.commit() + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["Wrap up Retainer with Acme"] + + def test_a_scoped_status_rule_watches_only_its_client(self, session): + acme, umbrella = client(session, "Acme"), client(session, "Umbrella") + r = rule(session, created=made(0), title="{project}", event="project=Lead", client_id=umbrella.id) + project(session, contract(session, acme, title="A"), title="Acme lead", stage="Lead") + project(session, contract(session, umbrella, title="U"), title="Umbrella lead", stage="Lead") + generate_tasks(session, TODAY) + assert [t.title for t in tasks_of(session, r)] == ["Umbrella lead"] + + def test_preview_of_a_new_rule_on_an_open_status(self, session): + project(session, contract(session, client(session)), title="Underfloor Heating", stage="Offer") + draft = TaskRule(title="Follow up on {project}", kind="event", event="project=Offer", offset=7, created_at=made(0)) + ev = evaluate(draft, session, TODAY) + assert (ev.next.title, ev.next.trigger) == ("Follow up on Underfloor Heating", days(7)) + + def test_preview_of_a_new_rule_on_a_final_status_makes_nothing_yet(self, session): + project(session, contract(session, client(session)), title="Old job", stage="Completed") + draft = TaskRule(title="Feedback on {project}", kind="event", event="project=Completed", created_at=made(0)) + ev = evaluate(draft, session, TODAY) + assert (ev.items, ev.next) == ([], None) diff --git a/tuttle_tests/test_task_rules_demo.py b/tuttle_tests/test_task_rules_demo.py new file mode 100644 index 00000000..64ade21a --- /dev/null +++ b/tuttle_tests/test_task_rules_demo.py @@ -0,0 +1,334 @@ +"""Task rules on Harry Tuttle's demo data, through the RPC calls the Tasks view makes. + +The composer's payloads are sent exactly as the UI shapes them (see +ui/src/components/tasks/RuleComposer.tsx). test_task_rules_simulation.py runs +the rules over two years of days. +""" + +import datetime +import json +from collections import Counter + +import pytest +import sqlmodel +from sqlmodel import select + +from tuttle.app.core.dispatch import dispatch +from tuttle.app.tasks.rules import EVENTS, RECORDS, add_months +from tuttle.model import Contract, FinancialGoal, Invoice, Project, TaskRule + +TODAY = datetime.date.today() + + +@pytest.fixture +def db(harry_tuttle_db): + """A session on the demo database. Rules a test adds are deleted afterwards, with their tasks.""" + engine = sqlmodel.create_engine(f"sqlite:///{harry_tuttle_db}") + with sqlmodel.Session(engine) as session: + before = {r.id for r in session.exec(select(TaskRule)).all()} + yield session + for rule_id in {r["id"] for r in ok(dispatch("tasks.get_rules", {}))} - before: + ok(dispatch("tasks.delete_rule", {"id": rule_id})) + engine.dispose() + + +def ok(result: dict): + """The data of a successful RPC envelope, which must survive JSON like the real transport.""" + assert result["ok"] is True, result.get("error") + json.dumps(result) + return result["data"] + + +def ui_rule(**fields) -> dict: + """A rule as the composer sends it: every field present, nulls included.""" + rule = { + "title": "Task", + "note": None, + "kind": "event", + "start_date": None, + "every": 1, + "unit": None, + "event": "contract.end", + "offset": 2, + "offset_unit": "week", + "before": True, + "client_id": None, + "paused": False, + } + rule.update(fields) + return rule + + +def first_of_next_month() -> datetime.date: + return add_months(TODAY.replace(day=1), 1) + + +# --------------------------------------------------------------------------- +# What the demo shows +# --------------------------------------------------------------------------- + + +class TestDemo: + def test_built_in_rules_and_harrys_own(self, db): + rules = ok(dispatch("tasks.get_rules", {})) + signature = {(r["kind"], r["event"], r["offset"], r["before"], r["paused"]) for r in rules} + # Every user starts with these two. + assert ("event", "invoice.due", 1, False, False) in signature + assert ("event", "contract.end", 30, True, False) in signature + # Harry's own: dates once and repeating, status changes, a scoped one and a paused one. + kinds = Counter(r["kind"] for r in rules) + assert kinds["date"] == 3 and kinds["event"] == 5 + assert any(r["paused"] for r in rules) + assert any(r["client_id"] for r in rules) + dated = {r["title"]: r["next_date"] for r in rules if r["kind"] == "date"} + assert dated["Send invoices for {period}"] == first_of_next_month().isoformat() + + def test_tasks_from_harrys_rules(self, db): + tasks = ok(dispatch("tasks.get_all", {})) + titles = {t["title"] for t in tasks if t["status"] == "pending"} + last_month = add_months(TODAY.replace(day=1), -1) + assert f"Send invoices for {last_month:%B %Y}" in titles + assert any(t.startswith("File the VAT return for Q") for t in titles) + assert "Follow up on the offer for Underfloor Heating" in titles + + def test_every_open_overdue_invoice_has_a_reminder_task(self, db): + overdue_rule = next(r for r in ok(dispatch("tasks.get_rules", {})) if r["event"] == "invoice.due") + tasks = [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == overdue_rule["id"]] + expected = { + i.id + for i in db.exec(select(Invoice)).all() + if i.sent + and not i.paid + and not i.cancelled + and not i.reminders + and i.effective_due_date + and i.effective_due_date < TODAY + } + assert expected, "the demo should show an overdue invoice" + assert {t["subject_id"] for t in tasks if t["status"] == "pending"} == expected + for t in tasks: + assert "{" not in t["title"] and t["title"].endswith("is overdue — send a reminder") + + def test_every_task_from_a_rule_points_at_a_record_that_exists(self, db): + models = {"contract": Contract, "project": Project, "invoice": Invoice, "goal": FinancialGoal} + for t in ok(dispatch("tasks.get_all", {})): + if t["subject_type"]: + assert db.get(models[t["subject_type"]], t["subject_id"]) is not None, t + if t["rule_id"]: + assert t["due_date"], t + + def test_pending_count_is_what_the_tasks_view_shows(self, db): + pending = [t for t in ok(dispatch("tasks.get_all", {})) if t["status"] == "pending"] + assert ok(dispatch("tasks.pending_count", {})) == len(pending) + + def test_options_name_harrys_clients(self, db): + options = ok(dispatch("tasks.rule_options", {})) + assert {"Central Services", "Ductwork Security Inc."} <= {c["name"] for c in options["clients"]} + assert sum(len(r["events"]) for r in options["records"]) == len(EVENTS) + + +# --------------------------------------------------------------------------- +# The composer's calls +# --------------------------------------------------------------------------- + + +def _timings(event): + yield "on", 0, False + if not event.after_only: + yield "before", 2, True + yield "after", 2, False + + +CATALOG = [(key, timing, offset, before) for key, e in EVENTS.items() for timing, offset, before in _timings(e)] +# On the day needs no unit; before and after are tried in days and in months (which keep within month ends). +PREVIEWS = [(k, t, o, b, u) for k, t, o, b in CATALOG for u in (["day"] if t == "on" else ["day", "month"])] + + +class TestComposer: + @pytest.mark.parametrize("key, timing, offset, before, unit", PREVIEWS, ids=[f"{k}-{t}-{u}" for k, t, _, _, u in PREVIEWS]) + def test_every_event_previews(self, db, key, timing, offset, before, unit): + event = EVENTS[key] + # As the composer suggests it: the heads-up title before or on the day, where there is one. + heads_up = before or (timing == "on" and not event.is_state) + title = (event.titles[0] or event.titles[1]) if heads_up else event.titles[1] + preview = ok( + dispatch( + "tasks.preview_rule", + { + "rule": ui_rule(title=title, event=key, offset=offset, offset_unit=unit, before=before), + }, + ) + ) + assert preview["error"] is None + placeholders = {f"{{{p}}}" for p in RECORDS[event.record].placeholders} + for task in preview["now"] + ([preview["next"]] if preview["next"] else []): + assert not any(p in task["title"] for p in placeholders), task + # A new rule on a final status (paid, completed) counts only what reaches it from today. + assert preview["from_today"] == event.final + + def test_monthly_preview(self, db): + preview = ok( + dispatch( + "tasks.preview_rule", + { + "rule": ui_rule( + kind="date", + title="Send invoices for {period}", + start_date=first_of_next_month().isoformat(), + unit="month", + ) + }, + ) + ) + first = first_of_next_month() + assert preview["next"] == {"title": f"Send invoices for {TODAY:%B %Y}", "date": first.isoformat()} + assert preview["later"] == [f"{d:%b} {d.day}, {d.year}" for d in (add_months(first, 1), add_months(first, 2))] + + def test_preview_of_a_day_that_has_passed(self, db): + preview = ok( + dispatch( + "tasks.preview_rule", + { + "rule": ui_rule( + kind="date", + title="Too late", + start_date=(TODAY - datetime.timedelta(days=3)).isoformat(), + ) + }, + ) + ) + assert (preview["error"], preview["now"], preview["next"]) == (None, [], None) + + def test_a_new_offer_rule_covers_the_offer_already_out(self, db): + preview = ok( + dispatch( + "tasks.preview_rule", + {"rule": ui_rule(title="Follow up on the offer for {project}", event="project=Offer", offset=1, before=False)}, + ) + ) + assert preview["from_today"] is False + assert preview["next"]["title"] == "Follow up on the offer for Underfloor Heating" + + def test_editing_a_status_rule_previews_its_own_tasks(self, db): + offer_rule = next(r for r in ok(dispatch("tasks.get_rules", {})) if r["event"] == "project=Offer") + preview = ok(dispatch("tasks.preview_rule", {"rule": offer_rule})) + assert preview["from_today"] is False + assert [t["title"] for t in preview["now"]] == ["Follow up on the offer for Underfloor Heating"] + + @pytest.mark.parametrize( + "fields, message", + [ + ({"title": ""}, "Give the task a title."), + ({"kind": "date", "start_date": None}, "Choose the date of the task."), + ({"kind": "date", "start_date": "15.10.2026"}, "Enter the date as a calendar date."), + ({"offset": "two"}, "Enter how many days, weeks or months as a whole number."), + ({"every": "often", "kind": "date", "start_date": "2026-12-01"}, "whole number"), + ({"client_id": "Acme"}, "Choose a client from the list."), + ({"event": None}, "Choose what the task follows"), + ({"event": "contract.explodes"}, "Choose what the task follows"), + ({"event": "invoice.sent", "before": True}, "only once it has happened"), + ({"event": "project=Offer", "before": True}, "only once it has happened"), + ], + ) + def test_preview_says_what_is_missing_and_save_refuses(self, db, fields, message): + preview = ok(dispatch("tasks.preview_rule", {"rule": ui_rule(**fields)})) + assert message in preview["error"] + saved = dispatch("tasks.save_rule", {"rule": ui_rule(**fields)}) + assert saved["ok"] is False + assert message in saved["error"] + assert "Traceback" not in saved["error"] and "NoneType" not in saved["error"] + + def test_create_edit_pause_resume_delete(self, db): + created = ok( + dispatch( + "tasks.save_rule", + { + "rule": ui_rule( + kind="date", + title="Water the plants", + note="Every {period}", + start_date=TODAY.isoformat(), + unit="week", + ) + }, + ) + ) + rule_id = created["id"] + tasks = [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == rule_id] + assert [(t["title"], t["due_date"]) for t in tasks] == [("Water the plants", TODAY.isoformat())] + + edited = ok(dispatch("tasks.save_rule", {"rule": {**created, "title": "Water the ficus"}})) + assert edited["id"] == rule_id + tasks = [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == rule_id] + assert [t["title"] for t in tasks] == ["Water the ficus"] + + assert ok(dispatch("tasks.set_rule_paused", {"id": rule_id, "paused": True}))["paused"] is True + assert ok(dispatch("tasks.set_rule_paused", {"id": rule_id, "paused": False}))["paused"] is False + + ok(dispatch("tasks.delete_rule", {"id": rule_id})) + assert rule_id not in {r["id"] for r in ok(dispatch("tasks.get_rules", {}))} + assert not [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == rule_id] + + def test_changing_the_trigger_starts_pending_tasks_afresh(self, db): + created = ok( + dispatch( + "tasks.save_rule", + {"rule": ui_rule(title="{invoice} overdue", event="invoice.due", offset=1, offset_unit="day", before=False)}, + ) + ) + assert [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == created["id"]] + # Ten years after it's due: no invoice of Harry's is that late. + ok(dispatch("tasks.save_rule", {"rule": {**created, "offset": 120, "offset_unit": "month"}})) + assert not [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == created["id"]] + + def test_a_final_status_rule_counts_from_the_day_it_is_made(self, db): + created = ok( + dispatch( + "tasks.save_rule", + {"rule": ui_rule(title="Thank {client}", event="invoice=paid", offset=0, before=False)}, + ) + ) + assert not [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == created["id"]] + ok(dispatch("tasks.set_rule_paused", {"id": created["id"], "paused": True})) + ok(dispatch("tasks.set_rule_paused", {"id": created["id"], "paused": False})) + assert not [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == created["id"]] + + def test_an_open_status_rule_resumed_does_not_follow_up_twice(self, db): + created = ok( + dispatch( + "tasks.save_rule", + {"rule": ui_rule(title="Chase {project}", event="project=Offer", offset=0, before=False)}, + ) + ) + tasks = [t for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == created["id"]] + assert [t["title"] for t in tasks] == ["Chase Underfloor Heating"] + ok(dispatch("tasks.set_rule_paused", {"id": created["id"], "paused": True})) + ok(dispatch("tasks.set_rule_paused", {"id": created["id"], "paused": False})) + assert [t["id"] for t in ok(dispatch("tasks.get_all", {})) if t["rule_id"] == created["id"]] == [tasks[0]["id"]] + + def test_a_rule_for_every_event_keeps_the_tasks_view_loading(self, db): + for key, timing, offset, before in CATALOG: + event = EVENTS[key] + ok( + dispatch( + "tasks.save_rule", + { + "rule": ui_rule( + title=event.titles[1], + event=key, + offset=offset, + offset_unit="week", + before=before, + ) + }, + ) + ) + tasks = ok(dispatch("tasks.get_all", {})) + assert len({t["key"] for t in tasks}) == len(tasks) + assert ok(dispatch("tasks.pending_count", {})) == sum(t["status"] == "pending" for t in tasks) + rules = ok(dispatch("tasks.get_rules", {})) + assert len(rules) >= len(CATALOG) + + def test_deleting_a_missing_rule_is_harmless(self, db): + ok(dispatch("tasks.delete_rule", {"id": 999999})) + assert dispatch("tasks.set_rule_paused", {"id": 999999, "paused": True})["error"] == "That rule no longer exists." diff --git a/tuttle_tests/test_task_rules_simulation.py b/tuttle_tests/test_task_rules_simulation.py new file mode 100644 index 00000000..0d7fe58f --- /dev/null +++ b/tuttle_tests/test_task_rules_simulation.py @@ -0,0 +1,118 @@ +"""Two years of days on Harry Tuttle's demo data, with a rule for every event in the catalog. + +Every few days the generator runs twice. Tasks must come neither early nor +twice about the same record, stay done once ticked off, and a second pass on +the same day must change nothing. +""" + +import datetime +import shutil +from collections import Counter + +import sqlmodel +from sqlmodel import select + +from tuttle.app.tasks.generator import generate_tasks +from tuttle.app.tasks.rules import EVENTS, shift, start_watching, validate_rule +from tuttle.model import Task, TaskRule + +TODAY = datetime.date.today() + +# Every event at every timing it allows: on the day (or as soon as), before, after. +CATALOG = [ + (key, timing, offset, before) + for key, e in EVENTS.items() + for timing, offset, before in [("on", 0, False), ("before", 2, True), ("after", 2, False)] + if not (before and e.after_only) +] + + +def _trigger(rule: TaskRule, task: Task) -> datetime.date: + """The day a task may first appear.""" + if rule.kind == "date": + return task.due_date + return shift(task.due_date, -rule.offset if rule.before else rule.offset, rule.offset_unit) + + +def test_two_years_of_days(harry_tuttle_db, tmp_path): + copy = tmp_path / "harry.db" + shutil.copy(harry_tuttle_db, copy) + engine = sqlmodel.create_engine(f"sqlite:///{copy}") + start = TODAY - datetime.timedelta(days=30) + made = datetime.datetime.combine(start - datetime.timedelta(days=30), datetime.time(9)) + with sqlmodel.Session(engine) as session: + # Every event at every timing (the units take turns), plus every repeat of a date rule. + for i, (key, timing, offset, before) in enumerate(CATALOG): + for unit in [("day", "week", "month")[i % 3]]: + rule = TaskRule( + title=f"{key} {timing} {unit} {{date}}", + kind="event", + event=key, + offset=offset, + offset_unit=unit, + before=before, + created_at=made, + ) + validate_rule(rule) + session.add(rule) + session.flush() + start_watching(session, rule, made.date()) + for unit, every in ((None, 1), ("week", 1), ("week", 2), ("month", 1), ("month", 3), ("month", 6), ("month", 12)): + for start_date in (start, datetime.date(start.year, 1, 31)): + session.add( + TaskRule( + title=f"every {every} {unit} {{period}}", + kind="date", + start_date=start_date, + unit=unit, + every=every, + created_at=made, + ) + ) + session.commit() + rules = {r.id: r for r in session.exec(select(TaskRule)).all()} + + def snapshot(): + return {t.key: (t.status, t.title, t.due_date) for t in session.exec(select(Task)).all()} + + done_keys: set[str] = set() + for step in range(0, 730, 10): + day = start + datetime.timedelta(days=step) + before = snapshot() + generate_tasks(session, day) + after = snapshot() + + generate_tasks(session, day) + assert snapshot() == after, f"a second pass on {day} changed tasks" + + new_tasks = [t for t in session.exec(select(Task).where(Task.rule_id.is_not(None))).all() if t.key not in before] + per_subject = Counter((t.rule_id, t.subject_id) for t in new_tasks) + assert max(per_subject.values(), default=0) <= 1, f"{day}: one rule made several tasks about one record" + for t in new_tasks: + rule = rules[t.rule_id] + assert _trigger(rule, t) <= day, f"{day}: '{t.title}' came early (due {t.due_date})" + assert "{" not in t.title, t.title + + for key in done_keys: + assert after[key][0] == "done", f"{day}: done task {key} came back" + + if step == 300: + # Harry ticks off everything; nothing ticked off may come back. + for t in session.exec(select(Task).where(Task.status == "pending")).all(): + t.status = "done" + session.add(t) + session.commit() + done_keys = {k for k, v in snapshot().items() if v[0] == "done"} + + # One task per record for a date that happens once. + once = Counter( + (t.rule_id, t.subject_id) + for t in session.exec(select(Task).where(Task.subject_id.is_not(None))).all() + if not EVENTS[rules[t.rule_id].event].is_state and not EVENTS[rules[t.rule_id].event].date.repeats + ) + assert max(once.values()) == 1 + made_any = Counter( + rules[t.rule_id].event or rules[t.rule_id].unit for t in session.exec(select(Task)).all() if t.rule_id + ) + assert made_any["invoice.due"] and made_any["contract.period_end"] and made_any["month"] + engine.dispose() diff --git a/tuttle_tests/test_tasks.py b/tuttle_tests/test_tasks.py index 12436c43..680b653d 100644 --- a/tuttle_tests/test_tasks.py +++ b/tuttle_tests/test_tasks.py @@ -16,6 +16,7 @@ InvoiceItem, Project, Task, + TaskRule, ) from tuttle.time import Cycle, TimeUnit @@ -115,8 +116,22 @@ def test_manual_tutorial_tasks_not_auto_resolved(self, session): class TestOverdueTasks: + """The overdue-invoice task, which every user starts with as a rule (see the task rules migration).""" + + @pytest.fixture + def overdue_rule(self, session): + rule = TaskRule( + title="Invoice {invoice} is overdue — send a reminder", + kind="event", + event="invoice.due", + offset=1, + ) + session.add(rule) + session.commit() + return rule + @pytest.fixture - def overdue_invoice(self, session): + def overdue_invoice(self, session, overdue_rule): contact = Contact( first_name="C", last_name="D", @@ -166,15 +181,18 @@ def overdue_invoice(self, session): session.commit() return invoice - def test_creates_overdue_task(self, session, overdue_invoice): + def test_creates_overdue_task(self, session, overdue_rule, overdue_invoice): assert overdue_invoice.status == "overdue" generate_tasks(session) - task = session.exec(sqlmodel.select(Task).where(Task.key == f"overdue:{overdue_invoice.id}")).first() + task = session.exec( + sqlmodel.select(Task).where(Task.key == f"rule:{overdue_rule.id}:invoice:{overdue_invoice.id}") + ).first() assert task is not None assert task.status == "pending" - assert "2025-001" in task.title + assert task.title == "Invoice 2025-001 is overdue — send a reminder" + assert task.due_date == datetime.date(2025, 1, 15) - def test_does_not_create_if_reminder_exists(self, session, overdue_invoice): + def test_does_not_create_if_reminder_exists(self, session, overdue_rule, overdue_invoice): # Add a reminder for the invoice reminder = Invoice( number="2025-001-M1", @@ -190,14 +208,25 @@ def test_does_not_create_if_reminder_exists(self, session, overdue_invoice): session.add(reminder) session.commit() generate_tasks(session) - task = session.exec(sqlmodel.select(Task).where(Task.key == f"overdue:{overdue_invoice.id}")).first() + task = session.exec( + sqlmodel.select(Task).where(Task.key == f"rule:{overdue_rule.id}:invoice:{overdue_invoice.id}") + ).first() assert task is None - def test_does_not_create_for_paid_invoice(self, session, overdue_invoice): + def test_does_not_create_for_paid_invoice(self, session, overdue_rule, overdue_invoice): overdue_invoice.paid = True session.add(overdue_invoice) session.commit() assert overdue_invoice.status == "paid" generate_tasks(session) - task = session.exec(sqlmodel.select(Task).where(Task.key == f"overdue:{overdue_invoice.id}")).first() + task = session.exec( + sqlmodel.select(Task).where(Task.key == f"rule:{overdue_rule.id}:invoice:{overdue_invoice.id}") + ).first() assert task is None + + def test_no_task_once_the_rule_is_deleted(self, session, overdue_rule, overdue_invoice): + """The overdue task is an ordinary rule: without it, Tuttle makes none.""" + session.delete(overdue_rule) + session.commit() + generate_tasks(session) + assert session.exec(sqlmodel.select(Task).where(Task.rule_id.is_not(None))).all() == [] diff --git a/ui/src/components/business/ContractsView.tsx b/ui/src/components/business/ContractsView.tsx index 63c9703d..d3035c7e 100644 --- a/ui/src/components/business/ContractsView.tsx +++ b/ui/src/components/business/ContractsView.tsx @@ -28,6 +28,7 @@ function contractStatus(c: Entity): string { } export function ContractsView() { + const { filter: navFilter } = useNavigation(); const [contracts, setContracts] = useState([]); const [clients, setClients] = useState>({}); const [selected, setSelected] = useState(null); @@ -233,7 +234,10 @@ export function ContractsView() { return title.includes(q) || clientName.includes(q); }); - useAutoSelect(contracts, filtered, selected, setSelected, { enabled: mode === "view" }); + useAutoSelect(contracts, filtered, selected, setSelected, { + enabled: mode === "view", + preferred: navFilter.contractId != null ? (c) => c.id === navFilter.contractId : undefined, + }); return (
diff --git a/ui/src/components/business/ProjectsView.tsx b/ui/src/components/business/ProjectsView.tsx index ee82cd86..81a9c0df 100644 --- a/ui/src/components/business/ProjectsView.tsx +++ b/ui/src/components/business/ProjectsView.tsx @@ -198,7 +198,8 @@ export function ProjectsView() { useAutoSelect(projects, filtered, selected, setSelected, { enabled: mode === "view", - preferred: navFilter.contractId != null ? (p) => num(p, "contract_id") === navFilter.contractId : undefined, + preferred: navFilter.projectId != null ? (p) => p.id === navFilter.projectId + : navFilter.contractId != null ? (p) => num(p, "contract_id") === navFilter.contractId : undefined, }); function moveToColumn(id: number, colId: string) { diff --git a/ui/src/components/layout/Shell.tsx b/ui/src/components/layout/Shell.tsx index e42f3a2b..721f09d4 100644 --- a/ui/src/components/layout/Shell.tsx +++ b/ui/src/components/layout/Shell.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useMemo, useEffect } from "react"; import { Sidebar, type RegisteredUser } from "./Sidebar"; import { OnboardingWizard, type OnboardingData } from "./OnboardingWizard"; -import { TasksView } from "../tasks/TasksView"; +import { TasksView, TASKS_CHANGED_EVENT } from "../tasks/TasksView"; import { DashboardView } from "../dashboard/DashboardView"; import { ProjectsView } from "../business/ProjectsView"; import { ClientsView } from "../business/ClientsView"; @@ -64,6 +64,7 @@ export function Shell() { const [regLoading, setRegLoading] = useState(false); const [bootError, setBootError] = useState(null); const [bootNotice, setBootNotice] = useState(null); + const [pendingTasks, setPendingTasks] = useState(0); const navigate = useCallback((view: string, filter?: NavigationFilter) => { setNavFilter(filter || {}); @@ -72,6 +73,25 @@ export function Shell() { const toggleSidebar = useCallback(() => setCollapsed((c) => !c), []); + // Pending tasks, for the sidebar: refreshed after every view change, and by the Tasks view itself. + // The core answers one call at a time, so the count waits until the view has asked for its data. + useEffect(() => { + if (bootState !== "ready") return; + let stale = false; + const handle = setTimeout(() => { + rpc("tasks.pending_count").then((res) => { + if (!stale && res.ok && typeof res.data === "number") setPendingTasks(res.data); + }); + }, 1000); + return () => { stale = true; clearTimeout(handle); }; + }, [bootState, selected, activeUser?.db_file]); + + useEffect(() => { + const onChange = (e: Event) => setPendingTasks((e as CustomEvent).detail); + window.addEventListener(TASKS_CHANGED_EVENT, onChange); + return () => window.removeEventListener(TASKS_CHANGED_EVENT, onChange); + }, []); + useEffect(() => { try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0"); } catch { /* remembering it is a convenience */ } }, [collapsed]); @@ -287,6 +307,7 @@ export function Shell() { onSwitchUser={handleSwitchUser} onAddUser={() => setRegDialogOpen(true)} onDeleteUser={handleDeleteUser} + badges={{ tasks: pendingTasks }} />
{/* Banners sit in the title-bar row above the view, so they drag the window too. */} diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx index 1dec3d43..05ad3a9d 100644 --- a/ui/src/components/layout/Sidebar.tsx +++ b/ui/src/components/layout/Sidebar.tsx @@ -59,11 +59,13 @@ type Props = { onSwitchUser: (dbFile: string) => void; onAddUser: () => void; onDeleteUser: (dbFile: string) => void; + /** A count to show next to a view, such as pending tasks. */ + badges?: Partial>; }; export function Sidebar({ selected, onSelect, collapsed, onToggleCollapse, - activeUser, allUsers, onSwitchUser, onAddUser, onDeleteUser, + activeUser, allUsers, onSwitchUser, onAddUser, onDeleteUser, badges = {}, }: Props) { const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); @@ -114,17 +116,21 @@ export function Sidebar({
{section.items.map((item) => { const active = selected === item.id; + const badge = badges[item.id] ?? 0; return ( ); })} diff --git a/ui/src/components/tasks/RuleComposer.tsx b/ui/src/components/tasks/RuleComposer.tsx new file mode 100644 index 00000000..404afca6 --- /dev/null +++ b/ui/src/components/tasks/RuleComposer.tsx @@ -0,0 +1,356 @@ +import { useEffect, useRef, useState } from "react"; +import { CalendarDays, Trash2, Zap } from "lucide-react"; +import { rpc } from "../../api/rpc"; +import { useDismiss } from "../../hooks/useDismiss"; +import { + REPEATS, findEvent, fmtDay, newRule, repeatOf, suggestedTitle, unitLabel, + type RuleKind, type RuleOptions, type RulePreview, type TaskRule, +} from "./rules"; + +type Timing = "on" | "before" | "after"; + +const KINDS: { kind: RuleKind; icon: typeof Zap; label: string; hint: string }[] = [ + { kind: "date", icon: CalendarDays, label: "On a date", hint: "Once or repeating" }, + { kind: "event", icon: Zap, label: "When something happens", hint: "Around a contract, project, invoice or goal" }, +]; + +// Each blank in the trigger sentence is an inline word, sized to its text. +const WORD = "appearance-none field-sizing-content font-semibold leading-normal text-accent bg-accent/12 hover:bg-accent/20 rounded-md px-[5px] py-px outline-none focus-visible:ring-2 focus-visible:ring-accent cursor-pointer"; +const SELECT = `${WORD} word-select pr-[16px]`; + +function timingOf(rule: TaskRule): Timing { + if (rule.offset === 0) return "on"; + return rule.before ? "before" : "after"; +} + +/** Create or edit a task rule. The trigger reads as one sentence; a preview shows what it would do. */ +export function RuleComposer({ rule, options, onClose, onSaved, onDeleted }: { + rule: TaskRule | null; + options: RuleOptions; + onClose: () => void; + onSaved: () => void; + onDeleted: () => void; +}) { + const editing = rule?.id != null; + const [draft, setDraft] = useState(() => rule ?? newRule("date", options)); + const [timing, setTiming] = useState(() => timingOf(rule ?? newRule("event", options))); + // The amount as typed, so it can be cleared and retyped; it counts as at least 1. + const [offsetText, setOffsetText] = useState(() => String(rule && rule.offset > 0 ? rule.offset : 2)); + const offset = Math.max(1, parseInt(offsetText) || 1); + const [titleTouched, setTitleTouched] = useState(editing); + // The preview, with the trigger it was worked out for: one for another trigger is never shown. + const [preview, setPreview] = useState<{ trigger: string; data: RulePreview } | null>(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const titleRef = useRef(null); + const backdrop = useDismiss(onClose); + + const found = findEvent(options, draft.event); + const record = found?.record ?? options.records[0]; + const event = found?.event ?? record.events[0]; + const repeat = repeatOf(draft); + + /** The rule as it would be saved. */ + function payload(d: TaskRule = draft, t: Timing = timing, o: number = offset): TaskRule { + if (d.kind === "date") return { ...d, event: null, client_id: null }; + return { ...d, offset: t === "on" ? 0 : o, before: t === "before" }; + } + + function update(patch: Partial, nextTiming: Timing = timing) { + setDraft((d) => { + const next = { ...d, ...patch }; + if (!titleTouched) next.title = suggestedTitle(payload(next, nextTiming), options); + return next; + }); + } + + function changeTiming(t: Timing) { + setTiming(t); + update({}, t); + } + + function changeRecord(key: string) { + const rec = options.records.find((r) => r.key === key); + if (!rec) return; + const first = rec.events.find((e) => !e.state) ?? rec.events[0]; + const t = first.after_only && timing === "before" ? "after" : timing; + setTiming(t); + update({ event: first.key, client_id: rec.key === "goal" ? null : draft.client_id }, t); + } + + function changeEvent(key: string) { + const ev = record.events.find((e) => e.key === key); + const t = ev?.after_only && timing === "before" ? "after" : timing; + setTiming(t); + update({ event: key }, t); + } + + function changeKind(kind: RuleKind) { + if (kind === draft.kind) return; + // A rule switched from the other kind brings none of this kind's fields. + const fresh = newRule(kind, options); + if (kind === "event" && !draft.event) update({ kind, event: fresh.event, offset_unit: fresh.offset_unit }); + else if (kind === "date" && !draft.start_date) update({ kind, start_date: fresh.start_date, unit: fresh.unit, every: fresh.every }); + else update({ kind }); + } + + function insertPlaceholder(name: string) { + const input = titleRef.current; + const token = `{${name}}`; + const start = input?.selectionStart ?? draft.title.length; + const end = input?.selectionEnd ?? start; + setTitleTouched(true); + setDraft((d) => ({ ...d, title: d.title.slice(0, start) + token + d.title.slice(end) })); + requestAnimationFrame(() => { + input?.focus(); + input?.setSelectionRange(start + token.length, start + token.length); + }); + } + + /** What decides which tasks the rule makes; the title only words them. */ + function triggerOf(r: TaskRule): string { + const { title: _title, note: _note, ...trigger } = r; + return JSON.stringify(trigger); + } + + // Preview what the rule would do, a moment after each change. + useEffect(() => { + let stale = false; + const rule = payload(); + const handle = setTimeout(async () => { + const res = await rpc("tasks.preview_rule", { rule: rule as unknown as Record }); + if (!stale) setPreview({ trigger: triggerOf(rule), data: res.ok ? res.data : { error: res.error ?? "Couldn't preview the rule." } }); + }, 150); + return () => { stale = true; clearTimeout(handle); }; + }, [draft, timing, offset]); // eslint-disable-line react-hooks/exhaustive-deps + + async function save() { + setSaving(true); + setError(null); + const res = await rpc("tasks.save_rule", { rule: payload() as unknown as Record }); + setSaving(false); + if (res.ok) onSaved(); + else setError(res.error ?? "Couldn't save the rule."); + } + + async function remove() { + if (draft.id == null) return; + const res = await rpc("tasks.delete_rule", { id: draft.id }); + if (res.ok) onDeleted(); + else setError(res.error ?? "Couldn't delete the rule."); + } + + const placeholders = draft.kind === "date" ? ["period", "date"] : record.placeholders; + const timings: { value: Timing; label: string }[] = [ + { value: "on", label: event.state ? "as soon as" : "on the day" }, + ...(event.after_only ? [] : [{ value: "before" as Timing, label: "before" }]), + { value: "after", label: "after" }, + ]; + const capitalize = (s: string) => s[0].toUpperCase() + s.slice(1); + const dateEvents = record.events.filter((e) => !e.state); + const stateEvents = record.events.filter((e) => e.state); + + return ( +
+
+
+

{editing ? "Edit rule" : "New rule"}

+
+ +
+
+ {KINDS.map(({ kind, icon: Icon, label, hint }) => ( + + ))} +
+ +
+ {draft.kind === "date" ? ( + <> + {" "} + {draft.unit ? "starting" : "on"}{" "} + update({ start_date: e.target.value || null })} /> + + ) : ( + <> + {timing !== "on" && ( + <> + setOffsetText(e.target.value)} onBlur={() => setOffsetText(String(offset))} + className={`${WORD} min-w-[2.2em] text-center tabular-nums cursor-text [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none`} />{" "} + {" "} + + )} + {" "} + {" "} + + {record.key !== "goal" && options.clients.length > 0 && ( + <> + {" "} + {/* A long client name wraps as a whole, after the event. */} + + for{" "} + + + + )} + + )} +
+ +
+ + { setTitleTouched(true); setDraft((d) => ({ ...d, title: e.target.value })); }} + className="w-full px-3 py-1.5 rounded-md bg-bg-card border border-border-subtle text-sm text-primary outline-none focus:border-accent" /> +
+ Insert + {placeholders.map((p) => ( + + ))} +
+
+ +
+ + setDraft((d) => ({ ...d, note: e.target.value }))} + className="w-full px-3 py-1.5 rounded-md bg-bg-card border border-border-subtle text-sm text-primary outline-none focus:border-accent" /> +
+ + +
+ +
+ {editing && !confirmDelete && ( + + )} + {editing && confirmDelete && ( +
+ Delete it and its tasks? + + +
+ )} + {error &&

{error}

} + + {/* The preview says what's missing; the rule can be saved once it doesn't. */} + +
+
+
+ ); +} + +function PreviewRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +/** The rule's event, for the preview of a new rule on a final status: "invoice", "is paid", " 1 week later". */ +type Watch = { noun: string; event: string; later: string }; + +function RulePreviewBox({ preview, kind, watch }: { preview: RulePreview | null; kind: RuleKind; watch: Watch }) { + let body: React.ReactNode; + if (!preview) body =

Working out what this rule does…

; + else if (preview.error) body =

{preview.error}

; + else if (preview.from_today) { + body =

From today, each {watch.noun} that {watch.event} gets a task{watch.later}.

; + } else { + const now = preview.now ?? []; + body = ( + <> + {now.length > 0 && ( + + {now.slice(0, 3).map((t, i) => {t.title})} + {now.length > 3 && and {now.length - 3} more} + + )} + {preview.next && ( + + {preview.next.title} + Appears {fmtDay(preview.next.date, true)} + + )} + {preview.later && preview.later.length > 0 && ( + + {preview.later.join(", ")}, … + + )} + {now.length === 0 && !preview.next && ( +

+ {kind === "date" + ? "That date has passed. Choose today or a later date." + : "Nothing in your records matches yet. Tasks appear as soon as something does."} +

+ )} + + ); + } + return
{body}
; +} diff --git a/ui/src/components/tasks/RulesList.tsx b/ui/src/components/tasks/RulesList.tsx new file mode 100644 index 00000000..2f65f9f5 --- /dev/null +++ b/ui/src/components/tasks/RulesList.tsx @@ -0,0 +1,88 @@ +import { CalendarDays, Plus, Repeat, Zap } from "lucide-react"; +import { fmtDay, ruleSentence, type RuleOptions, type TaskRule } from "./rules"; + +const GROUPS: { kind: TaskRule["kind"]; label: string }[] = [ + { kind: "date", label: "On a date" }, + { kind: "event", label: "When something happens" }, +]; + +/** The rules that create tasks, each read as its sentence over the task it makes. */ +export function RulesList({ rules, options, onEdit, onNew, onTogglePause }: { + rules: TaskRule[]; + options: RuleOptions | null; + onEdit: (rule: TaskRule) => void; + onNew: () => void; + onTogglePause: (rule: TaskRule) => void; +}) { + return ( +
+ {GROUPS.map(({ kind, label }) => { + const group = rules.filter((r) => r.kind === kind); + if (group.length === 0) return null; + return ( +
+

{label}

+ {group.map((rule) => ( + onEdit(rule)} onTogglePause={() => onTogglePause(rule)} /> + ))} +
+ ); + })} + +
+ ); +} + +function RuleRow({ rule, options, onEdit, onTogglePause }: { + rule: TaskRule; + options: RuleOptions | null; + onEdit: () => void; + onTogglePause: () => void; +}) { + const Icon = rule.kind === "event" ? Zap : rule.unit ? Repeat : CalendarDays; + const status = rule.paused ? "Paused" + : rule.next_date ? `Next ${fmtDay(rule.next_date)}` + : rule.kind === "event" && rule.event?.includes("=") ? "Watching" + : ""; + return ( +
{ if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); onEdit(); } }} + className="flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-content px-3.5 py-2.5 cursor-pointer hover:border-border hover:bg-bg-sidebar transition-colors"> +
+ +
+
+
{ruleSentence(rule, options)}
+
+
+ {status && {status}} + +
+ ); +} + +/** A task title template, with its {placeholders} shown as tokens. */ +export function TemplateText({ text }: { text: string }) { + return ( + <> + {text.split(/(\{\w+\})/).map((part, i) => + /^\{\w+\}$/.test(part) + ? {part.slice(1, -1)} + : {part}, + )} + + ); +} + +function Switch({ on, label, onToggle }: { on: boolean; label: string; onToggle: () => void }) { + return ( + + ); +} diff --git a/ui/src/components/tasks/TasksView.tsx b/ui/src/components/tasks/TasksView.tsx index 47d1b2fd..73fa4310 100644 --- a/ui/src/components/tasks/TasksView.tsx +++ b/ui/src/components/tasks/TasksView.tsx @@ -1,15 +1,23 @@ import { useEffect, useState } from "react"; -import { ArrowRight, CheckSquare, Circle, CheckCircle2, X } from "lucide-react"; +import { ArrowRight, CheckSquare, Circle, CheckCircle2, Repeat, X, Zap, CalendarDays } from "lucide-react"; import { rpc } from "../../api/rpc"; import { EmptyStateIntro } from "../shared/EmptyStateIntro"; import { LoadError, LoadingState } from "../shared/LoadStates"; -import { PageLayout } from "../shared/ToolbarButtons"; +import { PageLayout, ToolbarFilterGroup } from "../shared/ToolbarButtons"; import { useNavigation, type NavigationFilter } from "../shared/NavigationContext"; +import { localDateIso } from "../../api/entity"; import type { Entity } from "../../api/types"; +import { RuleComposer } from "./RuleComposer"; +import { RulesList } from "./RulesList"; +import { fmtDay, relDay, ruleSentence, subjectDestination, type RuleOptions, type TaskRule } from "./rules"; type Destination = { view: string; filter?: NavigationFilter }; +type Mode = "tasks" | "rules"; -// Where each task gets done, by task key (see tuttle/app/tasks/generator.py). +/** Tells the sidebar how many tasks are pending. */ +export const TASKS_CHANGED_EVENT = "tuttle:tasks-changed"; + +// Where each getting-started task gets done, by task key (see tuttle/app/tasks/generator.py). const DESTINATIONS: Record = { "tutorial:first_contact": { view: "contacts" }, "tutorial:first_client": { view: "clients" }, @@ -20,14 +28,18 @@ const DESTINATIONS: Record = { "tutorial:import_document": { view: "import" }, }; -function destinationOf(key: string): Destination | undefined { - if (key.startsWith("overdue:")) return { view: "invoicing", filter: { invoiceId: Number(key.slice("overdue:".length)) } }; - return DESTINATIONS[key]; +function destinationOf(task: Entity): Destination | undefined { + return DESTINATIONS[(task.key as string) ?? ""] + ?? subjectDestination((task.subject_type as string) ?? null, (task.subject_id as number) ?? null); } export function TasksView() { const { navigate } = useNavigation(); + const [mode, setMode] = useState("tasks"); const [tasks, setTasks] = useState([]); + const [rules, setRules] = useState([]); + const [options, setOptions] = useState(null); + const [composing, setComposing] = useState(null); const [loading, setLoading] = useState(true); const [loadFailed, setLoadFailed] = useState(false); @@ -36,9 +48,19 @@ export function TasksView() { async function load(showSpinner = false) { if (showSpinner) setLoading(true); setLoadFailed(false); - const res = await rpc("tasks.get_all"); - setLoadFailed(!res.ok); - if (res.ok && Array.isArray(res.data)) setTasks(res.data); + const [tasksRes, rulesRes, optionsRes] = await Promise.all([ + rpc("tasks.get_all"), + rpc("tasks.get_rules"), + rpc("tasks.rule_options"), + ]); + setLoadFailed(!tasksRes.ok || !rulesRes.ok || !optionsRes.ok); + if (tasksRes.ok && Array.isArray(tasksRes.data)) { + setTasks(tasksRes.data); + const pending = tasksRes.data.filter((t) => t.status === "pending").length; + window.dispatchEvent(new CustomEvent(TASKS_CHANGED_EVENT, { detail: pending })); + } + if (rulesRes.ok && Array.isArray(rulesRes.data)) setRules(rulesRes.data); + if (optionsRes.ok && optionsRes.data) setOptions(optionsRes.data); if (showSpinner) setLoading(false); } @@ -65,28 +87,77 @@ export function TasksView() { await load(); } + async function togglePause(rule: TaskRule) { + await rpc("tasks.set_rule_paused", { id: rule.id, paused: !rule.paused }); + await load(); + } + + const pending = tasks + .filter((t) => t.status === "pending") + // Getting started first, then by the date each task is about. + .sort((a, b) => String(a.due_date ?? "").localeCompare(String(b.due_date ?? ""))); + const completed = tasks.filter((t) => t.status === "done" || t.status === "dismissed"); + const ruleById = new Map(rules.map((r) => [r.id, r])); + + const toggle = ( + , rules: }} + labels={{ + tasks: <>Tasks {pending.length}, + rules: <>Rules {rules.length}, + }} + /> + ); + + const composer = composing && options && ( + setComposing(null)} + onSaved={() => { setComposing(null); load(); }} + onDeleted={() => { setComposing(null); load(); }} + /> + ); + if (loadFailed) return load(true)} />} />; if (loading) return } />; - if (tasks.length === 0) { - return } />; + + if (mode === "rules") { + return ( + + setComposing("new")} onTogglePause={togglePause} /> + {composer} + + ); } - const pending = tasks.filter((t) => t.status === "pending"); - const completed = tasks.filter((t) => t.status === "done" || t.status === "dismissed"); + if (tasks.length === 0) { + return ( + } /> + ); + } return ( - + {pending.length > 0 && (
{pending.map((task) => { - const dest = destinationOf((task.key as string) ?? ""); + const dest = destinationOf(task); + const rule = ruleById.get(task.rule_id as number); return ( navigate(dest.view, dest.filter) : undefined} onDone={() => markDone(task.id as number)} onDismiss={() => dismiss(task.id as number)} + onEditRule={rule ? () => setComposing(rule) : undefined} /> ); })} @@ -120,21 +191,28 @@ export function TasksView() { })}
)} + {composer}
); } -function TaskCard({ task, done, onOpen, onDone, onDismiss, onReopen }: { +function TaskCard({ task, rule, ruleLabel, done, onOpen, onDone, onDismiss, onReopen, onEditRule }: { task: Entity; + rule?: TaskRule; + ruleLabel?: string; done?: boolean; onOpen?: () => void; onDone?: () => void; onDismiss?: () => void; onReopen?: () => void; + onEditRule?: () => void; }) { const [confirmDismiss, setConfirmDismiss] = useState(false); const key = (task.key as string) ?? ""; const isTutorial = key.startsWith("tutorial:"); + const dueDate = (task.due_date as string) || null; + const today = localDateIso(); + const RuleIcon = rule?.kind === "event" ? Zap : rule?.unit ? Repeat : CalendarDays; // The card navigates; its own buttons must not. const own = (fn?: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn?.(); }; @@ -184,6 +262,21 @@ function TaskCard({ task, done, onOpen, onDone, onDismiss, onReopen }: { Getting started )} + {!done && (dueDate || ruleLabel) && ( +
+ {dueDate && ( + today ? "text-accent" : "text-tertiary"}`}> + {fmtDay(dueDate)} · {relDay(dueDate)} + + )} + {ruleLabel && ( + + )} +
+ )}
{onDismiss && !confirmDismiss && ( diff --git a/ui/src/components/tasks/rules.ts b/ui/src/components/tasks/rules.ts new file mode 100644 index 00000000..10eec4da --- /dev/null +++ b/ui/src/components/tasks/rules.ts @@ -0,0 +1,184 @@ +/** + * Task rules as the UI sees them (see tuttle/app/tasks/rules.py): the + * rule's fields, what the composer offers, and the sentence a rule reads as. + */ + +import { localDateIso } from "../../api/entity"; + +export type RuleKind = "date" | "event"; +export type OffsetUnit = "day" | "week" | "month"; + +export interface TaskRule { + id?: number; + title: string; + note: string | null; + kind: RuleKind; + // On a date + start_date: string | null; + every: number; + unit: "week" | "month" | null; + // When something happens + event: string | null; + offset: number; + offset_unit: OffsetUnit; + before: boolean; + client_id: number | null; + paused: boolean; + next_date?: string | null; +} + +export interface RuleEvent { + key: string; + label: string; + state: boolean; + final: boolean; + after_only: boolean; + title_before: string | null; + title_after: string; +} + +export interface RuleRecord { + key: string; + label: string; + placeholders: string[]; + events: RuleEvent[]; +} + +export interface RuleOptions { + records: RuleRecord[]; + clients: { id: number; name: string }[]; +} + +export interface RulePreview { + error: string | null; + now?: { title: string; date: string }[]; + next?: { title: string; date: string } | null; + later?: string[]; + /** A new rule on a final status (paid, completed): it counts only records that reach it from today. */ + from_today?: boolean; +} + +/** How often a rule on a date repeats, as the composer offers it. */ +export const REPEATS: { key: string; label: string; unit: "week" | "month" | null; every: number }[] = [ + { key: "once", label: "Once", unit: null, every: 1 }, + { key: "week", label: "Every week", unit: "week", every: 1 }, + { key: "2weeks", label: "Every 2 weeks", unit: "week", every: 2 }, + { key: "month", label: "Every month", unit: "month", every: 1 }, + { key: "quarter", label: "Every quarter", unit: "month", every: 3 }, + { key: "halfyear", label: "Every 6 months", unit: "month", every: 6 }, + { key: "year", label: "Every year", unit: "month", every: 12 }, +]; + +export function repeatOf(rule: Pick) { + return REPEATS.find((r) => r.unit === rule.unit && (r.unit === null || r.every === rule.every)); +} + +const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/** A calendar date ("2026-11-06") as a local date, not UTC midnight. */ +export function parseDay(iso: string): Date { + const [y, m, d] = iso.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +/** "Nov 6, 2026"; without the year when it is this year's. */ +export function fmtDay(iso: string, withYear = false): string { + const d = parseDay(iso); + const short = `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()}`; + return withYear || d.getFullYear() !== new Date().getFullYear() ? `${short}, ${d.getFullYear()}` : short; +} + +/** "today", "in 3 days", "12 days ago". */ +export function relDay(iso: string): string { + const today = new Date(); + const n = Math.round((parseDay(iso).getTime() - new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / 86400000); + if (n === 0) return "today"; + if (n === 1) return "tomorrow"; + if (n === -1) return "yesterday"; + return n > 0 ? `in ${n} days` : `${-n} days ago`; +} + +function ordinal(n: number): string { + const tens = n % 100; + if (tens >= 11 && tens <= 13) return `${n}th`; + return n + (["th", "st", "nd", "rd"][n % 10] ?? "th"); +} + +export function findEvent(options: RuleOptions | null, key: string | null): { record: RuleRecord; event: RuleEvent } | null { + if (!options || !key) return null; + for (const record of options.records) { + const event = record.events.find((e) => e.key === key); + if (event) return { record, event }; + } + return null; +} + +export function unitLabel(unit: string, n: number): string { + return n === 1 ? unit : `${unit}s`; +} + +/** The rule as a sentence: "30 days before a contract ends", "Every month on the 1st". */ +export function ruleSentence(rule: TaskRule, options: RuleOptions | null): string { + if (rule.kind === "date") { + if (!rule.start_date) return "On a date"; + const d = parseDay(rule.start_date); + const start = fmtDay(rule.start_date, true); + if (!rule.unit) return `On ${start}`; + if (rule.unit === "week") { + return `${rule.every === 1 ? "Every week" : `Every ${rule.every} weeks`} on ${WEEKDAYS[d.getDay()]}`; + } + if (rule.every === 1) return `Every month on the ${ordinal(d.getDate())}`; + if (rule.every === 12) return `Every year on ${MONTHS[d.getMonth()]} ${d.getDate()}`; + const repeat = repeatOf(rule); + return `${repeat ? repeat.label : `Every ${rule.every} months`}, starting ${start}`; + } + const found = findEvent(options, rule.event); + if (!found) return "When something happens"; + const { record, event } = found; + const when = rule.offset === 0 + ? (event.state ? "As soon as" : "On the day") + : `${rule.offset} ${unitLabel(rule.offset_unit, rule.offset)} ${rule.before ? "before" : "after"}`; + const client = rule.client_id != null ? options?.clients.find((c) => c.id === rule.client_id)?.name : null; + return `${when} ${record.label} ${event.label}${client ? ` for ${client}` : ""}`; +} + +/** Where a task about a record gets done. */ +export function subjectDestination(type: string | null, id: number | null): { view: string; filter?: Record } | undefined { + if (!type) return undefined; + if (type === "contract" && id != null) return { view: "contracts", filter: { contractId: id } }; + if (type === "project" && id != null) return { view: "projects", filter: { projectId: id } }; + if (type === "invoice" && id != null) return { view: "invoicing", filter: { invoiceId: id } }; + if (type === "goal") return { view: "dashboard" }; + return undefined; +} + +/** A blank rule of either kind, as the composer starts it. */ +export function newRule(kind: RuleKind, options: RuleOptions | null): TaskRule { + const today = new Date(); + const firstOfNextMonth = localDateIso(new Date(today.getFullYear(), today.getMonth() + 1, 1)); + const contractEnd = findEvent(options, "contract.end"); + return { + title: kind === "date" ? "Send invoices for {period}" : contractEnd?.event.title_before ?? "", + note: null, + kind, + start_date: firstOfNextMonth, + every: 1, + unit: "month", + event: "contract.end", + offset: 2, + offset_unit: "week", + before: true, + client_id: null, + paused: false, + }; +} + +/** The title the composer suggests for a rule, until the user writes their own. */ +export function suggestedTitle(rule: TaskRule, options: RuleOptions | null): string { + if (rule.kind === "date") return "Send invoices for {period}"; + const found = findEvent(options, rule.event); + if (!found) return ""; + const { event } = found; + return rule.before || (rule.offset === 0 && !event.state) ? event.title_before ?? event.title_after : event.title_after; +} diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index 7bc7de7f..9143422c 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -107,6 +107,21 @@ html, body { -webkit-app-region: no-drag; } +/* A select that reads as a word in a sentence (the task rule composer): a small caret, no native chrome. */ +.word-select { + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: calc(100% - 10px) 58%, calc(100% - 6px) 58%; + background-size: 4px 4px; + background-repeat: no-repeat; +} +.word-select option, +.word-select optgroup { + color: var(--color-primary); + background: var(--color-bg-content); + font-weight: 400; +} + /* Windows and Linux draw the window buttons over the title-bar row, at its end (and on some Linux desktops at its start). These insets keep content clear of them; on macOS there is no such overlay and they are zero. */