@anchrd/intel-ui 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +10 -2
  2. package/src/board/board-calendar/board-calendar.tsx +89 -0
  3. package/src/board/board-card/board-card.tsx +106 -0
  4. package/src/board/board-data/board-data.ts +241 -0
  5. package/src/board/board-data/board-data.types.ts +63 -0
  6. package/src/board/board-detail/board-detail.tsx +629 -0
  7. package/src/board/board-gantt/board-gantt.ts +545 -0
  8. package/src/board/board-gantt/board-gantt.tsx +286 -0
  9. package/src/board/board-graph/board-graph.ts +174 -0
  10. package/src/board/board-graph/board-graph.tsx +168 -0
  11. package/src/board/board-items/board-items.ts +183 -0
  12. package/src/board/board-kanban/board-kanban.ts +97 -0
  13. package/src/board/board-kanban/board-kanban.tsx +211 -0
  14. package/src/board/board-status/board-status.ts +59 -0
  15. package/src/board/board-statuses/board-statuses.ts +63 -0
  16. package/src/board/board-statuses/board-statuses.tsx +228 -0
  17. package/src/board/board-table/board-table.ts +33 -0
  18. package/src/board/board-table/board-table.tsx +413 -0
  19. package/src/board/board-views/board-views.tsx +68 -0
  20. package/src/board/board-views/board-views.types.ts +29 -0
  21. package/src/board/board.tsx +251 -0
  22. package/src/components/ui/dropdown-menu.tsx +25 -0
  23. package/src/components/ui/item-calendar.tsx +181 -0
  24. package/src/components/ui/item-gantt.tsx +463 -0
  25. package/src/components/ui/kanban.tsx +245 -0
  26. package/src/components/ui/switch.tsx +25 -0
  27. package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
  28. package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
  29. package/src/i18n/de.json +86 -1
  30. package/src/i18n/en.json +86 -1
  31. package/src/i18n/es.json +86 -1
  32. package/src/nodes/nodes.tsx +16 -0
  33. package/src/styles.css +33 -0
@@ -0,0 +1,228 @@
1
+ import { ArchivedBoardStatusId } from "@anchrd/intel-contract";
2
+ import { ChevronDown, ChevronUp, X } from "lucide-react";
3
+ import { useState } from "react";
4
+ import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import { useI18n } from "@/i18n/i18n-context.tsx";
13
+ import { configurableStatuses, statusIdFrom } from "./board-statuses.ts";
14
+
15
+ /**
16
+ * The status list, edited (anchrd/intel#286, `board_configure`).
17
+ *
18
+ * ⚠️ The whole list is written at once and never patched — adding, renaming and reordering are one
19
+ * call, because the order IS the list's order (#285). So the dialog holds a draft and sends it on
20
+ * save; a control that wrote per keystroke would write a version per letter.
21
+ *
22
+ * ⚠️ `archived` can be renamed and cannot be removed or moved. The contract refuses a list without
23
+ * it, and a shelf that could be dragged into the middle of the working columns would be a shelf
24
+ * pretending to be a stage.
25
+ */
26
+ export function BoardStatuses({ board, onClose }: { board: BoardHandle; onClose(): void }) {
27
+ const i18n = useI18n();
28
+ // ⚠️ Seeded once per OPENING, which is why the caller mounts this only while it is open
29
+ // (`board.tsx`) instead of handing it an `open` flag. Radix never reports `onOpenChange(true)`
30
+ // for a dialog whose openness is decided outside it, so a re-seed hung off that callback would be
31
+ // dead code — and the draft would then be whatever the list looked like the first time anybody
32
+ // opened this board. Saving it would write that stale list WHOLE and revert a column somebody
33
+ // else had added in between.
34
+ const [draft, setDraft] = useState(() => configurableStatuses(board.statuses));
35
+ const [added, setAdded] = useState("");
36
+ // ⚠️ `board.configure` lives in `useBoard` and outlives this dialog, so its `isError` is still set
37
+ // the next time the dialog is opened — a refusal from ten minutes ago greeting a list nobody has
38
+ // tried to save yet. A local flag rather than `configure.reset()`, because resetting shared state
39
+ // from a mount is a side effect on something this dialog does not own.
40
+ const [attempted, setAttempted] = useState(false);
41
+
42
+ const working = draft.filter((status) => status.id !== ArchivedBoardStatusId);
43
+ const shelf = draft.find((status) => status.id === ArchivedBoardStatusId);
44
+
45
+ const move = (index: number, delta: number) => {
46
+ const target = index + delta;
47
+ if (target < 0 || target >= working.length) return;
48
+ const next = [...working];
49
+ const [item] = next.splice(index, 1);
50
+ if (item) next.splice(target, 0, item);
51
+ setDraft([...next, ...(shelf ? [shelf] : [])]);
52
+ };
53
+
54
+ return (
55
+ <Dialog open onOpenChange={(next) => !next && onClose()}>
56
+ <DialogContent>
57
+ <DialogHeader>
58
+ <DialogTitle>{i18n.t("board.statuses")}</DialogTitle>
59
+ </DialogHeader>
60
+ <ul className="flex flex-col gap-2">
61
+ {working.map((status, index) => (
62
+ <li key={status.id} className="flex items-center gap-2">
63
+ <input
64
+ value={status.label}
65
+ aria-label={i18n.t("board.statusLabel")}
66
+ onChange={(event) => {
67
+ const label = event.currentTarget.value;
68
+ setDraft((current) =>
69
+ current.map((entry) => (entry.id === status.id ? { ...entry, label } : entry)),
70
+ );
71
+ }}
72
+ className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
73
+ />
74
+ {/* ⚠️ Which columns mean finished is a property of the column, not its position
75
+ (anchrd/intel#311) — so it is set here, per column, and several may carry it. It is
76
+ what decides whether a task waiting on this one is still blocked. */}
77
+ <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
78
+ <input
79
+ type="checkbox"
80
+ checked={status.terminal}
81
+ onChange={(event) => {
82
+ const terminal = event.currentTarget.checked;
83
+ setDraft((current) =>
84
+ current.map((entry) =>
85
+ entry.id === status.id ? { ...entry, terminal } : entry,
86
+ ),
87
+ );
88
+ }}
89
+ className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
90
+ />
91
+ {i18n.t("board.statusTerminal")}
92
+ </label>
93
+ <button
94
+ type="button"
95
+ aria-label={i18n.t("board.statusUp")}
96
+ disabled={index === 0}
97
+ onClick={() => move(index, -1)}
98
+ className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
99
+ >
100
+ <ChevronUp aria-hidden="true" className="size-4" />
101
+ </button>
102
+ <button
103
+ type="button"
104
+ aria-label={i18n.t("board.statusDown")}
105
+ disabled={index === working.length - 1}
106
+ onClick={() => move(index, 1)}
107
+ className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
108
+ >
109
+ <ChevronDown aria-hidden="true" className="size-4" />
110
+ </button>
111
+ <button
112
+ type="button"
113
+ aria-label={i18n.t("board.statusRemove", { label: status.label })}
114
+ // A column with tasks still in it cannot be taken away: the server refuses a task
115
+ // in a status the board does not have, so removing it would make the board
116
+ // unwritable rather than tidy.
117
+ disabled={board.tasks.some((task) => task.status === status.id)}
118
+ onClick={() =>
119
+ setDraft((current) => current.filter((entry) => entry.id !== status.id))
120
+ }
121
+ className="grid size-8 place-items-center rounded-md border text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
122
+ >
123
+ <X aria-hidden="true" className="size-4" />
124
+ </button>
125
+ </li>
126
+ ))}
127
+ {shelf ? (
128
+ <li className="flex items-center gap-2">
129
+ <input
130
+ value={shelf.label}
131
+ aria-label={i18n.t("board.statusLabel")}
132
+ onChange={(event) => {
133
+ const label = event.currentTarget.value;
134
+ setDraft((current) =>
135
+ current.map((entry) =>
136
+ entry.id === ArchivedBoardStatusId ? { ...entry, label } : entry,
137
+ ),
138
+ );
139
+ }}
140
+ className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
141
+ />
142
+ <span className="text-xs text-muted-foreground">{i18n.t("board.shelfFixed")}</span>
143
+ {/* The shelf's flag is shown and cannot be turned off: the contract refuses an explicit
144
+ `false` for it, and a shelf that did not mean finished would hold every archived
145
+ task open as a blocker (anchrd/intel#311). */}
146
+ <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
147
+ <input
148
+ type="checkbox"
149
+ checked
150
+ disabled
151
+ aria-label={i18n.t("board.statusTerminal")}
152
+ className="size-4 rounded border"
153
+ />
154
+ {i18n.t("board.statusTerminal")}
155
+ </label>
156
+ </li>
157
+ ) : null}
158
+ </ul>
159
+ <form
160
+ className="flex gap-2"
161
+ onSubmit={(event) => {
162
+ event.preventDefault();
163
+ const label = added.trim();
164
+ if (label === "") return;
165
+ setDraft((current) => {
166
+ const taken = new Set(current.map((entry) => entry.id));
167
+ const shelfEntry = current.find((entry) => entry.id === ArchivedBoardStatusId);
168
+ return [
169
+ ...current.filter((entry) => entry.id !== ArchivedBoardStatusId),
170
+ // A new column is work, not the end of it. Meaning "finished" is something somebody
171
+ // says on purpose, with the checkbox beside it.
172
+ { id: statusIdFrom(label, taken), label, terminal: false },
173
+ ...(shelfEntry ? [shelfEntry] : []),
174
+ ];
175
+ });
176
+ setAdded("");
177
+ }}
178
+ >
179
+ <input
180
+ value={added}
181
+ onChange={(event) => setAdded(event.currentTarget.value)}
182
+ placeholder={i18n.t("board.addStatus")}
183
+ aria-label={i18n.t("board.addStatus")}
184
+ className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
185
+ />
186
+ <button
187
+ type="submit"
188
+ className="h-8 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
189
+ >
190
+ {i18n.t("common.create")}
191
+ </button>
192
+ </form>
193
+ {attempted && board.configure.isError ? (
194
+ <p role="alert" className="text-sm text-destructive">
195
+ {i18n.t("node.operationFailed")}
196
+ </p>
197
+ ) : null}
198
+ <DialogFooter>
199
+ <button
200
+ type="button"
201
+ onClick={onClose}
202
+ className="h-9 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
203
+ >
204
+ {i18n.t("common.close")}
205
+ </button>
206
+ <button
207
+ type="button"
208
+ disabled={board.configure.isPending || draft.some((entry) => entry.label.trim() === "")}
209
+ onClick={() => {
210
+ setAttempted(true);
211
+ board.configure.mutate(
212
+ draft.map((entry) => ({
213
+ id: entry.id,
214
+ label: entry.label.trim(),
215
+ terminal: entry.terminal,
216
+ })),
217
+ { onSuccess: onClose },
218
+ );
219
+ }}
220
+ className="h-9 rounded-md bg-primary px-3 text-sm text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
221
+ >
222
+ {board.configure.isPending ? i18n.t("common.saving") : i18n.t("common.save")}
223
+ </button>
224
+ </DialogFooter>
225
+ </DialogContent>
226
+ </Dialog>
227
+ );
228
+ }
@@ -0,0 +1,33 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract";
2
+ import { rootedParents } from "@/board/board-items/board-items.ts";
3
+
4
+ // A task with the tasks under it, which is the shape `getSubRows` reads. `subRows` is absent rather
5
+ // than empty for a leaf: TanStack treats an empty array as "expandable, with nothing in it" and
6
+ // would draw a chevron that opens onto nothing.
7
+ export interface BoardRow extends BoardTask {
8
+ subRows?: BoardRow[];
9
+ }
10
+
11
+ /**
12
+ * The board as the tree the table draws (anchrd/intel#286).
13
+ *
14
+ * ⚠️ Which task a row hangs under is `rootedParents`, shared with Gantt (anchrd/intel#293) rather
15
+ * than kept here. That walk carries two subtleties — an orphan becomes a root instead of vanishing
16
+ * with its hidden parent, and a cycle is broken at the task that closes it — and two copies of those
17
+ * are two copies that drift, which would show as a task that is a root in one view and a child in
18
+ * the other.
19
+ */
20
+ export function boardRows(tasks: BoardTask[]): BoardRow[] {
21
+ const parents = rootedParents(tasks);
22
+ const rows = new Map<string, BoardRow>(tasks.map((task) => [task.id, { ...task }]));
23
+ const roots: BoardRow[] = [];
24
+ for (const task of tasks) {
25
+ const row = rows.get(task.id);
26
+ if (!row) continue;
27
+ const parent = parents.get(task.id) ?? null;
28
+ const target = parent === null ? undefined : rows.get(parent);
29
+ if (target === undefined) roots.push(row);
30
+ else target.subRows = [...(target.subRows ?? []), row];
31
+ }
32
+ return roots;
33
+ }
@@ -0,0 +1,413 @@
1
+ import {
2
+ type ColumnFiltersState,
3
+ type ColumnVisibilityState,
4
+ columnFilteringFeature,
5
+ columnGroupingFeature,
6
+ columnVisibilityFeature,
7
+ createColumnHelper,
8
+ createExpandedRowModel,
9
+ createFilteredRowModel,
10
+ createGroupedRowModel,
11
+ createSortedRowModel,
12
+ type ExpandedState,
13
+ filterFn_includesString,
14
+ type GroupingState,
15
+ rowExpandingFeature,
16
+ rowSortingFeature,
17
+ type SortingState,
18
+ sortFn_alphanumeric,
19
+ sortFn_basic,
20
+ tableFeatures,
21
+ useTable,
22
+ } from "@tanstack/react-table";
23
+ import { ChevronDown, ChevronRight, Columns3, Search } from "lucide-react";
24
+ import { useMemo, useState } from "react";
25
+ import { BoardAssigneeLabel } from "@/board/board-card/board-card.tsx";
26
+ import { shownTasks } from "@/board/board-data/board-data.ts";
27
+ import { labelOf, toneOf } from "@/board/board-status/board-status.ts";
28
+ import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
29
+ import {
30
+ DropdownMenu,
31
+ DropdownMenuCheckboxItem,
32
+ DropdownMenuContent,
33
+ DropdownMenuTrigger,
34
+ } from "@/components/ui/dropdown-menu";
35
+ import { useI18n } from "@/i18n/i18n-context.tsx";
36
+ import { type BoardRow, boardRows } from "./board-table.ts";
37
+
38
+ // ⚠️ Registered one by one rather than through `stockFeatures`. Every feature the board's table does
39
+ // not use — pagination, selection, pinning, resizing, faceting — would otherwise be in the bundle
40
+ // of a screen that never calls it, and the list below is also the honest answer to "what can this
41
+ // table do".
42
+ const features = tableFeatures({
43
+ columnFilteringFeature,
44
+ columnGroupingFeature,
45
+ columnVisibilityFeature,
46
+ rowExpandingFeature,
47
+ rowSortingFeature,
48
+ // ⚠️ A row-model slot has to stand AFTER the feature it needs. Filtering before grouping and
49
+ // expanding last is the order the rows actually pass through.
50
+ filteredRowModel: createFilteredRowModel(),
51
+ sortedRowModel: createSortedRowModel(),
52
+ groupedRowModel: createGroupedRowModel(),
53
+ expandedRowModel: createExpandedRowModel(),
54
+ filterFns: { includesString: filterFn_includesString },
55
+ sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
56
+ });
57
+
58
+ const helper = createColumnHelper<typeof features, BoardRow>();
59
+
60
+ // The one place a grouping is offered. Not free-form: a board has fixed fields (#285) and there is
61
+ // no UI to add one, so grouping by anything else would be grouping by something nobody can create.
62
+ const groupBy = ["none", "status", "assignee"] as const;
63
+ type GroupBy = (typeof groupBy)[number];
64
+
65
+ /**
66
+ * The board as a table (anchrd/intel#286).
67
+ *
68
+ * ⚠️ The hierarchy is `getSubRows` and nothing else. Epic, task and subtask are a DEPTH in this
69
+ * schema and not three types (#285), so the table has one row shape and indents it by
70
+ * `row.depth` — there is no second column saying which of the three a row is, because the schema
71
+ * has no such fact to show.
72
+ *
73
+ * ⚠️ The columns are fixed and there is no UI to add one. A board is not a table node (`kind:
74
+ * "table"`), and a custom-field editor here would be a second, weaker version of the thing Intel
75
+ * already has for exactly that.
76
+ */
77
+ export function BoardTable({ board, select, showArchived }: BoardViewProps) {
78
+ const i18n = useI18n();
79
+ const [sorting, setSorting] = useState<SortingState>([]);
80
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
81
+ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({});
82
+ const [expanded, setExpanded] = useState<ExpandedState>(true);
83
+ const [grouping, setGrouping] = useState<GroupingState>([]);
84
+
85
+ const data = useMemo(
86
+ () => boardRows(shownTasks(board.tasks, showArchived)),
87
+ [board.tasks, showArchived],
88
+ );
89
+
90
+ const columns = useMemo(
91
+ () =>
92
+ helper.columns([
93
+ helper.accessor("title", {
94
+ id: "title",
95
+ header: i18n.t("common.title"),
96
+ filterFn: "includesString",
97
+ sortFn: "alphanumeric",
98
+ // Grouping by the title would make one group per row, which is the table again with an
99
+ // extra click in front of it.
100
+ enableGrouping: false,
101
+ }),
102
+ helper.accessor((task) => labelOf(task.status, board.statuses), {
103
+ id: "status",
104
+ header: i18n.t("board.column.status"),
105
+ sortFn: "alphanumeric",
106
+ }),
107
+ helper.accessor(
108
+ // ⚠️ Grouped by a STABLE key, never by the rendered name. Most person ids cannot be
109
+ // resolved to a name in this browser (`useUserName`), so grouping by what is drawn would
110
+ // put every unnamed person into one group called "assigned".
111
+ (task) =>
112
+ task.assignee === null
113
+ ? ""
114
+ : task.assignee.type === "agent"
115
+ ? `agent:${task.assignee.nodeId}`
116
+ : `user:${task.assignee.id}`,
117
+ {
118
+ id: "assignee",
119
+ header: i18n.t("board.column.assignee"),
120
+ sortFn: "alphanumeric",
121
+ cell: ({ row }) =>
122
+ row.original.assignee === null ? (
123
+ <span className="text-muted-foreground">{i18n.t("board.unassigned")}</span>
124
+ ) : (
125
+ <BoardAssigneeLabel assignee={row.original.assignee} />
126
+ ),
127
+ },
128
+ ),
129
+ /**
130
+ * ⚠️ `undefined` for a task with no due date, and never `null`.
131
+ *
132
+ * `sortUndefined` only fires on `undefined` (`createSortedRowModel`), so with `null` the
133
+ * dateless rows fall through to the sort function — where `null < "2026-08-19"` and
134
+ * `null > "2026-08-19"` are both false. That makes the comparator report "equal" for pairs
135
+ * that are not, which is not a comparator at all: the sort then produces an order that
136
+ * depends on where the dateless rows happened to sit. Observed as a "sorted" column reading
137
+ * 8/6, 8/19, —, 8/14.
138
+ *
139
+ * An ISO date sorts correctly as a string, so `basic` is the whole of the comparison.
140
+ */
141
+ helper.accessor((task) => task.dueDate ?? undefined, {
142
+ id: "dueDate",
143
+ header: i18n.t("board.column.dueDate"),
144
+ sortFn: "basic",
145
+ sortUndefined: "last",
146
+ enableGrouping: false,
147
+ cell: ({ row }) => {
148
+ const value = row.original.dueDate;
149
+ return value === null ? (
150
+ <span className="text-muted-foreground">—</span>
151
+ ) : (
152
+ <time dateTime={value} className="tabular-nums">
153
+ {new Date(`${value}T00:00:00`).toLocaleDateString(i18n.locale)}
154
+ </time>
155
+ );
156
+ },
157
+ }),
158
+ helper.accessor((task) => task.labels.join(", "), {
159
+ id: "labels",
160
+ header: i18n.t("board.column.labels"),
161
+ filterFn: "includesString",
162
+ enableGrouping: false,
163
+ enableSorting: false,
164
+ }),
165
+ ]),
166
+ [i18n, board.statuses],
167
+ );
168
+
169
+ const table = useTable({
170
+ features,
171
+ columns,
172
+ data,
173
+ // The hierarchy the DoD asks for, in one option. Everything else about depth — the indent, the
174
+ // chevron, which rows exist at all — follows from it.
175
+ getSubRows: (row: BoardRow) => row.subRows,
176
+ getRowId: (row: BoardRow) => row.id,
177
+ state: { sorting, columnFilters, columnVisibility, expanded, grouping },
178
+ onSortingChange: setSorting,
179
+ onColumnFiltersChange: setColumnFilters,
180
+ onColumnVisibilityChange: setColumnVisibility,
181
+ onExpandedChange: setExpanded,
182
+ onGroupingChange: setGrouping,
183
+ /**
184
+ * ⚠️ Off, and it has to be off.
185
+ *
186
+ * TanStack resets the expanded state whenever the row structure changes, and on a board the row
187
+ * structure changes constantly: every card edit, every drag and every archived-switch flip
188
+ * hands `data` a new array. With the reset on, opening an epic and then renaming anything on the
189
+ * board closes it again — and turning grouping on collapsed every group the moment it appeared,
190
+ * which is how this was found (the groups in the first screenshot were shut).
191
+ */
192
+ autoResetExpanded: false,
193
+ });
194
+
195
+ const currentGrouping: GroupBy = (grouping[0] as GroupBy) ?? "none";
196
+ const titleFilter = String(table.getColumn("title")?.getFilterValue() ?? "");
197
+
198
+ return (
199
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-6">
200
+ <div className="flex flex-wrap items-center gap-2">
201
+ <label className="relative">
202
+ <span className="sr-only">{i18n.t("board.filterTitle")}</span>
203
+ <Search
204
+ aria-hidden="true"
205
+ className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
206
+ />
207
+ <input
208
+ type="search"
209
+ value={titleFilter}
210
+ placeholder={i18n.t("board.filterTitle")}
211
+ onChange={(event) =>
212
+ table.getColumn("title")?.setFilterValue(event.currentTarget.value || undefined)
213
+ }
214
+ className="h-8 w-56 rounded-md border bg-background pr-3 pl-8 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
215
+ />
216
+ </label>
217
+ <label className="flex items-center gap-2 text-sm">
218
+ <span className="text-muted-foreground">{i18n.t("board.groupBy")}</span>
219
+ <select
220
+ value={currentGrouping}
221
+ onChange={(event) => {
222
+ const next = event.currentTarget.value as GroupBy;
223
+ setGrouping(next === "none" ? [] : [next]);
224
+ // A group that opens closed is a screen with nothing on it. Regrouping therefore
225
+ // expands again, which is also the state the table starts in.
226
+ setExpanded(true);
227
+ }}
228
+ className="h-8 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
229
+ >
230
+ {groupBy.map((option) => (
231
+ <option key={option} value={option}>
232
+ {i18n.t(`board.groupBy.${option}`)}
233
+ </option>
234
+ ))}
235
+ </select>
236
+ </label>
237
+ <DropdownMenu>
238
+ <DropdownMenuTrigger className="ml-auto inline-flex h-8 items-center gap-2 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring">
239
+ <Columns3 aria-hidden="true" className="size-4" />
240
+ {i18n.t("board.columns")}
241
+ </DropdownMenuTrigger>
242
+ <DropdownMenuContent align="end">
243
+ {table.getAllLeafColumns().map((column) => (
244
+ <DropdownMenuCheckboxItem
245
+ key={column.id}
246
+ checked={column.getIsVisible()}
247
+ onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
248
+ // The last visible column cannot be hidden: an empty table is a screen that has
249
+ // lost its data as far as anybody looking at it can tell.
250
+ disabled={column.getIsVisible() && table.getVisibleLeafColumns().length === 1}
251
+ >
252
+ {i18n.t(`board.column.${column.id === "title" ? "title" : column.id}`)}
253
+ </DropdownMenuCheckboxItem>
254
+ ))}
255
+ </DropdownMenuContent>
256
+ </DropdownMenu>
257
+ </div>
258
+ <div className="min-h-0 flex-1 overflow-auto rounded-lg border">
259
+ <table className="w-full border-collapse text-sm">
260
+ <thead className="sticky top-0 z-10 bg-card">
261
+ {table.getHeaderGroups().map((group) => (
262
+ <tr key={group.id}>
263
+ {group.headers.map((header) => (
264
+ <th
265
+ key={header.id}
266
+ scope="col"
267
+ aria-sort={
268
+ header.column.getIsSorted() === "asc"
269
+ ? "ascending"
270
+ : header.column.getIsSorted() === "desc"
271
+ ? "descending"
272
+ : "none"
273
+ }
274
+ className="border-b px-3 py-2 text-left font-medium whitespace-nowrap"
275
+ >
276
+ {header.isPlaceholder ? null : header.column.getCanSort() ? (
277
+ <button
278
+ type="button"
279
+ onClick={header.column.getToggleSortingHandler()}
280
+ className="inline-flex items-center gap-1 rounded outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
281
+ >
282
+ <table.FlexRender header={header} />
283
+ <span aria-hidden="true" className="text-muted-foreground">
284
+ {header.column.getIsSorted() === "asc"
285
+ ? "↑"
286
+ : header.column.getIsSorted() === "desc"
287
+ ? "↓"
288
+ : ""}
289
+ </span>
290
+ </button>
291
+ ) : (
292
+ <table.FlexRender header={header} />
293
+ )}
294
+ </th>
295
+ ))}
296
+ </tr>
297
+ ))}
298
+ </thead>
299
+ <tbody>
300
+ {table.getRowModel().rows.map((row) => (
301
+ <tr key={row.id} className="border-b last:border-b-0 hover:bg-muted/60">
302
+ {row.getVisibleCells().map((cell) => (
303
+ <td key={cell.id} className="px-3 py-2 align-top">
304
+ {/* ⚠️ A group row carries the grouped value and NOTHING else. Left to render
305
+ itself, every other cell of a group row falls back to the first task in the
306
+ group — so the heading "Backlog 3" came with one arbitrary member's title
307
+ and its date beside it, which reads as a row of its own and is a lie about
308
+ the two below it. */}
309
+ {row.getIsGrouped() && !cell.getIsGrouped() ? null : cell.getIsGrouped() ? (
310
+ <button
311
+ type="button"
312
+ onClick={row.getToggleExpandedHandler()}
313
+ aria-expanded={row.getIsExpanded()}
314
+ className="inline-flex items-center gap-2 rounded font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring"
315
+ >
316
+ {row.getIsExpanded() ? (
317
+ <ChevronDown aria-hidden="true" className="size-4" />
318
+ ) : (
319
+ <ChevronRight aria-hidden="true" className="size-4" />
320
+ )}
321
+ <GroupLabel row={row} columnId={cell.column.id} value={cell.getValue()} />
322
+ <span className="text-xs text-muted-foreground tabular-nums">
323
+ {row.subRows.length}
324
+ </span>
325
+ </button>
326
+ ) : cell.getIsPlaceholder() ? null : cell.column.id === "title" ? (
327
+ <div
328
+ className="flex min-w-0 items-start gap-1.5"
329
+ // ⚠️ The indent is a style and not a nested table. A row nested in markup
330
+ // could not be sorted or grouped with its siblings, and a screen reader
331
+ // would read four tables where there is one.
332
+ style={{ paddingInlineStart: `${row.depth * 1.25}rem` }}
333
+ >
334
+ {row.getCanExpand() ? (
335
+ <button
336
+ type="button"
337
+ onClick={row.getToggleExpandedHandler()}
338
+ aria-expanded={row.getIsExpanded()}
339
+ aria-label={i18n.t("board.toggleSubtasks", {
340
+ task: row.original.title,
341
+ })}
342
+ className="mt-0.5 rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
343
+ >
344
+ {row.getIsExpanded() ? (
345
+ <ChevronDown aria-hidden="true" className="size-4" />
346
+ ) : (
347
+ <ChevronRight aria-hidden="true" className="size-4" />
348
+ )}
349
+ </button>
350
+ ) : (
351
+ <span aria-hidden="true" className="size-4 shrink-0" />
352
+ )}
353
+ <button
354
+ type="button"
355
+ onClick={() => select(row.original.id)}
356
+ className="min-w-0 rounded text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
357
+ >
358
+ {row.original.title}
359
+ </button>
360
+ {board.blockedBy(row.original).length > 0 ? (
361
+ <span className="shrink-0 rounded-md border border-destructive px-1 text-xs text-destructive">
362
+ {i18n.t("board.blocked")}
363
+ </span>
364
+ ) : null}
365
+ </div>
366
+ ) : cell.column.id === "status" ? (
367
+ <span className="inline-flex items-center gap-1.5 whitespace-nowrap">
368
+ <span
369
+ aria-hidden="true"
370
+ className={`size-2 rounded-full ${toneOf(row.original.status, board.statuses).dot}`}
371
+ />
372
+ <table.FlexRender cell={cell} />
373
+ </span>
374
+ ) : (
375
+ <table.FlexRender cell={cell} />
376
+ )}
377
+ </td>
378
+ ))}
379
+ </tr>
380
+ ))}
381
+ </tbody>
382
+ </table>
383
+ {table.getRowModel().rows.length === 0 ? (
384
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("board.noTasks")}</p>
385
+ ) : null}
386
+ </div>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ /**
392
+ * The value a group was formed on, said in words.
393
+ *
394
+ * ⚠️ The assignee column groups on a STABLE key (`user:…`, `agent:…`) and that key is not a name.
395
+ * The heading is therefore read off the first row of the group rather than off the key — that is
396
+ * the only place an agent's title can be resolved, and the only place a person who cannot be named
397
+ * is named honestly rather than by their id.
398
+ */
399
+ function GroupLabel({
400
+ row,
401
+ columnId,
402
+ value,
403
+ }: {
404
+ row: { subRows: { original: BoardRow }[] };
405
+ columnId: string;
406
+ value: unknown;
407
+ }) {
408
+ const i18n = useI18n();
409
+ if (columnId !== "assignee") return <span>{String(value ?? "")}</span>;
410
+ const assignee = row.subRows[0]?.original.assignee ?? null;
411
+ if (assignee === null) return <span>{i18n.t("board.unassigned")}</span>;
412
+ return <BoardAssigneeLabel assignee={assignee} />;
413
+ }