diff --git a/CHANGELOG.md b/CHANGELOG.md index bd35d475..b0f15769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Add `scheduled` and `deadline` date fields to the task + model across all four backends (org, markdown, sql, + json) and the cloud wire format. Both are date-only ISO + strings; `scheduled` is the snooze (later UI hides the + card until that day arrives) and `deadline` is the due + date (later UI surfaces it as an urgency cue). SQL gets + an idempotent `ALTER TABLE` migration so existing + databases pick up the columns on next open. The + add_task / update_task / MCP / API surfaces all accept + the new kwargs; UI follows in a separate PR. + - Replace the global "Show done" toggle on the kanban board with per-column collapse. Each column has its own chevron; collapsed columns shrink to a narrow strip with diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b58eccb9..432893e7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -14,6 +14,12 @@ export interface Task { created: string; body: string; github_url: string; + /** Date-only ISO string (``YYYY-MM-DD``) or null. + * Snooze date: when set and in the future, the card is + * hidden from the board until the day arrives. */ + scheduled: string | null; + /** Date-only ISO string (``YYYY-MM-DD``) or null. */ + deadline: string | null; state_history?: StateChange[]; } diff --git a/kaisho/api/routers/kanban.py b/kaisho/api/routers/kanban.py index c4ce5b2f..3514bd74 100644 --- a/kaisho/api/routers/kanban.py +++ b/kaisho/api/routers/kanban.py @@ -14,6 +14,11 @@ class TaskCreate(BaseModel): tags: list[str] = [] body: str | None = None github_url: str | None = None + # Date-only ISO strings (``YYYY-MM-DD``). ``scheduled`` + # is the snooze (hidden / subdued until that day), + # ``deadline`` is the due date. Both are optional. + scheduled: str | None = None + deadline: str | None = None class TaskUpdate(BaseModel): @@ -22,6 +27,10 @@ class TaskUpdate(BaseModel): customer: str | None = None body: str | None = None github_url: str | None = None + # ``None`` = leave unchanged (default Pydantic shape); + # ``""`` = clear; ``"YYYY-MM-DD"`` = set. + scheduled: str | None = None + deadline: str | None = None class TagsUpdate(BaseModel): @@ -66,6 +75,8 @@ def create_task(body: TaskCreate): tags=body.tags, body=body.body, github_url=body.github_url, + scheduled=body.scheduled, + deadline=body.deadline, ) @@ -85,6 +96,8 @@ def update_task(task_id: str, body: TaskUpdate): or body.customer is not None or body.body is not None or body.github_url is not None + or body.scheduled is not None + or body.deadline is not None ): result = tasks.update_task( task_id, @@ -92,6 +105,8 @@ def update_task(task_id: str, body: TaskUpdate): customer=body.customer, body=body.body, github_url=body.github_url, + scheduled=body.scheduled, + deadline=body.deadline, ) if result is None: raise HTTPException( diff --git a/kaisho/backends/json_backend/__init__.py b/kaisho/backends/json_backend/__init__.py index d0df6ff0..296efc94 100644 --- a/kaisho/backends/json_backend/__init__.py +++ b/kaisho/backends/json_backend/__init__.py @@ -212,6 +212,8 @@ def add_task( github_url=None, sync_id=None, task_id=None, + scheduled=None, + deadline=None, ) -> dict: """Create a new task and return its dict.""" tasks = _read_json(self._tasks_file) @@ -225,6 +227,8 @@ def add_task( "github_url": github_url or "", "properties": {}, "created": datetime.now().isoformat(), + "scheduled": scheduled or None, + "deadline": deadline or None, } tasks.insert(0, task) _write_json(self._tasks_file, tasks) @@ -274,6 +278,8 @@ def update_task( customer=None, body=None, github_url=None, + scheduled=None, + deadline=None, ) -> dict: """Update a task's fields and return updated dict.""" tasks = _read_json(self._tasks_file) @@ -287,6 +293,10 @@ def update_task( t["body"] = body if github_url is not None: t["github_url"] = github_url + if scheduled is not None: + t["scheduled"] = scheduled or None + if deadline is not None: + t["deadline"] = deadline or None _write_json(self._tasks_file, tasks) return t raise ValueError(f"Task not found: {task_id}") diff --git a/kaisho/backends/markdown/__init__.py b/kaisho/backends/markdown/__init__.py index a275359e..3ce0b504 100644 --- a/kaisho/backends/markdown/__init__.py +++ b/kaisho/backends/markdown/__init__.py @@ -444,6 +444,8 @@ def _section_to_task(sec: dict) -> dict: "archive_status": meta.get( "archive_status", "" ), + "scheduled": meta.get("scheduled") or None, + "deadline": meta.get("deadline") or None, } @@ -460,6 +462,14 @@ def _task_to_section(task: dict) -> dict: meta["archived_at"] = task["archived_at"] if task.get("archive_status"): meta["archive_status"] = task["archive_status"] + # Only emit ``scheduled`` / ``deadline`` when set, so + # the absence of a value reads as a clean missing key + # in the YAML / TOML-style metadata header rather than + # an explicit empty. + if task.get("scheduled"): + meta["scheduled"] = task["scheduled"] + if task.get("deadline"): + meta["deadline"] = task["deadline"] props = _task_meta_to_props({ **meta, "properties": task.get("properties", {}), @@ -588,6 +598,8 @@ def add_task( github_url=None, sync_id=None, task_id=None, + scheduled=None, + deadline=None, ) -> dict: """Create a new task and return its dict.""" tasks = self._load_tasks() @@ -604,6 +616,8 @@ def add_task( "properties": {}, "created": now.isoformat(), "updated_at": now.isoformat(), + "scheduled": scheduled or None, + "deadline": deadline or None, } tasks.insert(0, task) self._save_tasks(tasks) @@ -659,6 +673,8 @@ def update_task( customer=None, body=None, github_url=None, + scheduled=None, + deadline=None, ) -> dict: """Update a task's fields and return updated dict.""" tasks = self._load_tasks() @@ -672,6 +688,10 @@ def update_task( t["body"] = body if github_url is not None: t["github_url"] = github_url + if scheduled is not None: + t["scheduled"] = scheduled or None + if deadline is not None: + t["deadline"] = deadline or None t["updated_at"] = ( _local_now().isoformat() ) diff --git a/kaisho/backends/org/tasks.py b/kaisho/backends/org/tasks.py index 7603ad73..f0c9966e 100644 --- a/kaisho/backends/org/tasks.py +++ b/kaisho/backends/org/tasks.py @@ -51,6 +51,8 @@ def add_task( github_url: str | None = None, sync_id: str | None = None, task_id: str | None = None, + scheduled: str | None = None, + deadline: str | None = None, ) -> dict: """Create a new task and return its dict.""" return kanban.add_task( @@ -64,6 +66,8 @@ def add_task( github_url=github_url, sync_id=sync_id, task_id=task_id, + scheduled=scheduled, + deadline=deadline, ) def move_task(self, task_id: str, new_status: str) -> dict: @@ -100,6 +104,8 @@ def update_task( customer: str | None = None, body: str | None = None, github_url: str | None = None, + scheduled: str | None = None, + deadline: str | None = None, ) -> dict: """Update a task's fields and return updated dict.""" return kanban.update_task( @@ -110,6 +116,8 @@ def update_task( customer=customer, body=body, github_url=github_url, + scheduled=scheduled, + deadline=deadline, ) def archive_task(self, task_id: str) -> bool: diff --git a/kaisho/backends/sql/__init__.py b/kaisho/backends/sql/__init__.py index 7578a390..27f59993 100644 --- a/kaisho/backends/sql/__init__.py +++ b/kaisho/backends/sql/__init__.py @@ -60,6 +60,13 @@ class TaskRow(Base): created = Column(String, nullable=False) archived_at = Column(String, nullable=True) archive_status = Column(String, nullable=True) + # Date-only ISO strings (``YYYY-MM-DD``) or NULL. + # ``scheduled`` is the snooze: the task is hidden / + # subdued until that day arrives. ``deadline`` is the + # due date: shown but flagged when close or past. Both + # cross the wire so PWA / iOS see the same dates. + scheduled = Column(String, nullable=True) + deadline = Column(String, nullable=True) class ClockRow(Base): # noqa: E302 @@ -168,6 +175,7 @@ def __init__(self, dsn: str): self.engine = create_engine(dsn) Base.metadata.create_all(self.engine) _ensure_paused_column(self.engine) + _ensure_task_date_columns(self.engine) _ensure_customer_used_offset_column(self.engine) self._Session = sessionmaker(bind=self.engine) @@ -199,6 +207,29 @@ def _ensure_paused_column(engine) -> None: )) +def _ensure_task_date_columns(engine) -> None: + """Add ``tasks.scheduled`` and ``tasks.deadline`` columns + on legacy databases. + + Same shape as ``_ensure_paused_column``: idempotent + per-column check, silent skip when already present. + """ + from sqlalchemy import inspect, text + inspector = inspect(engine) + if "tasks" not in inspector.get_table_names(): + return + cols = {c["name"] for c in inspector.get_columns("tasks")} + with engine.begin() as conn: + if "scheduled" not in cols: + conn.execute(text( + "ALTER TABLE tasks ADD COLUMN scheduled VARCHAR" + )) + if "deadline" not in cols: + conn.execute(text( + "ALTER TABLE tasks ADD COLUMN deadline VARCHAR" + )) + + def _ensure_customer_used_offset_column(engine) -> None: """Add the ``customers.used_offset`` column on legacy databases. @@ -383,6 +414,8 @@ def _task_row_to_dict(row: TaskRow) -> dict: "created": row.created, "archived_at": row.archived_at, "archive_status": row.archive_status, + "scheduled": row.scheduled or None, + "deadline": row.deadline or None, } @@ -618,6 +651,8 @@ def add_task( github_url=None, sync_id=None, task_id=None, + scheduled=None, + deadline=None, ) -> dict: """Create a new task and return its dict.""" # ``task_id`` is honoured when the caller wants to @@ -636,6 +671,8 @@ def add_task( github_url=github_url or "", properties="{}", created=now, + scheduled=scheduled or None, + deadline=deadline or None, ) session = self._eng.session() try: @@ -653,6 +690,8 @@ def add_task( "github_url": github_url or "", "properties": {}, "created": now, + "scheduled": scheduled or None, + "deadline": deadline or None, } def move_task(self, task_id, new_status) -> dict: @@ -723,8 +762,17 @@ def update_task( customer=None, body=None, github_url=None, + scheduled=None, + deadline=None, ) -> dict: - """Update a task's fields and return updated dict.""" + """Update a task's fields and return updated dict. + + ``scheduled`` / ``deadline`` follow the same + sentinel rules as every other field on this + helper: ``None`` leaves the existing value alone, + an empty string clears the column, a date string + (``YYYY-MM-DD``) sets it. + """ session = self._eng.session() try: row = session.get(TaskRow, task_id) @@ -740,6 +788,10 @@ def update_task( row.body = body if github_url is not None: row.github_url = github_url + if scheduled is not None: + row.scheduled = scheduled or None + if deadline is not None: + row.deadline = deadline or None session.commit() result = _task_row_to_dict(row) finally: diff --git a/kaisho/cron/tool_defs.py b/kaisho/cron/tool_defs.py index eba153fe..56d80260 100644 --- a/kaisho/cron/tool_defs.py +++ b/kaisho/cron/tool_defs.py @@ -77,6 +77,20 @@ "link to this task" ), }, + "scheduled": { + "type": "string", + "description": ( + "Snooze date (YYYY-MM-DD). Task is " + "hidden until that day arrives." + ), + }, + "deadline": { + "type": "string", + "description": ( + "Due date (YYYY-MM-DD). Surfaced " + "as an urgency cue on the card." + ), + }, }, "required": ["title"], }, @@ -380,7 +394,7 @@ "tier": "write", "description": ( "Update a task's title, customer, body, " - "or GitHub URL." + "GitHub URL, scheduled date, or deadline." ), "input_schema": { "type": "object", @@ -411,6 +425,22 @@ "(optional)" ), }, + "scheduled": { + "type": "string", + "description": ( + "Snooze date (YYYY-MM-DD), empty " + "string to clear, omit to leave " + "unchanged." + ), + }, + "deadline": { + "type": "string", + "description": ( + "Due date (YYYY-MM-DD), empty " + "string to clear, omit to leave " + "unchanged." + ), + }, }, "required": ["task_id"], }, diff --git a/kaisho/cron/tools.py b/kaisho/cron/tools.py index d5412cfb..a99d56c1 100644 --- a/kaisho/cron/tools.py +++ b/kaisho/cron/tools.py @@ -310,6 +310,8 @@ def _add_task(args: dict) -> dict: tags=_coerce_tags(args.get("tags")), body=args.get("body"), github_url=args.get("github_url"), + scheduled=args.get("scheduled"), + deadline=args.get("deadline"), ) return {"task": task} @@ -475,6 +477,8 @@ def _update_task(args: dict) -> dict: customer=args.get("customer"), body=args.get("body"), github_url=args.get("github_url"), + scheduled=args.get("scheduled"), + deadline=args.get("deadline"), ) return {"task": task} diff --git a/kaisho/services/cloud_sync.py b/kaisho/services/cloud_sync.py index 2e637f3a..5633a5e3 100644 --- a/kaisho/services/cloud_sync.py +++ b/kaisho/services/cloud_sync.py @@ -742,7 +742,13 @@ def _strip_customer_prefix(title: str) -> str: def task_to_wire(task: dict) -> dict: - """Convert a local task dict to wire format.""" + """Convert a local task dict to wire format. + + ``scheduled`` / ``deadline`` are date-only ISO strings + (``YYYY-MM-DD``); they cross the wire unchanged so the + cloud (and downstream PWA / iOS) sees the same dates + the desktop set. + """ return { "id": task["sync_id"], "customer": task.get("customer") or "", @@ -753,6 +759,8 @@ def task_to_wire(task: dict) -> dict: "tags": task.get("tags") or [], "body": task.get("body") or "", "github_url": task.get("github_url") or "", + "scheduled": task.get("scheduled") or None, + "deadline": task.get("deadline") or None, "created_at": _local_to_utc( task.get("created") or local_now().isoformat() ), @@ -773,6 +781,8 @@ def wire_to_task(entry: dict) -> dict: "tags": entry.get("tags") or [], "body": entry.get("body") or "", "github_url": entry.get("github_url") or "", + "scheduled": entry.get("scheduled") or None, + "deadline": entry.get("deadline") or None, "created": _utc_to_local( entry.get("created_at") or "" ), @@ -1658,6 +1668,8 @@ def _apply_pulled_task( github_url=incoming["github_url"], tags=incoming.get("tags"), sync_id=incoming["sync_id"], + scheduled=incoming.get("scheduled"), + deadline=incoming.get("deadline"), ) return 1, 0 @@ -1692,6 +1704,8 @@ def _apply_task_update( customer=incoming["customer"], body=incoming["body"], github_url=incoming["github_url"], + scheduled=incoming.get("scheduled") or "", + deadline=incoming.get("deadline") or "", ) if incoming.get("tags") != existing.get("tags"): backend.tasks.set_tags( diff --git a/kaisho/services/kanban.py b/kaisho/services/kanban.py index e10fd639..bfa5e41d 100644 --- a/kaisho/services/kanban.py +++ b/kaisho/services/kanban.py @@ -90,6 +90,17 @@ def _heading_to_task(heading: Heading, task_id: str) -> dict: "github_url": heading.properties.get( "GITHUB_URL", "" ), + # ``SCHEDULED`` / ``DEADLINE`` heading properties + # carry the date-only ISO string. Org-mode users may + # also use the native ``SCHEDULED:`` / ``DEADLINE:`` + # planning lines, but kaisho writes them as + # properties to keep parsing trivial across backends. + "scheduled": ( + heading.properties.get("SCHEDULED") or None + ), + "deadline": ( + heading.properties.get("DEADLINE") or None + ), "state_history": _state_history(heading), } @@ -273,8 +284,16 @@ def add_task( github_url: str | None = None, sync_id: str | None = None, task_id: str | None = None, + scheduled: str | None = None, + deadline: str | None = None, ) -> dict: - """Add a new task to todos.org as a flat heading.""" + """Add a new task to todos.org as a flat heading. + + :param scheduled: Optional snooze date (``YYYY-MM-DD``). + Written as a ``:SCHEDULED:`` heading property. + :param deadline: Optional deadline date (``YYYY-MM-DD``). + Written as a ``:DEADLINE:`` heading property. + """ if not todos_file.exists(): todos_file.parent.mkdir(parents=True, exist_ok=True) todos_file.write_text("", encoding="utf-8") @@ -300,6 +319,10 @@ def add_task( ) if github_url: new_heading.properties["GITHUB_URL"] = github_url + if scheduled: + new_heading.properties["SCHEDULED"] = scheduled + if deadline: + new_heading.properties["DEADLINE"] = deadline # Persist the stable ID so renames don't change it. # Caller can pin the id explicitly (used by @@ -372,8 +395,16 @@ def update_task( customer: str | None = None, body: str | None = None, github_url: str | None = None, + scheduled: str | None = None, + deadline: str | None = None, ) -> dict: - """Update a task's title, customer, and/or body.""" + """Update a task's fields. + + ``scheduled`` / ``deadline`` follow the same sentinel + rules as the other params: ``None`` leaves the existing + value alone, an empty string clears the property, a + ``YYYY-MM-DD`` string sets it. + """ org_file = parse_org_file(todos_file, keywords) heading = _find_task_heading(org_file, keywords, task_id) if heading is None: @@ -392,6 +423,16 @@ def update_task( heading.properties["GITHUB_URL"] = github_url else: heading.properties.pop("GITHUB_URL", None) + if scheduled is not None: + if scheduled: + heading.properties["SCHEDULED"] = scheduled + else: + heading.properties.pop("SCHEDULED", None) + if deadline is not None: + if deadline: + heading.properties["DEADLINE"] = deadline + else: + heading.properties.pop("DEADLINE", None) heading.properties["UPDATED_AT"] = current_timestamp() ensure_sync_identity(heading) heading.dirty = True diff --git a/tests/test_task_scheduled_deadline.py b/tests/test_task_scheduled_deadline.py new file mode 100644 index 00000000..45198645 --- /dev/null +++ b/tests/test_task_scheduled_deadline.py @@ -0,0 +1,289 @@ +"""Round-trip tests for the new ``scheduled`` / ``deadline`` +task fields across all four backends + the cloud wire +format. + +Per the backend-parity rule: every data-model change must +be implemented in every backend (org, markdown, sql, +json) so a profile conversion never silently drops data. +These tests pin that contract. +""" +import json + +from kaisho.services.cloud_sync import ( + task_to_wire, wire_to_task, +) + + +# -- SQL backend ------------------------------------------ + +def _sql_clocks_tasks(tmp_path): + from kaisho.backends.sql import make_sql_backend + dsn = f"sqlite:///{tmp_path / 'sched.db'}" + backend = make_sql_backend(dsn) + # make_sql_backend returns (tasks, clocks, ...) tuple + # in some shapes; SqlTaskBackend is the first element + # named ``tasks`` on the returned object — use the + # public attribute so we stay decoupled from the tuple + # order. + return backend + + +def test_sql_add_and_read_dates(tmp_path): + from kaisho.backends.sql import make_sql_backend + eng_tuple = make_sql_backend( + f"sqlite:///{tmp_path / 'sched.db'}", + ) + # eng_tuple is a NamedTuple-like; access via ``tasks`` + # attribute / index. Iterating the convention used in + # other tests: + tasks = eng_tuple[0] + task = tasks.add_task( + customer="Acme", + title="Snooze me", + scheduled="2099-06-15", + deadline="2099-06-20", + ) + assert task["scheduled"] == "2099-06-15" + assert task["deadline"] == "2099-06-20" + + listed = tasks.list_tasks(include_done=True) + assert listed[0]["scheduled"] == "2099-06-15" + assert listed[0]["deadline"] == "2099-06-20" + + +def test_sql_update_sets_and_clears_dates(tmp_path): + from kaisho.backends.sql import make_sql_backend + tasks = make_sql_backend( + f"sqlite:///{tmp_path / 'sched.db'}" + )[0] + task = tasks.add_task(customer="A", title="T") + assert task["scheduled"] is None + assert task["deadline"] is None + + tasks.update_task( + task["id"], + scheduled="2099-06-15", + deadline="2099-06-20", + ) + again = tasks.list_tasks(include_done=True)[0] + assert again["scheduled"] == "2099-06-15" + assert again["deadline"] == "2099-06-20" + + # Empty string clears. + tasks.update_task( + task["id"], scheduled="", deadline="", + ) + again = tasks.list_tasks(include_done=True)[0] + assert again["scheduled"] is None + assert again["deadline"] is None + + +def test_sql_ensure_task_date_columns_is_idempotent( + tmp_path, +): + """Calling the migration twice on a fresh DB is a no-op + — newly-created tables already have the columns.""" + from kaisho.backends.sql import ( + _Engine, + _ensure_task_date_columns, + ) + eng = _Engine(f"sqlite:///{tmp_path / 'sched.db'}") + # Migration runs in _Engine.__init__; calling it again + # should not raise. + _ensure_task_date_columns(eng.engine) + + +def test_sql_legacy_db_gets_columns_added(tmp_path): + """A DB created without the new columns gets ALTER + TABLE-patched on open.""" + import sqlite3 + db_path = tmp_path / "legacy.db" + # Hand-build a tasks table that's missing the new + # columns, mirroring what an older kaisho install would + # have on disk. + with sqlite3.connect(db_path) as conn: + conn.execute( + "CREATE TABLE tasks (" + "id TEXT PRIMARY KEY, customer TEXT, title TEXT," + " status TEXT, tags TEXT, body TEXT," + " github_url TEXT, properties TEXT," + " created TEXT, archived_at TEXT," + " archive_status TEXT)" + ) + + from kaisho.backends.sql import make_sql_backend + tasks = make_sql_backend(f"sqlite:///{db_path}")[0] + + # The new columns must exist now: inserting via the + # backend with scheduled/deadline set should round-trip + # without raising "no such column". + task = tasks.add_task( + customer="Acme", + title="Migrated", + scheduled="2099-06-15", + deadline="2099-06-20", + ) + assert task["scheduled"] == "2099-06-15" + assert task["deadline"] == "2099-06-20" + + +# -- Org backend ------------------------------------------ + +def _org_backend(tmp_path): + from kaisho.backends.org.tasks import OrgTaskBackend + todos = tmp_path / "todos.org" + archive = tmp_path / "archive.org" + todos.write_text("", encoding="utf-8") + return OrgTaskBackend( + todos_file=todos, + archive_file=archive, + keywords={"TODO", "DONE", "CANCELLED"}, + ) + + +def test_org_add_and_read_dates(tmp_path): + backend = _org_backend(tmp_path) + task = backend.add_task( + customer="Acme", + title="Snooze me", + scheduled="2099-06-15", + deadline="2099-06-20", + ) + assert task["scheduled"] == "2099-06-15" + assert task["deadline"] == "2099-06-20" + + # File on disk has the SCHEDULED / DEADLINE properties. + text = (tmp_path / "todos.org").read_text("utf-8") + assert ":SCHEDULED: 2099-06-15" in text + assert ":DEADLINE: 2099-06-20" in text + + +def test_org_update_clears_dates_on_empty_string(tmp_path): + backend = _org_backend(tmp_path) + task = backend.add_task( + customer="Acme", + title="T", + scheduled="2099-06-15", + deadline="2099-06-20", + ) + backend.update_task( + task["id"], scheduled="", deadline="", + ) + text = (tmp_path / "todos.org").read_text("utf-8") + assert "SCHEDULED" not in text + assert "DEADLINE" not in text + again = backend.list_tasks(include_done=True)[0] + assert again["scheduled"] is None + assert again["deadline"] is None + + +# -- JSON backend ----------------------------------------- + +def _json_backend(tmp_path): + from kaisho.backends.json_backend import ( + JsonTaskBackend, + ) + return JsonTaskBackend( + tasks_file=tmp_path / "tasks.json", + archive_file=tmp_path / "archive.json", + ) + + +def test_json_add_and_read_dates(tmp_path): + backend = _json_backend(tmp_path) + task = backend.add_task( + customer="Acme", + title="T", + scheduled="2099-06-15", + deadline="2099-06-20", + ) + assert task["scheduled"] == "2099-06-15" + assert task["deadline"] == "2099-06-20" + + raw = json.loads( + (tmp_path / "tasks.json").read_text("utf-8"), + ) + assert raw[0]["scheduled"] == "2099-06-15" + assert raw[0]["deadline"] == "2099-06-20" + + +# -- Markdown backend ------------------------------------- + +def _md_backend(tmp_path): + from kaisho.backends.markdown import ( + MarkdownTaskBackend, + ) + return MarkdownTaskBackend( + tasks_file=tmp_path / "tasks.md", + archive_file=tmp_path / "archive.md", + ) + + +def test_markdown_add_and_read_dates(tmp_path): + backend = _md_backend(tmp_path) + task = backend.add_task( + customer="Acme", + title="T", + scheduled="2099-06-15", + deadline="2099-06-20", + ) + assert task["scheduled"] == "2099-06-15" + assert task["deadline"] == "2099-06-20" + + text = (tmp_path / "tasks.md").read_text("utf-8") + assert "scheduled" in text + assert "2099-06-15" in text + assert "deadline" in text + assert "2099-06-20" in text + + # Round-trip the file via a fresh backend instance to + # confirm the dates survive a save/load cycle. + fresh = _md_backend(tmp_path) + again = fresh.list_tasks(include_done=True)[0] + assert again["scheduled"] == "2099-06-15" + assert again["deadline"] == "2099-06-20" + + +# -- Cloud wire format ------------------------------------ + +def test_wire_round_trip_preserves_dates(): + local = { + "sync_id": "abc", + "customer": "Acme", + "title": "T", + "status": "TODO", + "tags": [], + "body": "", + "github_url": "", + "scheduled": "2099-06-15", + "deadline": "2099-06-20", + "created": "2026-06-09T10:00:00", + "updated_at": "2026-06-09T10:00:00", + } + wire = task_to_wire(local) + assert wire["scheduled"] == "2099-06-15" + assert wire["deadline"] == "2099-06-20" + + back = wire_to_task(wire) + assert back["scheduled"] == "2099-06-15" + assert back["deadline"] == "2099-06-20" + + +def test_wire_handles_missing_dates_as_none(): + local = { + "sync_id": "abc", + "customer": "Acme", + "title": "T", + "status": "TODO", + "tags": [], + "body": "", + "github_url": "", + "created": "2026-06-09T10:00:00", + "updated_at": "2026-06-09T10:00:00", + } + wire = task_to_wire(local) + assert wire["scheduled"] is None + assert wire["deadline"] is None + back = wire_to_task(wire) + assert back["scheduled"] is None + assert back["deadline"] is None