@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.
- package/package.json +10 -2
- package/src/agent/agent-profile/agent-profile.tsx +2 -0
- package/src/board/board-calendar/board-calendar.tsx +89 -0
- package/src/board/board-card/board-card.tsx +106 -0
- package/src/board/board-data/board-data.ts +241 -0
- package/src/board/board-data/board-data.types.ts +63 -0
- package/src/board/board-detail/board-detail.tsx +629 -0
- package/src/board/board-gantt/board-gantt.ts +545 -0
- package/src/board/board-gantt/board-gantt.tsx +286 -0
- package/src/board/board-graph/board-graph.ts +174 -0
- package/src/board/board-graph/board-graph.tsx +168 -0
- package/src/board/board-items/board-items.ts +183 -0
- package/src/board/board-kanban/board-kanban.ts +97 -0
- package/src/board/board-kanban/board-kanban.tsx +211 -0
- package/src/board/board-status/board-status.ts +59 -0
- package/src/board/board-statuses/board-statuses.ts +63 -0
- package/src/board/board-statuses/board-statuses.tsx +228 -0
- package/src/board/board-table/board-table.ts +33 -0
- package/src/board/board-table/board-table.tsx +413 -0
- package/src/board/board-views/board-views.tsx +68 -0
- package/src/board/board-views/board-views.types.ts +29 -0
- package/src/board/board.tsx +251 -0
- package/src/components/ui/dropdown-menu.tsx +25 -0
- package/src/components/ui/item-calendar.tsx +181 -0
- package/src/components/ui/item-gantt.tsx +463 -0
- package/src/components/ui/kanban.tsx +245 -0
- package/src/components/ui/switch.tsx +25 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
- package/src/i18n/de.json +88 -1
- package/src/i18n/en.json +88 -1
- package/src/i18n/es.json +88 -1
- package/src/kind-icon.ts +12 -1
- package/src/nodes/nodes.tsx +16 -0
- 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.
|
|
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.
|
|
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
|
+
}
|