@anchrd/intel-ui 0.16.0 → 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 (35) hide show
  1. package/package.json +10 -2
  2. package/src/agent/agent-profile/agent-profile.tsx +2 -0
  3. package/src/board/board-calendar/board-calendar.tsx +89 -0
  4. package/src/board/board-card/board-card.tsx +106 -0
  5. package/src/board/board-data/board-data.ts +241 -0
  6. package/src/board/board-data/board-data.types.ts +63 -0
  7. package/src/board/board-detail/board-detail.tsx +629 -0
  8. package/src/board/board-gantt/board-gantt.ts +545 -0
  9. package/src/board/board-gantt/board-gantt.tsx +286 -0
  10. package/src/board/board-graph/board-graph.ts +174 -0
  11. package/src/board/board-graph/board-graph.tsx +168 -0
  12. package/src/board/board-items/board-items.ts +183 -0
  13. package/src/board/board-kanban/board-kanban.ts +97 -0
  14. package/src/board/board-kanban/board-kanban.tsx +211 -0
  15. package/src/board/board-status/board-status.ts +59 -0
  16. package/src/board/board-statuses/board-statuses.ts +63 -0
  17. package/src/board/board-statuses/board-statuses.tsx +228 -0
  18. package/src/board/board-table/board-table.ts +33 -0
  19. package/src/board/board-table/board-table.tsx +413 -0
  20. package/src/board/board-views/board-views.tsx +68 -0
  21. package/src/board/board-views/board-views.types.ts +29 -0
  22. package/src/board/board.tsx +251 -0
  23. package/src/components/ui/dropdown-menu.tsx +25 -0
  24. package/src/components/ui/item-calendar.tsx +181 -0
  25. package/src/components/ui/item-gantt.tsx +463 -0
  26. package/src/components/ui/kanban.tsx +245 -0
  27. package/src/components/ui/switch.tsx +25 -0
  28. package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
  29. package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
  30. package/src/i18n/de.json +88 -1
  31. package/src/i18n/en.json +88 -1
  32. package/src/i18n/es.json +88 -1
  33. package/src/kind-icon.ts +12 -1
  34. package/src/nodes/nodes.tsx +16 -0
  35. package/src/styles.css +33 -0
@@ -0,0 +1,183 @@
1
+ import type { BoardStatus, BoardTask } from "@anchrd/intel-contract";
2
+ import { type BoardTone, labelOf, toneOf } from "@/board/board-status/board-status.ts";
3
+
4
+ /**
5
+ * One task as a dated thing (anchrd/intel#286).
6
+ *
7
+ * ⚠️ This is THE adapter between a board and every view that arranges by date — the calendar and
8
+ * Gantt (anchrd/intel#293). The shape is deliberately the item model those views already speak
9
+ * (`id`, `name`, `startAt`, `endAt`, `status`), so the second view was a renderer over this list
10
+ * rather than a second reading of `BoardTask`. Two readings would disagree about the one thing that
11
+ * is genuinely subtle here — what a task without a start date spans — and they would disagree
12
+ * quietly, in a picture.
13
+ */
14
+ export interface BoardItem {
15
+ id: string;
16
+ name: string;
17
+ // ⚠️ Both are LOCAL midnight, never UTC. A `BoardTaskDate` is a day and carries no zone by
18
+ // contract, so `new Date("2026-08-07")` — which JavaScript reads as UTC midnight — would show the
19
+ // 6th to every reader west of Greenwich. A task due on a day is due on that day for everybody.
20
+ startAt: Date;
21
+ endAt: Date;
22
+ status: { id: string; label: string; tone: BoardTone };
23
+ // Something it waits for is still open. Carried on the item rather than looked up again by each
24
+ // view: it is the same question in the calendar, the graph and the card.
25
+ blocked: boolean;
26
+ task: BoardTask;
27
+ }
28
+
29
+ export interface BoardSchedule {
30
+ items: BoardItem[];
31
+ /**
32
+ * The tasks that have no place in a date grid.
33
+ *
34
+ * ⚠️ Handed back rather than dropped. A calendar that silently omits everything without a due
35
+ * date is a calendar that lies about how much work there is — so the view says how many are
36
+ * missing and where they are instead (the DoD calls this out by name).
37
+ */
38
+ undated: BoardTask[];
39
+ }
40
+
41
+ // A day as the reader's own midnight. Split rather than handed to `new Date(string)` for the reason
42
+ // in `BoardItem.startAt`.
43
+ export function localDay(date: string): Date | null {
44
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
45
+ if (!parts) return null;
46
+ const [year, month, day] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
47
+ const parsed = new Date(year, month - 1, day);
48
+ // A `BoardTaskDate` is validated on both sides, so this only catches a real impossibility such as
49
+ // the 31st of February — which `Date` would roll forward into March rather than refuse.
50
+ return parsed.getFullYear() === year &&
51
+ parsed.getMonth() === month - 1 &&
52
+ parsed.getDate() === day
53
+ ? parsed
54
+ : null;
55
+ }
56
+
57
+ export function sameDay(left: Date, right: Date): boolean {
58
+ return (
59
+ left.getFullYear() === right.getFullYear() &&
60
+ left.getMonth() === right.getMonth() &&
61
+ left.getDate() === right.getDate()
62
+ );
63
+ }
64
+
65
+ /**
66
+ * A day back as the `BoardTaskDate` it is written as — the exact inverse of `localDay`
67
+ * (anchrd/intel#293).
68
+ *
69
+ * ⚠️ Built from the LOCAL components, never from `toISOString()`. That is the same trap as reading a
70
+ * date, in the other direction and with worse consequences: `new Date(2026, 7, 7).toISOString()` is
71
+ * `2026-08-06T22:00:00Z` in Berlin, so a bar dragged onto the 7th would be SAVED as the 6th. Reading
72
+ * it wrong draws the wrong day; writing it wrong stores the wrong day, and every surface then agrees
73
+ * about a date nobody chose.
74
+ *
75
+ * It exists because Gantt writes dates back and the calendar only reads them. One pair of functions
76
+ * in one place, so the two directions cannot disagree about what a day is.
77
+ */
78
+ export function dayValue(day: Date): string {
79
+ const month = String(day.getMonth() + 1).padStart(2, "0");
80
+ const date = String(day.getDate()).padStart(2, "0");
81
+ return `${day.getFullYear()}-${month}-${date}`;
82
+ }
83
+
84
+ /**
85
+ * The same day, a whole number of days later or earlier.
86
+ *
87
+ * ⚠️ Calendar arithmetic, not milliseconds. `time + 86_400_000` is 23 or 25 hours across a DST
88
+ * boundary, which lands the result on the previous day at 23:00 or the next at 01:00 — and a board
89
+ * whose dates shift by one on the last Sunday in March is a board nobody would trust again. `Date`
90
+ * normalises an out-of-range day (the 32nd of August is the 1st of September), so this needs no
91
+ * month handling of its own.
92
+ */
93
+ export function addDays(day: Date, count: number): Date {
94
+ return new Date(day.getFullYear(), day.getMonth(), day.getDate() + count);
95
+ }
96
+
97
+ /**
98
+ * How many days lie between two days, as a whole number.
99
+ *
100
+ * ⚠️ Compared as UTC noon-free day numbers rather than by subtracting local times, for the DST
101
+ * reason above: the difference between two local midnights is not always a multiple of 24 hours, and
102
+ * a `Math.round` over that is right until the one week of the year it is not.
103
+ */
104
+ export function daysBetween(from: Date, to: Date): number {
105
+ const left = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
106
+ const right = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
107
+ return Math.round((right - left) / 86_400_000);
108
+ }
109
+
110
+ /**
111
+ * Which task each task hangs under, once the list has been cut down.
112
+ *
113
+ * ⚠️ A task whose parent is not in the list becomes a ROOT rather than disappearing, and that is the
114
+ * whole reason this exists. Hiding the shelf hides archived tasks; an archived epic with two live
115
+ * tasks under it must not take them off the screen with it. The hierarchy is a fact about the board,
116
+ * what is shown is a question the reader asked, and the second must not silently answer the first.
117
+ *
118
+ * ⚠️ A chain that comes back to where it started is broken here, at the task that closes it. The
119
+ * write path refuses a cycle (#285), so this cannot be reached from a board Intel wrote — but the
120
+ * cost of being wrong about that is not a wrong picture, it is a walk that recurses until the tab
121
+ * dies. Depth is capped at five, so the walk is bounded anyway.
122
+ *
123
+ * ⚠️ It lives HERE, beside the dated reading, because the table and Gantt both need it
124
+ * (anchrd/intel#293). Two copies of a rule with two subtleties in it are two copies that drift, and
125
+ * the drift would show up as a task that is a root in one view and a child in the other.
126
+ */
127
+ export function rootedParents(tasks: BoardTask[]): Map<string, string | null> {
128
+ const byId = new Map(tasks.map((task) => [task.id, task]));
129
+ const parents = new Map<string, string | null>();
130
+ for (const task of tasks) {
131
+ const direct = task.parentId !== null && byId.has(task.parentId) ? task.parentId : null;
132
+ let cursor = direct;
133
+ const seen = new Set([task.id]);
134
+ while (cursor !== null && !seen.has(cursor)) {
135
+ seen.add(cursor);
136
+ const next = byId.get(cursor)?.parentId ?? null;
137
+ cursor = next !== null && byId.has(next) ? next : null;
138
+ }
139
+ parents.set(task.id, cursor === null ? direct : null);
140
+ }
141
+ return parents;
142
+ }
143
+
144
+ /**
145
+ * The board as a list of dated items, and the tasks that are not one.
146
+ *
147
+ * ⚠️ `dueDate` is what makes a task an item — a start date alone does not. A bar has to end
148
+ * somewhere, and a task that has only begun would either be drawn to today (a date nobody entered)
149
+ * or to forever. `startDate` therefore only ever shortens or lengthens the span; without one, the
150
+ * item is the single day it is due on.
151
+ */
152
+ export function boardSchedule(
153
+ tasks: BoardTask[],
154
+ statuses: BoardStatus[],
155
+ blockedBy: (task: BoardTask) => BoardTask[],
156
+ ): BoardSchedule {
157
+ const items: BoardItem[] = [];
158
+ const undated: BoardTask[] = [];
159
+ for (const task of tasks) {
160
+ const end = task.dueDate === null ? null : localDay(task.dueDate);
161
+ if (end === null) {
162
+ undated.push(task);
163
+ continue;
164
+ }
165
+ const start = task.startDate === null ? null : localDay(task.startDate);
166
+ items.push({
167
+ id: task.id,
168
+ name: task.title,
169
+ // A start after the due date is a board somebody typed wrong, not a bar drawn backwards. The
170
+ // due date wins because it is the one the calendar files the task under.
171
+ startAt: start !== null && start <= end ? start : end,
172
+ endAt: end,
173
+ status: {
174
+ id: task.status,
175
+ label: labelOf(task.status, statuses),
176
+ tone: toneOf(task.status, statuses),
177
+ },
178
+ blocked: blockedBy(task).length > 0,
179
+ task,
180
+ });
181
+ }
182
+ return { items, undated };
183
+ }
@@ -0,0 +1,97 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract";
2
+ import type { MoveTask } from "@/board/board-data/board-data.types.ts";
3
+ import type { KanbanDrop } from "@/components/ui/kanban.tsx";
4
+
5
+ /**
6
+ * What a drop means, as the one `board_task_move` it is worth.
7
+ *
8
+ * ⚠️ Neighbours, never an index. The server mints the fractional key between the two tasks named
9
+ * here (#285), so one drag writes one task and renumbers nothing — the reason a board is addressed
10
+ * by task id and not by position at all. A UI that sent a position would have to send the whole
11
+ * column with it to be unambiguous.
12
+ *
13
+ * ⚠️ `null` for a drag that changed nothing, and that is half the point of this function. dnd-kit
14
+ * reports a drop wherever the pointer was let go, including back on the card's own place; sending
15
+ * a move for that would write a version, bump the board and show up in the audit as work that did
16
+ * not happen.
17
+ *
18
+ * `lanes` is what the board DRAWS — root tasks per column, in order. Subtasks are not lane cards
19
+ * (they are a counter on their parent), so they are not in it and cannot be dropped on either.
20
+ */
21
+ export function moveFromDrop(
22
+ drop: KanbanDrop,
23
+ lanes: Map<string, BoardTask[]>,
24
+ byId: Map<string, BoardTask>,
25
+ // The statuses the board actually lists. ⚠️ Not the same as the lanes: a lane is also drawn for a
26
+ // status the board no longer has, so that the cards left in it stay visible — and that lane must
27
+ // not be somewhere a card can be MOVED. The server refuses a task in a status the board does not
28
+ // have, so a drop there would be a refusal, an invalidation and no word to the reader.
29
+ writable: ReadonlySet<string>,
30
+ ): MoveTask | null {
31
+ const active = byId.get(drop.activeId);
32
+ // ⚠️ Let go over itself is the commonest drag of all — a click that travelled five pixels — and
33
+ // it has to be nothing before any of the arithmetic below runs. Read as "dropped on a card", it
34
+ // would compute a landing place for a card that never left, which is the shape of a write that
35
+ // says work happened.
36
+ if (drop.overId === null || drop.overId === drop.activeId || active === undefined) return null;
37
+
38
+ // The drop landed either on a column's empty space — then its id is the status — or on a card,
39
+ // and then the column is that card's. A card the board does not draw (a subtask, or one filtered
40
+ // away) resolves to neither and the drag is a no-op rather than a guess.
41
+ const overTask = byId.get(drop.overId);
42
+ const status = overTask?.status ?? (lanes.has(drop.overId) ? drop.overId : null);
43
+ if (status === null || !writable.has(status)) return null;
44
+
45
+ const lane = lanes.get(status) ?? [];
46
+ const from = lane.findIndex((task) => task.id === active.id);
47
+ const rest = lane.filter((task) => task.id !== active.id);
48
+
49
+ let landing: number;
50
+ if (overTask === undefined) {
51
+ // Let go over the column rather than over a card: the end of it.
52
+ landing = rest.length;
53
+ } else {
54
+ const overIndex = rest.findIndex((task) => task.id === overTask.id);
55
+ if (overIndex === -1) return null;
56
+ // Dragged downwards inside its own column, the card takes the place BELOW the one it was let go
57
+ // over — the same reading `arrayMove` has, and the one the pointer suggests. Every other case
58
+ // puts it above.
59
+ const overInLane = lane.findIndex((task) => task.id === overTask.id);
60
+ landing = from !== -1 && from < overInLane ? overIndex + 1 : overIndex;
61
+ }
62
+
63
+ // Re-inserting where it came from is the arrangement it already had.
64
+ if (from !== -1 && landing === from) return null;
65
+
66
+ return {
67
+ taskId: active.id,
68
+ status,
69
+ afterTaskId: rest[landing - 1]?.id ?? null,
70
+ beforeTaskId: rest[landing]?.id ?? null,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * How much of a task's subtree is finished — the counter a card carries instead of its children.
76
+ *
77
+ * ⚠️ The whole subtree, not the direct children. A card that says "1/2" while three of the four
78
+ * things under it are open would be worse than no counter: the number would be true of a level
79
+ * nobody is looking at.
80
+ */
81
+ export function subtaskCount(
82
+ taskId: string,
83
+ childrenOf: Map<string, BoardTask[]>,
84
+ settled: (task: BoardTask) => boolean,
85
+ ): { done: number; total: number } {
86
+ let done = 0;
87
+ let total = 0;
88
+ const walk = (id: string) => {
89
+ for (const child of childrenOf.get(id) ?? []) {
90
+ total += 1;
91
+ if (settled(child)) done += 1;
92
+ walk(child.id);
93
+ }
94
+ };
95
+ walk(taskId);
96
+ return { done, total };
97
+ }
@@ -0,0 +1,211 @@
1
+ import { ArchivedBoardStatusId, type BoardTask } from "@anchrd/intel-contract";
2
+ import { useMemo } from "react";
3
+ import { BoardCardBody } from "@/board/board-card/board-card.tsx";
4
+ import { visibleStatuses } from "@/board/board-data/board-data.ts";
5
+ import { labelOf, toneOf } from "@/board/board-status/board-status.ts";
6
+ import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
7
+ import {
8
+ KanbanBoard,
9
+ KanbanCard,
10
+ KanbanCards,
11
+ KanbanHeader,
12
+ KanbanProvider,
13
+ } from "@/components/ui/kanban.tsx";
14
+ import { useI18n } from "@/i18n/i18n-context.tsx";
15
+ import { moveFromDrop, subtaskCount } from "./board-kanban.ts";
16
+
17
+ /**
18
+ * The board as lanes (anchrd/intel#286).
19
+ *
20
+ * ⚠️ Lanes are the board's `statuses` in their own order — not a list this view keeps. Renaming a
21
+ * column in `board_configure` renames the lane, and the id under it never moves, so no task has to
22
+ * be rewritten to follow.
23
+ *
24
+ * ⚠️ Only root tasks are cards. A subtask is a counter on its parent (`subtaskCount`); drawing it
25
+ * as a lane card as well would put one piece of work on the board twice, once inside its parent and
26
+ * once beside it. The whole hierarchy is the table's job and the detail panel's.
27
+ */
28
+ export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
29
+ const i18n = useI18n();
30
+
31
+ const columns = useMemo(() => {
32
+ // ⚠️ `archived` is not a lane by default and is not a stage either — it is the shelf a task
33
+ // is swept onto, and a board that shows it always spends a fifth of its width on work
34
+ // somebody has finished with. The switch that brings it back is in the bar above.
35
+ /**
36
+ * ⚠️ One lane per status id, however often the list names it (anchrd/intel#321).
37
+ *
38
+ * Two lanes under one id is the half of that ticket that does not throw, which is what makes it
39
+ * the worse half: `lanes.get(column.id)` answers both of them with the same cards, so the same
40
+ * work stands in two columns, `useDroppable` registers one id twice and a drop has two targets
41
+ * that are one — a screen that looks like it is working. The bundle import refuses such a
42
+ * document now; a board imported before it is still stored as it was written, and the first
43
+ * mention is the lane to keep because it is the one `labelOf` and `toneOf` already answer with
44
+ * — a lane drawn under the second label would have carried the first one's colour.
45
+ */
46
+ const seen = new Set<string>();
47
+ const listed = (showArchived ? board.statuses : visibleStatuses(board.statuses))
48
+ .filter((status) => {
49
+ if (seen.has(status.id)) return false;
50
+ seen.add(status.id);
51
+ return true;
52
+ })
53
+ .map((status) => ({ id: status.id, name: status.label }));
54
+ /**
55
+ * ⚠️ And a lane at the end for every status a task still stands in that the board no longer
56
+ * lists.
57
+ *
58
+ * Configuring a column away does not rewrite the tasks that were in it (#285), so this is
59
+ * reachable — through `board_configure` over MCP, which has no dialog stopping it. Without the
60
+ * extra lane those cards would be in `board.tasks`, counted in the title line, and drawn
61
+ * nowhere: gone from the one view a board is opened for, with nothing saying so. The other
62
+ * others already keep them (`orderedTasks` sorts them last, `labelOf` falls back to the raw
63
+ * id), and no two views may disagree about what is on the board.
64
+ */
65
+ const known = new Set(listed.map((column) => column.id));
66
+ const stray = new Set(
67
+ board.tasks
68
+ .filter((task) => task.parentId === null && !known.has(task.status))
69
+ .filter((task) => showArchived || task.status !== ArchivedBoardStatusId)
70
+ .map((task) => task.status),
71
+ );
72
+ return [
73
+ ...listed.map((column) => ({ ...column, writable: true })),
74
+ // ⚠️ Drawn, never written into. The server refuses a task in a status the board does not have,
75
+ // so a card dropped here would be a refusal, an invalidation and nothing said to the reader.
76
+ // The lane is a place work is VISIBLE, not a place it can be put.
77
+ ...[...stray].map((id) => ({ id, name: labelOf(id, board.statuses), writable: false })),
78
+ ];
79
+ }, [board.statuses, board.tasks, showArchived]);
80
+
81
+ const writable = useMemo(
82
+ () => new Set(columns.filter((column) => column.writable).map((column) => column.id)),
83
+ [columns],
84
+ );
85
+
86
+ const lanes = useMemo(() => {
87
+ const byStatus = new Map<string, BoardTask[]>();
88
+ for (const column of columns) byStatus.set(column.id, []);
89
+ for (const task of board.tasks) {
90
+ if (task.parentId !== null) continue;
91
+ byStatus.get(task.status)?.push(task);
92
+ }
93
+ return byStatus;
94
+ }, [board.tasks, columns]);
95
+
96
+ const itemsByColumn = useMemo(
97
+ () => new Map([...lanes].map(([status, tasks]) => [status, tasks.map((task) => task.id)])),
98
+ [lanes],
99
+ );
100
+
101
+ const card = (task: BoardTask) => (
102
+ <BoardCardBody
103
+ task={task}
104
+ statusLabel={labelOf(task.status, board.statuses)}
105
+ toneDot={toneOf(task.status, board.statuses).dot}
106
+ blocked={board.blockedBy(task).length > 0}
107
+ subtasks={subtaskCount(task.id, board.childrenOf, board.settled)}
108
+ />
109
+ );
110
+
111
+ return (
112
+ <div className="min-h-0 flex-1 overflow-x-auto p-6">
113
+ <KanbanProvider
114
+ columns={columns}
115
+ itemsByColumn={itemsByColumn}
116
+ className="min-w-max"
117
+ announcements={{
118
+ onDragStart: ({ active }) =>
119
+ i18n.t("board.dnd.start", { task: board.byId.get(String(active.id))?.title ?? "" }),
120
+ onDragOver: ({ active, over }) =>
121
+ i18n.t("board.dnd.over", {
122
+ task: board.byId.get(String(active.id))?.title ?? "",
123
+ lane: over === null ? "" : laneName(String(over.id), board, columns),
124
+ }),
125
+ onDragEnd: ({ active, over }) =>
126
+ i18n.t("board.dnd.end", {
127
+ task: board.byId.get(String(active.id))?.title ?? "",
128
+ lane: over === null ? "" : laneName(String(over.id), board, columns),
129
+ }),
130
+ onDragCancel: ({ active }) =>
131
+ i18n.t("board.dnd.cancel", { task: board.byId.get(String(active.id))?.title ?? "" }),
132
+ }}
133
+ overlay={(activeId) => {
134
+ const task = board.byId.get(activeId);
135
+ return task ? (
136
+ <div className="w-72 rounded-lg border bg-card p-3 shadow-lg ring-2 ring-ring">
137
+ {card(task)}
138
+ </div>
139
+ ) : null;
140
+ }}
141
+ // ⚠️ Exactly one write per drag, and only when the card actually went somewhere.
142
+ // `moveFromDrop` answers `null` for a drop back onto its own place, and the mutation names
143
+ // the NEIGHBOURS rather than an index — the server mints the order key between them, so one
144
+ // drag writes one task and the other cards are not touched (#285).
145
+ onDrop={(drop) => {
146
+ const move = moveFromDrop(drop, lanes, board.byId, writable);
147
+ if (move) board.move.mutate(move);
148
+ }}
149
+ >
150
+ {(column) => (
151
+ <KanbanBoard
152
+ id={column.id}
153
+ // ⚠️ Read back off the list rather than off the argument: `KanbanProvider` hands its
154
+ // children a `KanbanColumn`, which is an id and a name — the primitive has no business
155
+ // knowing which of a board's columns may be written to.
156
+ droppable={columns.find((entry) => entry.id === column.id)?.writable !== false}
157
+ className="w-72 shrink-0"
158
+ >
159
+ <KanbanHeader>
160
+ <span
161
+ aria-hidden="true"
162
+ className={`size-2 rounded-full ${toneOf(column.id, board.statuses).dot}`}
163
+ />
164
+ <span className="min-w-0 truncate">{column.name}</span>
165
+ <span className="ml-auto text-xs font-normal text-muted-foreground tabular-nums">
166
+ {lanes.get(column.id)?.length ?? 0}
167
+ </span>
168
+ </KanbanHeader>
169
+ <KanbanCards id={column.id}>
170
+ {(lanes.get(column.id) ?? []).map((task) => (
171
+ <KanbanCard
172
+ key={task.id}
173
+ id={task.id}
174
+ dragLabel={i18n.t("board.dragCard", { task: task.title })}
175
+ >
176
+ {/* Two controls, each with a name of its own: the handle beside this one drags,
177
+ this one opens. The pointer sensor's four-pixel threshold is what keeps a
178
+ click on it from counting as a drag of nothing. */}
179
+ <button
180
+ type="button"
181
+ className="w-full rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
182
+ onClick={() => select(task.id)}
183
+ >
184
+ {card(task)}
185
+ </button>
186
+ </KanbanCard>
187
+ ))}
188
+ {(lanes.get(column.id) ?? []).length === 0 ? (
189
+ <p className="px-1 py-2 text-xs text-muted-foreground">
190
+ {column.id === ArchivedBoardStatusId
191
+ ? i18n.t("board.archivedEmpty")
192
+ : i18n.t("board.laneEmpty")}
193
+ </p>
194
+ ) : null}
195
+ </KanbanCards>
196
+ </KanbanBoard>
197
+ )}
198
+ </KanbanProvider>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ function laneName(
204
+ overId: string,
205
+ board: BoardViewProps["board"],
206
+ columns: { id: string; name: string }[],
207
+ ): string {
208
+ const task = board.byId.get(overId);
209
+ const statusId = task?.status ?? overId;
210
+ return columns.find((column) => column.id === statusId)?.name ?? statusId;
211
+ }
@@ -0,0 +1,59 @@
1
+ import { ArchivedBoardStatusId, type BoardStatus } from "@anchrd/intel-contract";
2
+
3
+ /**
4
+ * How a status looks, in the three places a colour has to be spelled differently.
5
+ *
6
+ * `token` is the CSS variable name rather than a value, because Sigma paints to a canvas and cannot
7
+ * read a class (`resolveGraphColor` turns it into sRGB there). `dot` and `chip` are Tailwind
8
+ * classes for everything that is drawn in the DOM.
9
+ */
10
+ export interface BoardTone {
11
+ token: string;
12
+ dot: string;
13
+ chip: string;
14
+ }
15
+
16
+ // ⚠️ Written out rather than built from a template string. Tailwind reads the source to decide
17
+ // which classes to emit, and a class assembled at runtime — `bg-board-status-${n}` — is a class it
18
+ // never sees and therefore never generates. The board would then be uncoloured in the production
19
+ // build and correct in dev, which is the worst shape a bug can have.
20
+ const Ramp: readonly BoardTone[] = [
21
+ { token: "--board-status-1", dot: "bg-board-status-1", chip: "border-board-status-1" },
22
+ { token: "--board-status-2", dot: "bg-board-status-2", chip: "border-board-status-2" },
23
+ { token: "--board-status-3", dot: "bg-board-status-3", chip: "border-board-status-3" },
24
+ { token: "--board-status-4", dot: "bg-board-status-4", chip: "border-board-status-4" },
25
+ { token: "--board-status-5", dot: "bg-board-status-5", chip: "border-board-status-5" },
26
+ { token: "--board-status-6", dot: "bg-board-status-6", chip: "border-board-status-6" },
27
+ ];
28
+
29
+ // The shelf, and everything the board no longer has a column for. Both are "not a stage", and both
30
+ // have to read as background rather than as one more colour competing with the stages.
31
+ const Shelved: BoardTone = {
32
+ token: "--muted-foreground",
33
+ dot: "bg-muted-foreground",
34
+ chip: "border-border",
35
+ };
36
+
37
+ /**
38
+ * The colour a status is drawn in.
39
+ *
40
+ * ⚠️ By POSITION in the board's own list, not by id. A status list is configured per board, so
41
+ * `done` is no more a colour than "the fourth column" is a name — and a board that renames a column
42
+ * must not have its whole ramp shift underneath it. Positions count the working columns only, so
43
+ * moving `archived` around in the list cannot recolour everything after it.
44
+ */
45
+ export function toneOf(statusId: string, statuses: BoardStatus[]): BoardTone {
46
+ if (statusId === ArchivedBoardStatusId) return Shelved;
47
+ const position = statuses
48
+ .filter((status) => status.id !== ArchivedBoardStatusId)
49
+ .findIndex((status) => status.id === statusId);
50
+ if (position === -1) return Shelved;
51
+ return Ramp[position % Ramp.length] ?? Shelved;
52
+ }
53
+
54
+ // The label a reader sees. A task can hold a status the board no longer lists — configuring a
55
+ // column away does not rewrite the tasks that stood in it — and the raw id is then the only honest
56
+ // answer: it says which column is missing instead of pretending the task has none.
57
+ export function labelOf(statusId: string, statuses: BoardStatus[]): string {
58
+ return statuses.find((status) => status.id === statusId)?.label ?? statusId;
59
+ }
@@ -0,0 +1,63 @@
1
+ import { ArchivedBoardStatusId, type BoardStatus } from "@anchrd/intel-contract";
2
+
3
+ // One row of the dialog. `terminal` travels with it because it is written in the same call as the
4
+ // label and the order — `board_configure` takes the list whole (#285).
5
+ export interface BoardStatusDraft {
6
+ id: string;
7
+ label: string;
8
+ terminal: boolean;
9
+ }
10
+
11
+ /**
12
+ * A status id out of the label somebody typed.
13
+ *
14
+ * ⚠️ The id is what every task in the column carries, so it is minted ONCE and never follows a
15
+ * rename — renaming changes the label and no task has to be rewritten (#285). This function is
16
+ * therefore only ever called for a NEW column.
17
+ *
18
+ * The shape is the contract's: lower case, digits and underscores, starting on an alphanumeric,
19
+ * forty characters at most. A label that leaves nothing usable — one written in a script that has
20
+ * no ASCII form — falls back to a generated id rather than being refused, because a column named
21
+ * in Japanese is a column somebody meant to make.
22
+ */
23
+ export function statusIdFrom(label: string, taken: ReadonlySet<string>): string {
24
+ const base = label
25
+ .toLowerCase()
26
+ .normalize("NFKD")
27
+ // Combining marks removed, so "Prüfung" becomes "prufung" rather than losing the letter.
28
+ .replace(/[\u0300-\u036f]/g, "")
29
+ .replace(/[^a-z0-9]+/g, "_")
30
+ .replace(/^_+|_+$/g, "")
31
+ .slice(0, 40)
32
+ .replace(/^[^a-z0-9]+/, "");
33
+ const seed = base === "" ? "status" : base;
34
+ if (!taken.has(seed) && seed !== ArchivedBoardStatusId) return seed;
35
+ for (let suffix = 2; ; suffix += 1) {
36
+ const candidate = `${seed.slice(0, 37)}_${suffix}`;
37
+ if (!taken.has(candidate) && candidate !== ArchivedBoardStatusId) return candidate;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The list as `board_configure` takes it: whole, in the order it should be drawn.
43
+ *
44
+ * ⚠️ `archived` is put back at the end if the editor lost it. The contract refuses a list without
45
+ * it, and a refusal here would be a dialog that cannot be saved for a reason the reader never
46
+ * caused — the shelf is not a column anybody is editing.
47
+ */
48
+ export function configurableStatuses(statuses: BoardStatus[]): BoardStatusDraft[] {
49
+ const working = statuses.filter((status) => status.id !== ArchivedBoardStatusId);
50
+ const shelf = statuses.find((status) => status.id === ArchivedBoardStatusId);
51
+ return [
52
+ ...working.map((status) => ({
53
+ id: status.id,
54
+ label: status.label,
55
+ terminal: status.terminal,
56
+ })),
57
+ // ⚠️ Always `true` for the shelf, whatever a stored board says. The contract refuses an explicit
58
+ // `false` for it (anchrd/intel#311), so sending anything else would be a dialog that cannot be
59
+ // saved — and a shelf that did not mean finished would hold every archived task open as a
60
+ // blocker forever.
61
+ { id: ArchivedBoardStatusId, label: shelf?.label ?? "Archived", terminal: true },
62
+ ];
63
+ }