diff --git a/frontend/src/components/kanban/TaskEditForm.tsx b/frontend/src/components/kanban/TaskEditForm.tsx
index 91226ada..595b5237 100644
--- a/frontend/src/components/kanban/TaskEditForm.tsx
+++ b/frontend/src/components/kanban/TaskEditForm.tsx
@@ -71,13 +71,30 @@ export function TaskEditForm({
const { t } = useTranslation("kanban");
const { t: tc } = useTranslation("common");
+ // Cross-field check: a snooze date past the deadline
+ // is incoherent (the deadline badge would fire before
+ // the snooze even surfaces). Lex compare agrees with
+ // chronological order on ``YYYY-MM-DD``. The API
+ // mirrors this rule and returns 400 if it slips
+ // through, so this is purely instant-feedback UX.
+ const datesOutOfOrder = !!(
+ editScheduled
+ && editDeadline
+ && editDeadline < editScheduled
+ );
+
+ function handleSave() {
+ if (datesOutOfOrder) return;
+ onSave();
+ }
+
function handleKeyDown(e: React.KeyboardEvent) {
if (
(e.metaKey || e.ctrlKey) &&
e.key === "Enter"
) {
e.preventDefault();
- onSave();
+ handleSave();
}
if (e.key === "Escape") {
onCancel();
@@ -155,6 +172,11 @@ export function TaskEditForm({
/>
+ {datesOutOfOrder && (
+
+ {t("datesOutOfOrder")}
+
+ )}
e.stopPropagation()}
>
@@ -177,8 +199,8 @@ export function TaskEditForm({
e.stopPropagation()}
- onClick={onSave}
- disabled={isSaving}
+ onClick={handleSave}
+ disabled={isSaving || datesOutOfOrder}
className="p-1 text-cta hover:bg-cta-muted rounded disabled:opacity-40"
>
diff --git a/frontend/src/locales/de/kanban.json b/frontend/src/locales/de/kanban.json
index efd7e8cb..e6cab72a 100644
--- a/frontend/src/locales/de/kanban.json
+++ b/frontend/src/locales/de/kanban.json
@@ -4,6 +4,7 @@
"newTask": "Neue Aufgabe (Doppeltipp B)",
"collapseColumn": "Spalte einklappen",
"expandColumn": "Spalte ausklappen",
+ "datesOutOfOrder": "Frist muss am oder nach dem geplanten Datum liegen.",
"scheduledLabel": "Geplant",
"deadlineLabel": "Frist",
"snoozedCount": "{{count}} geplant",
diff --git a/frontend/src/locales/en/kanban.json b/frontend/src/locales/en/kanban.json
index 73e6c85d..577a1596 100644
--- a/frontend/src/locales/en/kanban.json
+++ b/frontend/src/locales/en/kanban.json
@@ -4,6 +4,7 @@
"newTask": "New task (double-tap B)",
"collapseColumn": "Collapse column",
"expandColumn": "Expand column",
+ "datesOutOfOrder": "Deadline must be on or after the scheduled date.",
"scheduledLabel": "Scheduled",
"deadlineLabel": "Deadline",
"snoozedCount": "{{count}} snoozed",
diff --git a/frontend/src/locales/es/kanban.json b/frontend/src/locales/es/kanban.json
index 4f2e23ad..0971bb9b 100644
--- a/frontend/src/locales/es/kanban.json
+++ b/frontend/src/locales/es/kanban.json
@@ -4,6 +4,7 @@
"newTask": "Nueva tarea (doble clic en B)",
"collapseColumn": "Colapsar columna",
"expandColumn": "Expandir columna",
+ "datesOutOfOrder": "La fecha límite debe ser igual o posterior a la fecha programada.",
"scheduledLabel": "Programada",
"deadlineLabel": "Fecha l\u00edmite",
"snoozedCount": "{{count}} pospuestas",
diff --git a/frontend/src/locales/ru/kanban.json b/frontend/src/locales/ru/kanban.json
index 280f21dc..a22f16c4 100644
--- a/frontend/src/locales/ru/kanban.json
+++ b/frontend/src/locales/ru/kanban.json
@@ -4,6 +4,7 @@
"newTask": "Новая задача (двойное нажатие B)",
"collapseColumn": "Свернуть колонку",
"expandColumn": "Развернуть колонку",
+ "datesOutOfOrder": "Срок должен быть не раньше запланированной даты.",
"scheduledLabel": "Запланировано",
"deadlineLabel": "Срок",
"snoozedCount": "{{count}} отложено",
diff --git a/kaisho/api/routers/kanban.py b/kaisho/api/routers/kanban.py
index 3514bd74..8b26e836 100644
--- a/kaisho/api/routers/kanban.py
+++ b/kaisho/api/routers/kanban.py
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Body, HTTPException
-from pydantic import BaseModel
+from pydantic import BaseModel, model_validator
from ...backends import get_backend
from ...config import load_settings_yaml
@@ -7,6 +7,36 @@
router = APIRouter(prefix="/api/kanban", tags=["kanban"])
+def _reject_deadline_before_scheduled(
+ scheduled: str | None, deadline: str | None,
+) -> None:
+ """Raise ``ValueError`` when the user has supplied
+ both dates in the same payload and the deadline lands
+ before the scheduled (snooze) date.
+
+ A snoozed-past-its-deadline task is incoherent — the
+ deadline badge would fire before the snooze even
+ surfaces — and is almost certainly a typo. Lexicographic
+ compare on ``YYYY-MM-DD`` agrees with chronological
+ order, so a plain string compare is enough.
+
+ On ``TaskUpdate`` this only catches the "user sent
+ both at once" case; sending just one that breaks the
+ invariant against the stored other is not validated
+ here (the frontend has the merged shape and can do
+ that check). Catching the typo at the API boundary is
+ the higher-value safety net.
+ """
+ if not scheduled or not deadline:
+ return
+ if deadline < scheduled:
+ raise ValueError(
+ "deadline must be on or after scheduled date "
+ f"(got scheduled={scheduled}, "
+ f"deadline={deadline})"
+ )
+
+
class TaskCreate(BaseModel):
customer: str = ""
title: str
@@ -20,6 +50,13 @@ class TaskCreate(BaseModel):
scheduled: str | None = None
deadline: str | None = None
+ @model_validator(mode="after")
+ def _check_dates(self) -> "TaskCreate":
+ _reject_deadline_before_scheduled(
+ self.scheduled, self.deadline,
+ )
+ return self
+
class TaskUpdate(BaseModel):
status: str | None = None
@@ -32,6 +69,13 @@ class TaskUpdate(BaseModel):
scheduled: str | None = None
deadline: str | None = None
+ @model_validator(mode="after")
+ def _check_dates(self) -> "TaskUpdate":
+ _reject_deadline_before_scheduled(
+ self.scheduled, self.deadline,
+ )
+ return self
+
class TagsUpdate(BaseModel):
tags: list[str]
diff --git a/kaisho/cli/task.py b/kaisho/cli/task.py
index 7b90b178..af687b87 100644
--- a/kaisho/cli/task.py
+++ b/kaisho/cli/task.py
@@ -24,6 +24,12 @@ def _format_task_line(task: dict) -> str:
if tag_str:
parts.append(tag_str)
parts.append(created)
+ scheduled = task.get("scheduled")
+ deadline = task.get("deadline")
+ if scheduled:
+ parts.append(f"scheduled:{scheduled}")
+ if deadline:
+ parts.append(f"deadline:{deadline}")
return " ".join(parts)
@@ -41,10 +47,16 @@ def task():
help="Task body/description")
@click.option("--github-url", default=None,
help="GitHub issue/PR URL")
+@click.option("--scheduled", default=None,
+ help="Snooze date (YYYY-MM-DD). "
+ "Task hidden until that day arrives.")
+@click.option("--deadline", default=None,
+ help="Deadline date (YYYY-MM-DD).")
@click.option("--json", "as_json", is_flag=True,
help="JSON output")
def task_add(customer_name, title, tags, status,
- body, github_url, as_json):
+ body, github_url, scheduled, deadline,
+ as_json):
"""Add a new task."""
backend = get_backend()
backend.customers.ensure_customer(customer_name or "")
@@ -55,6 +67,8 @@ def task_add(customer_name, title, tags, status,
tags=list(tags),
body=body,
github_url=github_url,
+ scheduled=scheduled,
+ deadline=deadline,
)
if as_json:
click.echo(json.dumps(result, default=str))
@@ -187,9 +201,15 @@ def task_show(task_id, as_json):
help="New body text")
@click.option("--github-url", default=None,
help="GitHub issue/PR URL")
+@click.option("--scheduled", default=None,
+ help="Snooze date (YYYY-MM-DD). "
+ "Pass empty string to clear.")
+@click.option("--deadline", default=None,
+ help="Deadline date (YYYY-MM-DD). "
+ "Pass empty string to clear.")
@click.option("--json", "as_json", is_flag=True)
def task_update(task_id, title, customer, body,
- github_url, as_json):
+ github_url, scheduled, deadline, as_json):
"""Update a task's fields."""
result = get_backend().tasks.update_task(
task_id=task_id,
@@ -197,6 +217,8 @@ def task_update(task_id, title, customer, body,
customer=customer,
body=body,
github_url=github_url,
+ scheduled=scheduled,
+ deadline=deadline,
)
if as_json:
click.echo(json.dumps(result, default=str))
diff --git a/kaisho/cron/tool_defs.py b/kaisho/cron/tool_defs.py
index 56d80260..df4f5c44 100644
--- a/kaisho/cron/tool_defs.py
+++ b/kaisho/cron/tool_defs.py
@@ -19,8 +19,10 @@
"name": "list_tasks",
"tier": "read",
"description": (
- "List tasks from the kanban board. "
- "Returns id, customer, title, status, tags for each task."
+ "List tasks from the kanban board. Returns "
+ "id, customer, title, status, tags, scheduled "
+ "(snooze date, YYYY-MM-DD or null), and "
+ "deadline (YYYY-MM-DD or null) for each task."
),
"input_schema": {
"type": "object",
diff --git a/tests/test_api.py b/tests/test_api.py
index 09d93f3c..f396b4ee 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -285,6 +285,65 @@ def test_create_missing_title(self, client):
)
assert r.status_code == 422
+ def test_create_rejects_deadline_before_scheduled(
+ self, client,
+ ):
+ """A snoozed-past-its-deadline task is incoherent
+ (deadline badge would fire before the snooze even
+ surfaces). The API rejects with 422 when both
+ dates are set and deadline < scheduled."""
+ r = client.post(
+ "/api/kanban/tasks",
+ json={
+ "customer": "Acme",
+ "title": "Incoherent",
+ "scheduled": "2099-06-15",
+ "deadline": "2099-06-10",
+ },
+ )
+ assert r.status_code == 422
+ assert "scheduled" in r.text.lower()
+
+ def test_create_allows_deadline_equal_scheduled(
+ self, client,
+ ):
+ """Same-day scheduled/deadline is legal — the
+ snooze surfaces and the deadline lands on the
+ same day, no contradiction."""
+ r = client.post(
+ "/api/kanban/tasks",
+ json={
+ "customer": "Acme",
+ "title": "Same day",
+ "scheduled": "2099-06-15",
+ "deadline": "2099-06-15",
+ },
+ )
+ assert r.status_code == 201
+
+ def test_update_rejects_deadline_before_scheduled(
+ self, client,
+ ):
+ """The cross-field check applies to PATCH too —
+ the user can't slip an invalid pair in via an
+ edit that sends both fields at once."""
+ r = client.post(
+ "/api/kanban/tasks",
+ json={
+ "customer": "Acme",
+ "title": "Edit me",
+ },
+ )
+ task_id = r.json()["id"]
+ r = client.patch(
+ f"/api/kanban/tasks/{task_id}",
+ json={
+ "scheduled": "2099-06-15",
+ "deadline": "2099-06-10",
+ },
+ )
+ assert r.status_code == 422
+
# ── Clocks ──────────────────────────────────────────────