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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/components/kanban/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -279,6 +288,8 @@ export function TaskCard({
{!editing && !isDragOverlay && (
<TaskCardActions
status={task.status}
isStatusDone={isStatusDone}
hasDoneState={!!firstDoneState}
isTimerRunning={isTimerRunning}
hasCustomer={!!task.customer}
isMarkDonePending={markDone.isPending}
Expand All @@ -287,12 +298,13 @@ export function TaskCard({
}
isStopClockPending={stopClock.isPending}
isArchivePending={archiveTask.isPending}
onMarkDone={() =>
onMarkDone={() => {
if (!firstDoneState) return;
markDone.mutate({
taskId: task.id,
status: "DONE",
})
}
status: firstDoneState,
});
}}
onStartTimer={async () => {
if (activeTimer?.active) {
await stopClock.mutateAsync();
Expand Down
44 changes: 30 additions & 14 deletions frontend/src/components/kanban/TaskCardActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -38,6 +48,8 @@ interface TaskCardActionsProps {
*/
export function TaskCardActions({
status,
isStatusDone,
hasDoneState,
isTimerRunning,
hasCustomer,
isMarkDonePending,
Expand All @@ -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 (
<div className="flex flex-col items-center gap-1 px-1 py-2 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{status !== "DONE" &&
status !== "CANCELLED" && (
<button
onPointerDown={(e) =>
e.stopPropagation()
}
onClick={onMarkDone}
disabled={isMarkDonePending}
className="p-1 rounded text-fg-subtle hover:text-green-500 hover:bg-green-500/10 transition-colors disabled:opacity-40"
title={t("markAsDone")}
>
<Check size={11} />
</button>
)}
{showMarkDone && (
<button
onPointerDown={(e) =>
e.stopPropagation()
}
onClick={onMarkDone}
disabled={isMarkDonePending}
className="p-1 rounded text-fg-subtle hover:text-green-500 hover:bg-green-500/10 transition-colors disabled:opacity-40"
title={t("markAsDone")}
>
<Check size={11} />
</button>
)}
{!isTimerRunning && hasCustomer && (
<button
onPointerDown={(e) =>
Expand Down
33 changes: 27 additions & 6 deletions frontend/src/components/settings/TagsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ function TaskStateRow({
state: TaskState;
isLast: boolean;
}) {
const { t } = useTranslation("settings");
const update = useUpdateState();
const toast = useToast();
const remove = useDeleteState();
Expand All @@ -315,6 +316,13 @@ function TaskStateRow({
}
}

function toggleDone() {
update.mutate({
name: state.name,
updates: { done: !state.done },
});
}

function handleDelete() {
remove.mutate(state.name, {
onError: (err: unknown) => {
Expand Down Expand Up @@ -360,11 +368,21 @@ function TaskStateRow({
e.currentTarget.blur();
}}
/>
{state.done && (
<span className="text-2xs font-semibold uppercase text-fg-muted bg-surface-raised px-1.5 py-0.5 rounded shrink-0">
done
</span>
)}
<button
type="button"
onClick={toggleDone}
className={[
"text-2xs font-semibold uppercase",
"px-1.5 py-0.5 rounded shrink-0",
"transition-colors border",
state.done
? "bg-green-500/10 text-green-600 border-green-500/20 hover:bg-green-500/20"
: "bg-surface-raised text-fg-subtle border-border-subtle hover:text-fg hover:bg-surface-card",
].join(" ")}
title={t("doneStateToggleTooltip")}
>
{t("doneStateBadge")}
</button>
<ConfirmPopover
onConfirm={handleDelete}
disabled={remove.isPending}
Expand Down Expand Up @@ -543,7 +561,7 @@ function TaskStatesSection({
const existingNames = states.map((s) => s.name);
return (
<section>
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center gap-3 mb-1">
<h2 className="text-xs font-semibold tracking-wider uppercase text-fg-muted">
{t("taskStates")}
</h2>
Expand All @@ -555,6 +573,9 @@ function TaskStatesSection({
<Plus size={12} />
</button>
</div>
<p className="text-2xs text-fg-muted mb-3 leading-relaxed">
{t("taskStatesHint")}
</p>
<div className="bg-surface-card rounded-lg border border-border overflow-hidden">
<SortableList
items={states}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
"resetLocalPreferences": "Lokale Einstellungen zurücksetzen",
"clearLocalPreferencesConfirm": "Alle lokalen Einstellungen löschen und neu laden?",
"taskStates": "Aufgabenstatus",
"taskStatesHint": "Aktiviere ERLEDIGT für jeden Status, der als abgeschlossen zählt. Das Häkchen-Symbol im Kanban verschiebt eine Aufgabe in den ersten so markierten Status; ohne Markierung verschwindet das Häkchen.",
"doneStateBadge": "ERLEDIGT",
"doneStateToggleTooltip": "Klicken, um umzuschalten, ob dieser Status als abgeschlossen zählt",
"tags": "Tags",
"addTag": "Tag hinzufügen",
"noTagsDefined": "Keine Tags definiert.",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
"resetLocalPreferences": "Reset local preferences",
"clearLocalPreferencesConfirm": "Clear all local preferences and reload?",
"taskStates": "Task States",
"taskStatesHint": "Toggle DONE on the states that count as completed. The kanban's tick icon moves a task to the first state flagged DONE; if none is flagged, the tick disappears.",
"doneStateBadge": "DONE",
"doneStateToggleTooltip": "Click to toggle whether this state counts as completed",
"tags": "Tags",
"addTag": "Add tag",
"noTagsDefined": "No tags defined.",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
"resetLocalPreferences": "Restablecer preferencias locales",
"clearLocalPreferencesConfirm": "¿Borrar todas las preferencias locales y recargar?",
"taskStates": "Estados de tarea",
"taskStatesHint": "Activa COMPLETADO en los estados que cuenten como finalizados. El icono de tick del kanban mueve la tarea al primer estado marcado COMPLETADO; sin ninguno marcado, el tick desaparece.",
"doneStateBadge": "COMPLETADO",
"doneStateToggleTooltip": "Haz clic para alternar si este estado cuenta como completado",
"tags": "Etiquetas",
"addTag": "Añadir etiqueta",
"noTagsDefined": "No hay etiquetas definidas.",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
"resetLocalPreferences": "Сбросить локальные настройки",
"clearLocalPreferencesConfirm": "Очистить все локальные настройки и перезагрузить?",
"taskStates": "Состояния задач",
"taskStatesHint": "Включите ВЫПОЛНЕНО для состояний, которые считаются завершёнными. Галочка на канбане переводит задачу в первое такое состояние; если ни одно не помечено, галочка исчезает.",
"doneStateBadge": "ВЫПОЛНЕНО",
"doneStateToggleTooltip": "Нажмите, чтобы переключить, считается ли это состояние завершённым",
"tags": "Теги",
"addTag": "Добавить тег",
"noTagsDefined": "Теги не заданы.",
Expand Down
30 changes: 28 additions & 2 deletions kaisho/services/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,34 @@ def _matches_filter(


def _get_done_states(keywords: set[str]) -> 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(
Expand Down
88 changes: 88 additions & 0 deletions tests/test_done_state_flag.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading