Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions frontend/src/components/kanban/TaskEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -155,6 +172,11 @@ export function TaskEditForm({
/>
</label>
</div>
{datesOutOfOrder && (
<p className="text-2xs text-red-500 px-0.5">
{t("datesOutOfOrder")}
</p>
)}
<div
onPointerDown={(e) => e.stopPropagation()}
>
Expand All @@ -177,8 +199,8 @@ export function TaskEditForm({
</button>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={onSave}
disabled={isSaving}
onClick={handleSave}
disabled={isSaving || datesOutOfOrder}
className="p-1 text-cta hover:bg-cta-muted rounded disabled:opacity-40"
>
<Check size={12} />
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/de/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/en/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/es/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/ru/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"newTask": "Новая задача (двойное нажатие B)",
"collapseColumn": "Свернуть колонку",
"expandColumn": "Развернуть колонку",
"datesOutOfOrder": "Срок должен быть не раньше запланированной даты.",
"scheduledLabel": "Запланировано",
"deadlineLabel": "Срок",
"snoozedCount": "{{count}} отложено",
Expand Down
46 changes: 45 additions & 1 deletion kaisho/api/routers/kanban.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
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

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
Expand All @@ -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
Expand All @@ -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]
Expand Down
26 changes: 24 additions & 2 deletions kaisho/cli/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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 "")
Expand All @@ -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))
Expand Down Expand Up @@ -187,16 +201,24 @@ 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,
title=title,
customer=customer,
body=body,
github_url=github_url,
scheduled=scheduled,
deadline=deadline,
)
if as_json:
click.echo(json.dumps(result, default=str))
Expand Down
6 changes: 4 additions & 2 deletions kaisho/cron/tool_defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────

Expand Down
Loading