diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c6a2cb..31ca6ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +- Make the `DONE` flag on task states load-bearing. The + badge in Settings → Task States is now a clickable + toggle, the backend's done-filter reads it instead of + hardcoding `{DONE, CANCELLED}`, and the kanban card's + tick icon moves a task to the **first** state flagged + done (deterministic by the user's own ordering). If no + state is flagged, the tick icon hides entirely — so + deleting the DONE column can't silently orphan tasks + into a missing status anymore. Existing profiles whose + settings have no `done` flag set fall back to the old + hardcoded set, so nothing changes for anyone who + hasn't customised. A short description above the list + explains what the toggle does. + - Refactor the cloud-WS broadcast plumbing from the post-feature audit. Single `BROADCAST_RESOURCES` constant replaces three duplicate listings of diff --git a/frontend/src/components/kanban/TaskCard.tsx b/frontend/src/components/kanban/TaskCard.tsx index 6f12fc1e..8448ab49 100644 --- a/frontend/src/components/kanban/TaskCard.tsx +++ b/frontend/src/components/kanban/TaskCard.tsx @@ -97,6 +97,15 @@ export function TaskCard({ const archiveTask = useArchiveTask(); const { data: settings } = useSettings(); const allTags = settings?.tags ?? []; + // Done-states drive both the tick icon's visibility and + // its target. First state with ``done: true`` wins; if + // none is configured the tick icon disappears entirely + // so nothing can orphan the task into a missing column. + const doneStates = (settings?.task_states ?? []) + .filter((s) => s.done) + .map((s) => s.name); + const firstDoneState = doneStates[0] ?? null; + const isStatusDone = doneStates.includes(task.status); const style = { transform: CSS.Transform.toString(transform), @@ -279,6 +288,8 @@ export function TaskCard({ {!editing && !isDragOverlay && ( + onMarkDone={() => { + if (!firstDoneState) return; markDone.mutate({ taskId: task.id, - status: "DONE", - }) - } + status: firstDoneState, + }); + }} onStartTimer={async () => { if (activeTimer?.active) { await stopClock.mutateAsync(); diff --git a/frontend/src/components/kanban/TaskCardActions.tsx b/frontend/src/components/kanban/TaskCardActions.tsx index f314206f..e2a69b64 100644 --- a/frontend/src/components/kanban/TaskCardActions.tsx +++ b/frontend/src/components/kanban/TaskCardActions.tsx @@ -14,6 +14,16 @@ import { ConfirmPopover } from "../common/ConfirmPopover"; interface TaskCardActionsProps { /** Current task status. */ status: string; + /** Whether the task is already in a configured + * ``done`` state — hides the tick icon in that case. + * Computed from the task-states settings so a custom + * workflow (e.g. ``ARCHIVED`` flagged as done) is + * respected without a code change. */ + isStatusDone: boolean; + /** Whether *any* state has the ``done`` flag set. + * When false, the tick icon is hidden — there's + * nowhere coherent to move the task. */ + hasDoneState: boolean; /** Whether a timer is already running for this task. */ isTimerRunning: boolean; /** Whether the task has a customer assigned. */ @@ -38,6 +48,8 @@ interface TaskCardActionsProps { */ export function TaskCardActions({ status, + isStatusDone, + hasDoneState, isTimerRunning, hasCustomer, isMarkDonePending, @@ -52,22 +64,26 @@ export function TaskCardActions({ const { t } = useTranslation("kanban"); const { t: tc } = useTranslation("common"); const { t: tClocks } = useTranslation("clocks"); + // ``status`` is read above for symmetry with the rest of + // the props bag, but the tick visibility is driven by + // the computed flags below. + void status; + const showMarkDone = hasDoneState && !isStatusDone; return (
- {status !== "DONE" && - status !== "CANCELLED" && ( - - )} + {showMarkDone && ( + + )} {!isTimerRunning && hasCustomer && ( s.name); return (
-
+

{t("taskStates")}

@@ -555,6 +573,9 @@ function TaskStatesSection({
+

+ {t("taskStatesHint")} +

set[str]: - """Determine done states from keywords set.""" - return {"DONE", "CANCELLED"} & keywords + """Return the configured terminal/done states. + + Reads the ``done: true`` flag from ``task_states`` in + ``settings.yaml`` so the user can choose which states + count as completed (e.g. mark ``ARCHIVED`` as done in + a custom workflow). Falls back to the hardcoded + ``{"DONE", "CANCELLED"}`` intersection only when no + settings flag is set so existing profiles without the + flag don't suddenly lose their done-filtering. + + ``keywords`` is the set of TODO-keyword names the + parser understands; the result is intersected with it + so a stale ``done: true`` for a state that was later + deleted doesn't surface as a phantom done-state. + """ + from ..config import load_settings_yaml + from . import settings as settings_svc + try: + configured = set( + settings_svc.get_done_state_names( + load_settings_yaml(), + ), + ) + except (FileNotFoundError, OSError): + configured = set() + if not configured: + configured = {"DONE", "CANCELLED"} + return configured & keywords def reorder_tasks( diff --git a/tests/test_done_state_flag.py b/tests/test_done_state_flag.py new file mode 100644 index 00000000..434a81f1 --- /dev/null +++ b/tests/test_done_state_flag.py @@ -0,0 +1,88 @@ +"""``_get_done_states`` reads the ``done: true`` flag +from ``settings.yaml`` so the user can pick which states +count as completed. + +Before, the helper hardcoded ``{"DONE", "CANCELLED"}``, +which meant the badge in Settings was decoration and the +tick icon silently moved tasks into a state that may not +exist. +""" +from unittest.mock import patch + +from kaisho.services.kanban import _get_done_states + + +def _fake_settings(states): + return {"task_states": states} + + +def test_uses_done_flag_from_settings(): + """A state flagged ``done: true`` is treated as + completed; one without the flag is not.""" + settings = _fake_settings([ + {"name": "TODO", "done": False}, + {"name": "WAIT"}, + {"name": "ARCHIVED", "done": True}, + ]) + with patch( + "kaisho.config.load_settings_yaml", + return_value=settings, + ): + result = _get_done_states( + {"TODO", "WAIT", "ARCHIVED"}, + ) + assert result == {"ARCHIVED"} + + +def test_falls_back_when_no_state_flagged(): + """Existing profiles whose settings predate the + flag should keep the historical + ``{DONE, CANCELLED}`` behaviour rather than suddenly + have nothing marked completed.""" + settings = _fake_settings([ + {"name": "TODO"}, {"name": "DONE"}, + {"name": "CANCELLED"}, + ]) + with patch( + "kaisho.config.load_settings_yaml", + return_value=settings, + ): + result = _get_done_states( + {"TODO", "DONE", "CANCELLED"}, + ) + assert result == {"DONE", "CANCELLED"} + + +def test_intersects_with_keywords(): + """A stale ``done: true`` for a state that was later + deleted (and is no longer in the parser keyword set) + must not surface as a phantom done-state.""" + settings = _fake_settings([ + {"name": "TODO", "done": False}, + {"name": "GHOST", "done": True}, + {"name": "ARCHIVED", "done": True}, + ]) + with patch( + "kaisho.config.load_settings_yaml", + return_value=settings, + ): + result = _get_done_states({"TODO", "ARCHIVED"}) + assert result == {"ARCHIVED"} + + +def test_falls_back_on_missing_settings_file(): + """Test setups that don't ship a ``settings.yaml`` + (existing backend-parity tests rely on this) must + not crash. Empty / missing settings drops through + to the legacy hardcoded set.""" + def _raise(*_a, **_kw): + raise FileNotFoundError("no settings.yaml in test") + + with patch( + "kaisho.config.load_settings_yaml", + _raise, + ): + result = _get_done_states( + {"TODO", "DONE", "CANCELLED"}, + ) + assert result == {"DONE", "CANCELLED"}