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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- 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
a rotated label and task count, the choice persists per
profile, and re-expanding restores the full column.
Drops are blocked on collapsed columns so cards can't
vanish into an invisible drop zone.

## 2.5.1

This release fixes a cluster of cloud-sync edge cases that
Expand Down
37 changes: 12 additions & 25 deletions frontend/src/components/kanban/KanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,14 @@ import { reorderTasks as apiReorderTasks } from "../../api/client";
import { useReorderStates, useSettings } from "../../hooks/useSettings";
import type { ArchivedTask, Task } from "../../types";
import { ConfirmPopover } from "../common/ConfirmPopover";
import { Toggle } from "../common/Toggle";
import { Button } from "../common/Button";
import { HelpButton } from "../common/HelpButton";
import { OpenInEditorButton } from "../common/OpenInEditorButton";
import {
profileGet,
profileSet,
} from "../../utils/profileStorage";
import { SearchInput } from "../common/SearchInput";
import { PanelToolbar } from "../common/PanelToolbar";
import { ResizeHandle } from "../common/ResizeHandle";
import { useResizableColumns } from "../../hooks/useResizableColumns";
import { useCollapsedColumns } from "../../hooks/useCollapsedColumns";
import { DOCS } from "../../docs/panelDocs";
import { TaskCard } from "./TaskCard";
import { KanbanColumn } from "./KanbanColumn";
Expand Down Expand Up @@ -347,13 +343,15 @@ export function KanbanBoard() {
const { t } = useTranslation("kanban");
const { t: tc } = useTranslation("common");
const { t: tNav } = useTranslation("nav");
const [showDone, setShowDone] = useState(
() => profileGet("board_show_done") === "true"
);
const [openAddInFirst, setOpenAddInFirst] = useState(false);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const { data: rawTasks = [], isLoading } = useTasks(showDone);
// Always fetch all states; per-column collapse hides them
// visually without re-fetching, so re-expanding is instant.
const { data: rawTasks = [], isLoading } = useTasks(true);
const {
isCollapsed, toggle: toggleCollapsed,
} = useCollapsedColumns();
const tasks = rawTasks.filter((t) => matchesSearch(t, search));
const { pendingSearch, clearPendingSearch } = usePendingSearch();

Expand Down Expand Up @@ -406,8 +404,7 @@ export function KanbanBoard() {
})
);

const states =
settings?.task_states.filter((s) => showDone || !s.done) ?? [];
const states = settings?.task_states ?? [];
const colCount = states.length;

// Responsive column width: snap to 2, 3, or all columns
Expand Down Expand Up @@ -632,20 +629,6 @@ export function KanbanBoard() {
</div>
</>}
right={<>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-xs text-fg-muted">
{t("showDone")}
</span>
<Toggle
checked={showDone}
onChange={(v) => {
profileSet(
"board_show_done", String(v),
);
setShowDone(v);
}}
/>
</label>
<Button
variant="tonal"
size="sm"
Expand Down Expand Up @@ -695,6 +678,10 @@ export function KanbanBoard() {
mergeSearchToken(s, "customer", c)
)
}
collapsed={isCollapsed(state.name)}
onToggleCollapsed={
() => toggleCollapsed(state.name)
}
/>
))}
</div>
Expand Down
104 changes: 103 additions & 1 deletion frontend/src/components/kanban/KanbanColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, X, Check, GripVertical } from "lucide-react";
import {
Plus, X, Check, GripVertical,
ChevronLeft, ChevronRight,
} from "lucide-react";
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CustomerAutocomplete } from "../common/CustomerAutocomplete";
Expand All @@ -19,8 +22,17 @@ interface KanbanColumnProps {
onAddOpened?: () => void;
onTagClick?: (tag: string) => void;
onCustomerClick?: (customer: string) => void;
/** Collapse state, persisted per profile by the parent. */
collapsed?: boolean;
onToggleCollapsed?: () => void;
}

/** Width of a collapsed column in px. Wide enough for the
* vertical title + count chip + expand chevron, narrow
* enough that several collapsed columns side by side still
* leave most of the board for the expanded ones. */
const COLLAPSED_WIDTH = 40;

export function KanbanColumn({
state,
tasks,
Expand All @@ -29,6 +41,8 @@ export function KanbanColumn({
onTagClick,
onCustomerClick,
columnWidth,
collapsed = false,
onToggleCollapsed,
}: KanbanColumnProps) {
const { t } = useTranslation("kanban");
const {
Expand Down Expand Up @@ -89,6 +103,84 @@ export function KanbanColumn({
if (e.key === "Escape") setAdding(false);
}

if (collapsed) {
// Compact strip. The header row mirrors the open
// column's ``flex items-center gap-2 mb-3 px-1`` so
// the expand chevron lines up vertically with the
// collapse chevrons on the open columns next to it.
// The body below mirrors the open column's drop zone
// — dashed border, rounded corners, same background —
// but holds the dot + count chip + rotated label
// instead of cards. No drop target on purpose: cards
// must not vanish into an invisible column.
return (
<div
ref={setNodeRef}
style={{
...style,
width: COLLAPSED_WIDTH,
minWidth: COLLAPSED_WIDTH,
}}
className={[
"flex flex-col shrink-0 h-full min-h-0",
isDragging ? "opacity-40" : "",
]
.filter(Boolean)
.join(" ")}
>
{/* Header: same Y as the open column's header */}
<div className="flex items-center justify-center mb-3 px-1">
<button
onClick={onToggleCollapsed}
className={
"p-1 rounded-md text-fg-muted hover:text-fg "
+ "hover:bg-surface-raised transition-colors"
}
title={t("expandColumn")}
>
<ChevronRight size={13} strokeWidth={2} />
</button>
</div>
{/* Body: matches the open column's drop-zone */}
<div
className={[
"flex flex-col items-center gap-3 p-2",
"rounded-lg border border-dashed",
"border-border-subtle bg-surface-card/30",
"flex-1 min-h-0",
].join(" ")}
>
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: state.color }}
/>
<span
className={[
"px-1.5 py-0.5 rounded text-2xs font-semibold",
"bg-surface-raised text-fg-muted",
"border border-border-subtle",
].join(" ")}
>
{tasks.length}
</span>
<div
{...attributes}
{...listeners}
className={
"[writing-mode:vertical-rl] rotate-180 "
+ "text-xs font-semibold tracking-wider "
+ "uppercase text-fg cursor-grab "
+ "active:cursor-grabbing select-none"
}
title={t("dragToReorder")}
>
{state.label || state.name}
</div>
</div>
</div>
);
}

return (
<div
ref={setNodeRef}
Expand Down Expand Up @@ -143,6 +235,16 @@ export function KanbanColumn({
>
<Plus size={13} strokeWidth={2} />
</button>
<button
onClick={onToggleCollapsed}
className={
"p-1 rounded-md text-fg-muted hover:text-fg "
+ "hover:bg-surface-raised transition-colors"
}
title={t("collapseColumn")}
>
<ChevronLeft size={13} strokeWidth={2} />
</button>
</div>

{/* Drop zone */}
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/hooks/useCollapsedColumns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Per-profile persistence for collapsed kanban columns.
*
* Replaces the old single ``board_show_done`` flag. Now
* every column can be collapsed independently and the
* choice persists across reloads.
*
* Storage shape: a JSON array of task-state names (e.g.
* ``["DONE", "CANCELLED"]``) under
* ``board_collapsed_columns``. Profile-scoped via
* ``profileGet`` / ``profileSet`` so switching profiles
* gives each its own layout.
*
* A storage event-style custom event lets multiple
* components reading the same state stay in sync without
* a context provider — overkill for one consumer today,
* but the board renders many columns and tomorrow the
* dashboard may want the same.
*/
import { useCallback, useEffect, useState } from "react";

import {
profileGet,
profileSet,
} from "../utils/profileStorage";

const STORAGE_KEY = "board_collapsed_columns";
const CHANGE_EVENT = "collapsed-columns-change";

function load(): Set<string> {
const raw = profileGet(STORAGE_KEY);
if (!raw) return new Set();
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return new Set(
parsed.filter(
(item): item is string => typeof item === "string",
),
);
}
} catch {
// Corrupted value — fall through to a fresh set so
// the board never crashes on malformed local data.
}
return new Set();
}

function persist(state: Set<string>): void {
profileSet(
STORAGE_KEY, JSON.stringify(Array.from(state)),
);
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
}

export function useCollapsedColumns(): {
isCollapsed: (name: string) => boolean;
toggle: (name: string) => void;
} {
const [state, setState] = useState<Set<string>>(load);

useEffect(() => {
function onChange() {
setState(load());
}
window.addEventListener(CHANGE_EVENT, onChange);
return () => {
window.removeEventListener(CHANGE_EVENT, onChange);
};
}, []);

const isCollapsed = useCallback(
(name: string) => state.has(name),
[state],
);

const toggle = useCallback((name: string) => {
const next = new Set(state);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
persist(next);
setState(next);
}, [state]);

return { isCollapsed, toggle };
}
3 changes: 2 additions & 1 deletion frontend/src/locales/de/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"board": "Board",
"loadingTasks": "Aufgaben laden...",
"newTask": "Neue Aufgabe (Doppeltipp B)",
"showDone": "Erledigte anzeigen",
"collapseColumn": "Spalte einklappen",
"expandColumn": "Spalte ausklappen",
"empty": "Leer",
"addTask": "Aufgabe hinzuf\u00fcgen",
"taskTitle": "Aufgabentitel",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/locales/en/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"board": "Board",
"loadingTasks": "Loading tasks...",
"newTask": "New task (double-tap B)",
"showDone": "Show done",
"collapseColumn": "Collapse column",
"expandColumn": "Expand column",
"empty": "Empty",
"addTask": "Add task",
"taskTitle": "Task title",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/locales/es/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"board": "Tablero",
"loadingTasks": "Cargando tareas...",
"newTask": "Nueva tarea (doble clic en B)",
"showDone": "Mostrar completadas",
"collapseColumn": "Colapsar columna",
"expandColumn": "Expandir columna",
"empty": "Vac\u00edo",
"addTask": "A\u00f1adir tarea",
"taskTitle": "T\u00edtulo de la tarea",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/locales/ru/kanban.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"board": "Доска",
"loadingTasks": "Загрузка задач...",
"newTask": "Новая задача (двойное нажатие B)",
"showDone": "Показать выполненные",
"collapseColumn": "Свернуть колонку",
"expandColumn": "Развернуть колонку",
"empty": "Пусто",
"addTask": "Добавить задачу",
"taskTitle": "Название задачи",
Expand Down
Loading