@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -16,6 +16,10 @@
16
16
  "scripts",
17
17
  "!src/**/*.unit.ts",
18
18
  "!src/**/*.unit.tsx",
19
+ "!src/**/*.int.ts",
20
+ "!src/**/*.int.tsx",
21
+ "!src/**/*.e2e.ts",
22
+ "!src/**/*.e2e.tsx",
19
23
  "index.html",
20
24
  "vite.config.ts",
21
25
  "tsconfig.json",
@@ -29,16 +33,20 @@
29
33
  "typecheck": "tsc --noEmit"
30
34
  },
31
35
  "dependencies": {
32
- "@anchrd/intel-contract": "^0.9.0",
36
+ "@anchrd/intel-contract": "^0.11.0",
33
37
  "@assistant-ui/react": "^0.15.4",
34
38
  "@assistant-ui/react-ai-sdk": "^1.4.4",
35
39
  "@blocknote/core": "^0.52.1",
36
40
  "@blocknote/react": "^0.52.1",
37
41
  "@blocknote/shadcn": "^0.52.1",
42
+ "@dnd-kit/core": "^6.3.1",
43
+ "@dnd-kit/sortable": "^10.0.0",
44
+ "@dnd-kit/utilities": "^3.2.2",
38
45
  "@react-sigma/core": "^5.0.6",
39
46
  "@tailwindcss/vite": "^4.3.3",
40
47
  "@tanstack/react-query": "^5.101.4",
41
48
  "@tanstack/react-router": "^1.170.18",
49
+ "@tanstack/react-table": "^9.1.0",
42
50
  "@vitejs/plugin-react": "^6.0.4",
43
51
  "@xyflow/react": "^12.11.2",
44
52
  "class-variance-authority": "^0.7.1",
@@ -17,6 +17,7 @@ import {
17
17
  Copy,
18
18
  FileText,
19
19
  Folder,
20
+ KanbanSquare,
20
21
  KeyRound,
21
22
  MessageSquare,
22
23
  Paperclip,
@@ -431,6 +432,7 @@ const KindIcon: Record<NodeKind, typeof Folder> = {
431
432
  table: Table2,
432
433
  attachment: Paperclip,
433
434
  agent: Bot,
435
+ board: KanbanSquare,
434
436
  };
435
437
 
436
438
  /**
@@ -0,0 +1,89 @@
1
+ import { useMemo, useState } from "react";
2
+ import { shownTasks } from "@/board/board-data/board-data.ts";
3
+ import { type BoardItem, boardSchedule } from "@/board/board-items/board-items.ts";
4
+ import type { BoardViewProps } from "@/board/board-views/board-views.types.ts";
5
+ import { ItemCalendar } from "@/components/ui/item-calendar.tsx";
6
+ import { useI18n } from "@/i18n/i18n-context.tsx";
7
+
8
+ // Which day a week starts on, in the reader's own language rather than fixed at Sunday. Intl knows
9
+ // it; a hard-coded 0 would draw a German month the way an American reads one.
10
+ function weekStart(locale: string): number {
11
+ const info = new Intl.Locale(locale);
12
+ // Not every runtime answers, and the ones that do disagree on where it lives. Monday is the ISO
13
+ // default and the safer of the two guesses for a European installation.
14
+ const week = (info as unknown as { getWeekInfo?(): { firstDay: number } }).getWeekInfo?.();
15
+ const firstDay = week?.firstDay ?? 1;
16
+ // Intl counts Monday as 1 and Sunday as 7; `Date.getDay()` counts Sunday as 0.
17
+ return firstDay % 7;
18
+ }
19
+
20
+ /**
21
+ * The board as a month (anchrd/intel#286).
22
+ *
23
+ * ⚠️ It draws `boardSchedule` and nothing else. That adapter is the ONE reading of a task as a
24
+ * dated thing, and Gantt (anchrd/intel#293) is a second renderer over the same list — a calendar
25
+ * that read `dueDate` itself would be the second reading, and the two would disagree about the case
26
+ * that is actually subtle (a task with a start date and no due date).
27
+ *
28
+ * ⚠️ A task without a due date is NOT in the grid and is counted underneath it. Nothing vanishes
29
+ * quietly: a month showing eleven of twenty tasks with no word about the other nine is a month
30
+ * that answers "what is due" with a number that is wrong.
31
+ */
32
+ export function BoardCalendar({ board, select, showArchived }: BoardViewProps) {
33
+ const i18n = useI18n();
34
+ const [month, setMonth] = useState(() => {
35
+ const now = new Date();
36
+ return new Date(now.getFullYear(), now.getMonth(), 1);
37
+ });
38
+
39
+ const schedule = useMemo(
40
+ () => boardSchedule(shownTasks(board.tasks, showArchived), board.statuses, board.blockedBy),
41
+ [board.tasks, board.statuses, board.blockedBy, showArchived],
42
+ );
43
+
44
+ return (
45
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-6">
46
+ <ItemCalendar<BoardItem>
47
+ items={schedule.items}
48
+ month={month}
49
+ onMonthChange={setMonth}
50
+ locale={i18n.locale}
51
+ weekStartsOn={weekStart(i18n.locale)}
52
+ labels={{
53
+ previous: i18n.t("board.calendar.previous"),
54
+ next: i18n.t("board.calendar.next"),
55
+ today: i18n.t("board.calendar.today"),
56
+ more: (count) => i18n.t("board.calendar.more", { count }),
57
+ }}
58
+ renderItem={(item) => (
59
+ <button
60
+ type="button"
61
+ onClick={() => select(item.id)}
62
+ // ⚠️ The same click as a card in a lane and a node in the graph: it opens the one
63
+ // detail panel. Three views with three different ideas of what selecting means would
64
+ // be three products.
65
+ // The status is on the left edge as well as in the dot: at this size a two-millimetre
66
+ // dot is the whole of the colour coding, and a bar down the side survives a glance
67
+ // across a whole month.
68
+ className={`flex w-full min-w-0 items-center gap-1.5 rounded border-l-2 bg-muted/60 px-1.5 py-0.5 text-left text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring ${item.status.tone.chip}`}
69
+ >
70
+ <span
71
+ aria-hidden="true"
72
+ className={`size-1.5 shrink-0 rounded-full ${item.status.tone.dot}`}
73
+ />
74
+ <span className="min-w-0 truncate">{item.name}</span>
75
+ {/* The status in words as well as in colour — the grid is the one place a reader has
76
+ no column heading to fall back on. */}
77
+ <span className="sr-only">{item.status.label}</span>
78
+ {item.blocked ? <span className="sr-only">{i18n.t("board.blocked")}</span> : null}
79
+ </button>
80
+ )}
81
+ />
82
+ {schedule.undated.length > 0 ? (
83
+ <p className="text-sm text-muted-foreground">
84
+ {i18n.t("board.calendar.undated", { count: schedule.undated.length })}
85
+ </p>
86
+ ) : null}
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,106 @@
1
+ import type { BoardAssignee, BoardTask } from "@anchrd/intel-contract";
2
+ import { Ban, Bot, CalendarDays, ListTree, User } from "lucide-react";
3
+ import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
+ import { useUserName } from "@/user-name/user-name.ts";
6
+
7
+ /**
8
+ * Who a task is on.
9
+ *
10
+ * ⚠️ An id is never shown, and never falls back to being shown. Intel has no user directory
11
+ * (`useUserName`), so most person ids cannot be resolved here — but an unassigned card and a card
12
+ * assigned to somebody this browser cannot name are two different facts, and drawing them the same
13
+ * would be the lie the DoD's "assignee on the card" exists to prevent. So the marker stays and only
14
+ * the name goes: "assigned", with nobody named.
15
+ */
16
+ export function BoardAssigneeLabel({ assignee }: { assignee: BoardAssignee }) {
17
+ const i18n = useI18n();
18
+ const person = useUserName(assignee.type === "user" ? assignee.id : null);
19
+ const agent = useEntryTitle(assignee.type === "agent" ? assignee.nodeId : "");
20
+ const Icon = assignee.type === "agent" ? Bot : User;
21
+ const name =
22
+ assignee.type === "agent" ? agent.title : (person ?? i18n.t("board.assignee.unnamed"));
23
+ return (
24
+ <span className="inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
25
+ <Icon aria-hidden="true" className="size-3.5 shrink-0" />
26
+ <span className="truncate">{name}</span>
27
+ </span>
28
+ );
29
+ }
30
+
31
+ /**
32
+ * The card, wherever it is drawn: in a lane, and in the overlay that follows the pointer.
33
+ *
34
+ * ⚠️ It never renders the description. A card is a card — what needs more than a line is opened,
35
+ * and the panel is where the description lives. The board arrives as ONE document (#285), so no
36
+ * card causes a request of its own either; there is nothing left for a card to load.
37
+ */
38
+ export function BoardCardBody({
39
+ task,
40
+ statusLabel,
41
+ toneDot,
42
+ blocked,
43
+ subtasks,
44
+ }: {
45
+ task: BoardTask;
46
+ statusLabel: string;
47
+ toneDot: string;
48
+ blocked: boolean;
49
+ subtasks: { done: number; total: number };
50
+ }) {
51
+ const i18n = useI18n();
52
+ return (
53
+ <div className="flex min-w-0 flex-col gap-2">
54
+ <div className="flex min-w-0 items-start gap-2">
55
+ <span
56
+ aria-hidden="true"
57
+ className={`mt-1 size-2 shrink-0 rounded-full ${toneDot}`}
58
+ data-slot="board-card-status"
59
+ />
60
+ <span className="min-w-0 text-sm font-medium break-words">{task.title}</span>
61
+ </div>
62
+ {/* The colour above says the status to whoever sees colour; this says it to everybody else,
63
+ and it is what makes the card readable in a screen reader's list of cards. */}
64
+ <span className="sr-only">{statusLabel}</span>
65
+ {blocked ? (
66
+ <span className="inline-flex w-fit items-center gap-1 rounded-md border border-destructive px-1.5 py-0.5 text-xs text-destructive">
67
+ <Ban aria-hidden="true" className="size-3" />
68
+ {i18n.t("board.blocked")}
69
+ </span>
70
+ ) : null}
71
+ {task.labels.length > 0 ? (
72
+ <ul className="flex flex-wrap gap-1">
73
+ {/* Once per word, which is what makes `key={label}` unique by construction rather than by
74
+ decoration (anchrd/intel#318) — the same reading the detail panel has. */}
75
+ {[...new Set(task.labels)].map((label) => (
76
+ <li
77
+ key={label}
78
+ className="rounded-md border bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
79
+ >
80
+ {label}
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ ) : null}
85
+ <div className="flex flex-wrap items-center gap-3">
86
+ {task.assignee ? <BoardAssigneeLabel assignee={task.assignee} /> : null}
87
+ {task.dueDate ? (
88
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
89
+ <CalendarDays aria-hidden="true" className="size-3.5" />
90
+ <time dateTime={task.dueDate}>
91
+ {new Date(`${task.dueDate}T00:00:00`).toLocaleDateString(i18n.locale)}
92
+ </time>
93
+ </span>
94
+ ) : null}
95
+ {/* ⚠️ A counter and not the children themselves. Drawing a subtask as a card of its own
96
+ would put the same work on the board twice — once under its parent and once beside it. */}
97
+ {subtasks.total > 0 ? (
98
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
99
+ <ListTree aria-hidden="true" className="size-3.5" />
100
+ {i18n.t("board.subtaskCount", { done: subtasks.done, total: subtasks.total })}
101
+ </span>
102
+ ) : null}
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,241 @@
1
+ import {
2
+ ArchivedBoardStatusId,
3
+ type BoardDocument,
4
+ type BoardStatus,
5
+ type BoardTask,
6
+ type NodeBoard,
7
+ } from "@anchrd/intel-contract";
8
+ import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useMemo } from "react";
10
+ import { useIntelRouterContext } from "@/router/router-context.ts";
11
+ import type { BoardHandle } from "./board-data.types.ts";
12
+
13
+ export const boardKey = (nodeId: string) => ["board", nodeId] as const;
14
+
15
+ // The columns somebody works in. `archived` is a shelf and not a stage, so it is out of every list
16
+ // that offers a status to move a task to and out of the lanes drawn by default.
17
+ export function visibleStatuses(statuses: BoardStatus[]): BoardStatus[] {
18
+ return statuses.filter((status) => status.id !== ArchivedBoardStatusId);
19
+ }
20
+
21
+ /**
22
+ * The board in the order it is drawn: by column, then by the server's fractional key.
23
+ *
24
+ * ⚠️ `order` is only defined WITHIN a column — the server mints a key between the neighbours a
25
+ * move named, and those neighbours are the ones in the column it lands in. Sorting the whole board
26
+ * by `order` alone would therefore interleave columns by an accident of key history. An unknown
27
+ * status sorts last rather than being dropped: a task whose column was configured away is still a
28
+ * task, and a view that silently loses it is worse than one that shows it at the end.
29
+ *
30
+ * ⚠️ And one entry per task id, however often the document names it (anchrd/intel#321). Here rather
31
+ * than in each renderer, for the same reason `shownTasks` is here: the kanban keys its cards by
32
+ * `task.id`, the table its rows, Gantt its bars and the graph its nodes, and React's answer to two
33
+ * children under one key is "duplicated and/or omitted — the behavior is unsupported". Four views
34
+ * would otherwise need four copies of one sentence, and the first bug is the day two of them
35
+ * disagree about which of the pair is on the board.
36
+ *
37
+ * ⚠️ Folded AFTER the sort, so the entry that survives is the one that would have been drawn first,
38
+ * and `byId` therefore answers with the card the reader is looking at.
39
+ *
40
+ * ⚠️ This is a decision about a drawing and not the one `upgradeStoredBoard` deliberately does not
41
+ * make (`repeatedBoardId`, packages/api). Two tasks under one id are two whole tasks; folding them
42
+ * on the SERVER's read would be written back by the next save and the second one would be gone for
43
+ * good. Nothing here writes anything — the document still holds both, and the day the pair is
44
+ * repaired the board draws both again.
45
+ */
46
+ export function orderedTasks(document: BoardDocument): BoardTask[] {
47
+ const rank = new Map(document.statuses.map((status, index) => [status.id, index]));
48
+ const last = document.statuses.length;
49
+ const sorted = [...document.tasks].sort(
50
+ (left, right) =>
51
+ (rank.get(left.status) ?? last) - (rank.get(right.status) ?? last) ||
52
+ (left.order < right.order ? -1 : left.order > right.order ? 1 : 0) ||
53
+ left.id.localeCompare(right.id),
54
+ );
55
+ const seen = new Set<string>();
56
+ return sorted.filter((task) => {
57
+ if (seen.has(task.id)) return false;
58
+ seen.add(task.id);
59
+ return true;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Which columns mean the work is finished (anchrd/intel#311).
65
+ *
66
+ * ⚠️ Read off the status list's own `terminal` flag, never off a position. This used to be "the last
67
+ * column before `archived`", reasoned by symmetry with the server's rule for a new task — and a
68
+ * board configured to `… done → blocked → archived` then counted "blocked" as finished, with a wrong
69
+ * blocked marker as the only symptom. A status says it for itself now, and this is the ONE place the
70
+ * UI asks.
71
+ *
72
+ * ⚠️ A status a task still stands in that the board no longer lists is NOT finished: nothing says it
73
+ * is, and treating an unknown column as done would let deleting a column clear blockers.
74
+ */
75
+ export function settledIn(statuses: BoardStatus[]): ReadonlySet<string> {
76
+ return new Set(statuses.filter((status) => status.terminal).map((status) => status.id));
77
+ }
78
+
79
+ /**
80
+ * What a view draws once the shelf switch has had its say.
81
+ *
82
+ * ⚠️ One rule for every view, here rather than in each of them. The kanban could have done it by
83
+ * leaving out a lane, the table by filtering a column, the graph by dropping nodes and Gantt by
84
+ * leaving a bar undrawn — four implementations of one sentence, and the first bug is the day two of
85
+ * them disagree about whether an archived subtask under a live parent counts as hidden.
86
+ */
87
+ export function shownTasks(tasks: BoardTask[], showArchived: boolean): BoardTask[] {
88
+ return showArchived ? tasks : tasks.filter((task) => task.status !== ArchivedBoardStatusId);
89
+ }
90
+
91
+ /**
92
+ * The board document with one task's write folded in.
93
+ *
94
+ * ⚠️ Patched, never refetched. Every task operation answers with the ONE task it wrote (#285), and
95
+ * a board of five thousand cards would otherwise re-read all of them after each drag. The status
96
+ * list travels along because `configureBoard` writes that instead of a task.
97
+ */
98
+ export function withTask(document: BoardDocument, task: BoardTask): BoardDocument {
99
+ const index = document.tasks.findIndex((existing) => existing.id === task.id);
100
+ const tasks =
101
+ index === -1
102
+ ? [...document.tasks, task]
103
+ : document.tasks.map((existing) => (existing.id === task.id ? task : existing));
104
+ return { ...document, tasks };
105
+ }
106
+
107
+ /**
108
+ * The board with a task and everything under it gone.
109
+ *
110
+ * ⚠️ Two things go, not one. The server cascades to every descendant AND clears the `dependsOn`
111
+ * entries that pointed into the hole, so a cache that only dropped the named task would keep
112
+ * showing subtasks whose parent no longer exists and blocked markers waiting on nothing. The
113
+ * server's answer carries a count and no ids, so the same walk is done here — from the same
114
+ * `parentId` field, which is the whole hierarchy.
115
+ */
116
+ export function withoutTask(document: BoardDocument, taskId: string): BoardDocument {
117
+ const gone = new Set([taskId]);
118
+ // A board is capped at five thousand tasks and five levels, so repeating the sweep until it
119
+ // stops growing is bounded and needs no ordering of its own.
120
+ for (let grew = true; grew; ) {
121
+ grew = false;
122
+ for (const task of document.tasks) {
123
+ if (task.parentId !== null && gone.has(task.parentId) && !gone.has(task.id)) {
124
+ gone.add(task.id);
125
+ grew = true;
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ ...document,
131
+ tasks: document.tasks
132
+ .filter((task) => !gone.has(task.id))
133
+ .map((task) =>
134
+ task.dependsOn.some((id) => gone.has(id))
135
+ ? { ...task, dependsOn: task.dependsOn.filter((id) => !gone.has(id)) }
136
+ : task,
137
+ ),
138
+ };
139
+ }
140
+
141
+ // One place writes the cache, so a mutation that forgets the version id cannot exist: the next
142
+ // read has to see the version the write produced, or the board would offer to write against one
143
+ // that is already superseded.
144
+ function patch(
145
+ client: QueryClient,
146
+ nodeId: string,
147
+ change: (document: BoardDocument) => BoardDocument,
148
+ versionId: string,
149
+ ): void {
150
+ client.setQueryData<NodeBoard>(boardKey(nodeId), (current) =>
151
+ current ? { ...current, board: change(current.board), versionId } : current,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * One board, and the only way any of its views reads or writes it (anchrd/intel#286).
157
+ *
158
+ * ⚠️ Every mutation mints its own `idempotencyKey`. A retried drag must not move a card twice, and
159
+ * a key minted by the view would be one the view has to remember across a re-render it does not
160
+ * control.
161
+ *
162
+ * ⚠️ A failed write invalidates rather than patching. The five operations are the only writers,
163
+ * but they are not the only ones: an agent holds the same board over MCP, and a refusal is the one
164
+ * moment this screen knows its copy may be behind.
165
+ */
166
+ export function useBoard(nodeId: string): BoardHandle {
167
+ const { data } = useIntelRouterContext();
168
+ const client = useQueryClient();
169
+ const query = useQuery({ queryKey: boardKey(nodeId), queryFn: () => data.getBoard(nodeId) });
170
+ const document = query.data?.board ?? null;
171
+
172
+ const derived = useMemo(() => {
173
+ const statuses = document?.statuses ?? [];
174
+ const tasks = document ? orderedTasks(document) : [];
175
+ const byId = new Map(tasks.map((task) => [task.id, task]));
176
+ const childrenOf = new Map<string, BoardTask[]>();
177
+ for (const task of tasks) {
178
+ if (task.parentId === null) continue;
179
+ const siblings = childrenOf.get(task.parentId);
180
+ if (siblings) siblings.push(task);
181
+ else childrenOf.set(task.parentId, [task]);
182
+ }
183
+ const settledStatuses = settledIn(statuses);
184
+ return {
185
+ statuses,
186
+ tasks,
187
+ byId,
188
+ childrenOf,
189
+ settled: (task: BoardTask) => settledStatuses.has(task.status),
190
+ // A dependency on a task that is not on this board cannot exist (#285 refuses it on the
191
+ // write path), so an id that resolves to nothing here is one the reader may not see — and it
192
+ // is left out rather than counted as blocking, because a marker nobody can explain is worse
193
+ // than none.
194
+ blockedBy: (task: BoardTask) =>
195
+ task.dependsOn.flatMap((id) => {
196
+ const other = byId.get(id);
197
+ return other && !settledStatuses.has(other.status) ? [other] : [];
198
+ }),
199
+ };
200
+ }, [document]);
201
+
202
+ const failed = () => client.invalidateQueries({ queryKey: boardKey(nodeId) });
203
+
204
+ const add = useMutation({
205
+ mutationFn: (input: Parameters<BoardHandle["add"]["mutate"]>[0]) =>
206
+ data.addBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
207
+ onSuccess: (result) =>
208
+ patch(client, nodeId, (d) => withTask(d, result.task), result.version.id),
209
+ onError: failed,
210
+ });
211
+ const update = useMutation({
212
+ mutationFn: (input: Parameters<BoardHandle["update"]["mutate"]>[0]) =>
213
+ data.updateBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
214
+ onSuccess: (result) =>
215
+ patch(client, nodeId, (d) => withTask(d, result.task), result.version.id),
216
+ onError: failed,
217
+ });
218
+ const move = useMutation({
219
+ mutationFn: (input: Parameters<BoardHandle["move"]["mutate"]>[0]) =>
220
+ data.moveBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
221
+ onSuccess: (result) =>
222
+ patch(client, nodeId, (d) => withTask(d, result.task), result.version.id),
223
+ onError: failed,
224
+ });
225
+ const remove = useMutation({
226
+ mutationFn: (input: { taskId: string }) =>
227
+ data.deleteBoardTask({ ...input, nodeId, idempotencyKey: crypto.randomUUID() }),
228
+ onSuccess: (result, input) =>
229
+ patch(client, nodeId, (d) => withoutTask(d, input.taskId), result.version.id),
230
+ onError: failed,
231
+ });
232
+ const configure = useMutation({
233
+ mutationFn: (statuses: Parameters<BoardHandle["configure"]["mutate"]>[0]) =>
234
+ data.configureBoard({ nodeId, statuses, idempotencyKey: crypto.randomUUID() }),
235
+ onSuccess: (result) =>
236
+ patch(client, nodeId, (d) => ({ ...d, statuses: result.statuses }), result.version.id),
237
+ onError: failed,
238
+ });
239
+
240
+ return { nodeId, query, ...derived, add, update, move, remove, configure };
241
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ AddBoardTaskInput,
3
+ BoardStatus,
4
+ BoardStatusInput,
5
+ BoardTask,
6
+ BoardTaskResult,
7
+ ConfigureBoardResult,
8
+ DeleteBoardTaskResult,
9
+ MoveBoardTaskInput,
10
+ NodeBoard,
11
+ UpdateBoardTaskInput,
12
+ } from "@anchrd/intel-contract";
13
+ import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
14
+
15
+ // What a caller hands over, minus what the data layer fills in for it: the node it is on and the
16
+ // idempotency key. Neither is a decision any view makes, and a view that could get them wrong is a
17
+ // view that could write to the wrong board.
18
+ export type AddTask = Omit<AddBoardTaskInput, "nodeId" | "idempotencyKey">;
19
+ export type UpdateTask = Omit<UpdateBoardTaskInput, "nodeId" | "idempotencyKey">;
20
+ export type MoveTask = Omit<MoveBoardTaskInput, "nodeId" | "idempotencyKey">;
21
+
22
+ /**
23
+ * The board, and every way to change it — the one thing a view is handed (anchrd/intel#286).
24
+ *
25
+ * ⚠️ No view loads and no view writes. Five of them draw the same list, the last of which (Gantt,
26
+ * anchrd/intel#293) arrived as a renderer rather than a rebuild — which only holds while the
27
+ * loading, the cache and the five operations live in exactly one place. A view that reached for
28
+ * `useQuery` itself would be a second cache entry for the same board: two readers, one stale, the
29
+ * same task in two states on one screen.
30
+ */
31
+ export interface BoardHandle {
32
+ nodeId: string;
33
+ query: UseQueryResult<NodeBoard>;
34
+ // The status list in the order it is drawn, `archived` included. A view that wants the columns a
35
+ // person works in filters it with `visibleStatuses`.
36
+ statuses: BoardStatus[];
37
+ // Every task of the board, ordered by status and then by `order`. Archived ones are in here too:
38
+ // hiding them is the KANBAN's switch, not a fact about the data, and a view that never saw them
39
+ // could not offer to show them.
40
+ tasks: BoardTask[];
41
+ byId: Map<string, BoardTask>;
42
+ // The direct children of a task, keyed by parent. Built once for every view that asks, and each
43
+ // building its own map would walk the whole board again.
44
+ childrenOf: Map<string, BoardTask[]>;
45
+ /**
46
+ * Whether something this task waits for is still open.
47
+ *
48
+ * ⚠️ "Open" is the status list's own `terminal` flag and nothing else (anchrd/intel#311). It is
49
+ * not a position: reading it as "the last column before `archived`" made a board configured to
50
+ * `… done → blocked → archived` treat "blocked" as finished, silently, with a wrong marker as the
51
+ * only symptom.
52
+ */
53
+ blockedBy(task: BoardTask): BoardTask[];
54
+ // The other half of the same question: a task nobody is waiting for any more. It is what a subtask
55
+ // counter counts and what `blockedBy` measures its dependencies against, so both come from one
56
+ // definition rather than two that can drift apart.
57
+ settled(task: BoardTask): boolean;
58
+ add: UseMutationResult<BoardTaskResult, Error, AddTask>;
59
+ update: UseMutationResult<BoardTaskResult, Error, UpdateTask>;
60
+ move: UseMutationResult<BoardTaskResult, Error, MoveTask>;
61
+ remove: UseMutationResult<DeleteBoardTaskResult, Error, { taskId: string }>;
62
+ configure: UseMutationResult<ConfigureBoardResult, Error, BoardStatusInput[]>;
63
+ }