@anchrd/intel-ui 0.38.0 → 0.40.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.
@@ -1,74 +1,265 @@
1
1
  import type { BoardTaskFilter } from "@anchrd/intel-contract/board";
2
2
  import type { Node } from "@anchrd/intel-contract/node";
3
- import { useState } from "react";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { useNavigate, useSearch } from "@tanstack/react-router";
5
+ import type { ColumnVisibilityState } from "@tanstack/react-table";
6
+ import { KanbanSquare, Settings, Table2 } from "lucide-react";
7
+ import { Suspense, useCallback, useState } from "react";
8
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
4
9
  import { useBoard } from "@/board/board-data/board-data.ts";
5
10
  import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
6
- import type { GroupKey, Sort } from "@/board/board-table/board-table.ts";
11
+ import { BoardSettings } from "@/board/board-settings/board-settings.tsx";
7
12
  import { BoardTable } from "@/board/board-table/board-table.tsx";
13
+ import { BoardTaskDocument } from "@/board/board-task/board-task.tsx";
8
14
  import { useI18n } from "@/i18n/i18n-context.tsx";
15
+ import { NodeEditor } from "@/node-editor/node-editor.tsx";
16
+ import { useIntelRouterContext } from "@/router/router-context.ts";
17
+ import { openTaskFrom } from "@/router/selection-search.ts";
9
18
 
10
19
  type View = "kanban" | "table";
11
20
 
21
+ const views = [
22
+ { name: "kanban", Icon: KanbanSquare },
23
+ { name: "table", Icon: Table2 },
24
+ ] as const;
25
+
12
26
  /**
13
27
  * A board on the node screen, beside the folder, the table and the editor (D66, #376).
14
28
  *
15
29
  * ⚠️ The whole board is ONE answer. `board_get` returns the columns and every card in them, and the
16
- * views below read that one object — no view fetches per card, per column, or on scroll. A board
17
- * that loaded its cards one by one would look exactly the same on screen and be a different thing
18
- * over the wire, which is why `board-panel.unit.tsx` counts the calls rather than the cards.
30
+ * views below read that one object — no view fetches per card, per column, or on scroll.
31
+ *
32
+ * ⚠️ **The controls live in the title line, not in a row of their own** (#363). `title-actions` puts
33
+ * them before the three-dot menu, which is where everything that acts on THIS thing already sits —
34
+ * a third bar above the board would be a permanent row for something nobody uses twice a minute.
19
35
  */
20
36
  export function BoardPanel({ node }: { node: Node }) {
21
37
  const i18n = useI18n();
22
38
  const [view, setView] = useState<View>("kanban");
39
+ const [settingsOpen, setSettingsOpen] = useState(false);
40
+ // Which columns the table shows outlives the trip through the kanban — see `BoardTable`.
41
+ const [hidden, setHidden] = useState<ColumnVisibilityState>({});
42
+ /**
43
+ * ⚠️ **The filter lives HERE and not in the table, since #671.** Opening a card unmounts the
44
+ * table, so a filter kept down there was silently thrown away by the one control that is the only
45
+ * way back out. It does NOT reach the kanban: that view reads this component's own unfiltered
46
+ * board answer, a different query — which is what kept the filter out of it before, and still is.
47
+ */
48
+ const [filter, setFilter] = useState<Partial<BoardTaskFilter>>({});
49
+ const board = useBoard(node.id);
50
+ const search = useSearch({ strict: false });
51
+ const navigate = useNavigate();
52
+ const openId = openTaskFrom(search);
53
+
54
+ /**
55
+ * ⚠️ **The address, not a piece of state.** A task opened into component state would have no link
56
+ * and no back button — the browser's back would leave the board entirely, which is the one thing
57
+ * somebody pressing it after opening a card does not want.
58
+ */
59
+ const open = useCallback(
60
+ (taskId: string | null) =>
61
+ void navigate({
62
+ to: ".",
63
+ // ⚠️ **Closing REPLACES, opening pushes.** Opening a card is a step somebody took and the
64
+ // back button must undo it; closing is the undo itself. Pushed as well, the history holds
65
+ // board → card → board, and one press of Back re-opens the card that was just closed —
66
+ // exactly the behaviour the address was chosen to avoid.
67
+ replace: taskId === null,
68
+ search: (previous: Record<string, unknown>) =>
69
+ taskId === null ? { ...previous, task: undefined } : { ...previous, task: taskId },
70
+ }),
71
+ // ⚠️ Stable, or the table's `useMemo` over its columns rebuilds on every render and the memo
72
+ // is decoration.
73
+ [navigate],
74
+ );
75
+
23
76
  return (
24
77
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
25
- {/* No wrapper role: the house switch (`view-toggle.tsx`) carries none either, and a `group`
26
- on a plain `div` is the shape a linter rightly asks to be a `fieldset`. What names the
27
- control is each button its own text, plus `aria-pressed` for which view is showing. */}
28
- <div className="flex shrink-0 items-center gap-1 border-b px-4 py-2">
29
- {(["kanban", "table"] as const).map((name) => (
78
+ <ActionSlot name="title-actions">
79
+ {/* ⚠️ **Gone while a card is open.** The switch chooses between two views OF THE BOARD, and
80
+ an open card is neitherleft standing it reports `aria-pressed` for a view nobody is
81
+ looking at and does nothing when pressed, which is a control that lies twice. */}
82
+ {(openId === null ? views : []).map(({ name, Icon }) => (
30
83
  <button
31
84
  key={name}
32
85
  type="button"
33
86
  aria-pressed={view === name}
87
+ aria-label={i18n.t(`board.view.${name}`)}
34
88
  onClick={() => setView(name)}
35
- className="rounded-md px-3 py-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted aria-pressed:font-medium"
89
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-accent"
36
90
  >
37
- {i18n.t(`board.view.${name}`)}
91
+ <Icon aria-hidden="true" className="size-4" />
38
92
  </button>
39
93
  ))}
40
- </div>
41
- {view === "kanban" ? <KanbanView nodeId={node.id} /> : <TableView nodeId={node.id} />}
94
+ <button
95
+ type="button"
96
+ aria-label={i18n.t("board.settings.open")}
97
+ onClick={() => setSettingsOpen(true)}
98
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
99
+ >
100
+ <Settings aria-hidden="true" className="size-4" />
101
+ </button>
102
+ </ActionSlot>
103
+ {/* ⚠️ Mounted only while open, so its draft is seeded once per opening. Handed an `open` flag
104
+ instead, a draft re-seeded from the board would lose what somebody typed the moment a
105
+ write from elsewhere answers. */}
106
+ {settingsOpen ? <BoardSettings board={board} onClose={() => setSettingsOpen(false)} /> : null}
107
+ {openId === null ? (
108
+ view === "kanban" ? (
109
+ <KanbanView board={board} onOpen={open} />
110
+ ) : (
111
+ <TableView
112
+ nodeId={node.id}
113
+ hidden={hidden}
114
+ onHidden={setHidden}
115
+ onOpen={open}
116
+ filter={filter}
117
+ onFilter={setFilter}
118
+ />
119
+ )
120
+ ) : (
121
+ // ⚠️ Drawn from the panel's own UNFILTERED board answer, which is also what the kanban
122
+ // reads — the open task is not a second read of the card. ⚠️ It is NOT the table's answer:
123
+ // the table keeps its own filtered query, so a card filtered out of the table still opens
124
+ // from a link. What this cannot find is a card that is gone, or one on another board.
125
+ <OpenTask board={board} taskId={openId} onOpen={open} />
126
+ )}
42
127
  </div>
43
128
  );
44
129
  }
45
130
 
46
- function KanbanView({ nodeId }: { nodeId: string }) {
47
- const board = useBoard(nodeId);
48
- return <BoardKanban board={board} />;
131
+ function KanbanView({
132
+ board,
133
+ onOpen,
134
+ }: {
135
+ board: ReturnType<typeof useBoard>;
136
+ onOpen(taskId: string): void;
137
+ }) {
138
+ return <BoardKanban board={board} onOpen={onOpen} />;
49
139
  }
50
140
 
51
141
  /**
52
- * ⚠️ **The filter lives HERE, inside the table's own branch, not beside both views.** Held one
53
- * level up it would survive a switch to the kanban board — which draws whatever the query answered
54
- * and would then show a column full and the rest empty, with nothing on screen saying why. That is
55
- * the quiet failure: cards that look deleted. Unmounting the table takes its filter with it, and
56
- * the kanban asks for the whole board every time.
142
+ * One card, opened.
143
+ *
144
+ * ⚠️ **The body is the node's own document and is the ONE thing this screen reads separately.** The
145
+ * head and the link list come out of `board_get`; the markdown text does not live in that answer at
146
+ * all a card's text is a node version, and `board_get` returns cards, not documents.
57
147
  */
58
- function TableView({ nodeId }: { nodeId: string }) {
59
- const [filter, setFilter] = useState<Partial<BoardTaskFilter>>({});
60
- const [sort, setSort] = useState<Sort | null>(null);
61
- const [group, setGroup] = useState<GroupKey>("none");
148
+ function OpenTask({
149
+ board,
150
+ taskId,
151
+ onOpen,
152
+ }: {
153
+ board: ReturnType<typeof useBoard>;
154
+ taskId: string;
155
+ onOpen(taskId: string | null): void;
156
+ }) {
157
+ const i18n = useI18n();
158
+ const task = board.columns.flatMap((entry) => entry.cards).find((card) => card.id === taskId);
159
+
160
+ if (board.isPending) {
161
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
162
+ }
163
+ // ⚠️ Back to the board rather than an empty screen. The board answer here is unfiltered, so a
164
+ // card missing from it is a card that is gone, archived out of view, or on a different board —
165
+ // a stale link and a deleted card look exactly the same from here, and both want the same way out.
166
+ if (task === undefined) {
167
+ return (
168
+ <div className="flex flex-col items-start gap-3 p-6">
169
+ <p className="text-sm text-muted-foreground">{i18n.t("board.taskGone")}</p>
170
+ <button
171
+ type="button"
172
+ onClick={() => onOpen(null)}
173
+ className="rounded-md border px-3 py-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
174
+ >
175
+ {board.title}
176
+ </button>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ return (
182
+ <BoardTaskDocument
183
+ task={task}
184
+ board={board}
185
+ onOpen={onOpen}
186
+ onClose={() => onOpen(null)}
187
+ body={<TaskBody nodeId={task.id} />}
188
+ />
189
+ );
190
+ }
191
+
192
+ /**
193
+ * ⚠️ **The filter is handed DOWN since #671, and that is safe for a different reason than it looks.**
194
+ * It used to live here so that unmounting the table took it along — until opening a card, which
195
+ * also unmounts the table, threw the filter away through the one control that is the way back out.
196
+ * It sits in the panel now, and the kanban still shows everything: that view reads the panel's own
197
+ * unfiltered query, a different key, so no filtered answer ever reaches it. Measured, not argued —
198
+ * after a switch there is no new request at all, and `leaves the kanban unfiltered after a filter
199
+ * was set in the table` renders the whole panel, so it exercises exactly this arrangement.
200
+ */
201
+ function TableView({
202
+ nodeId,
203
+ hidden,
204
+ onHidden,
205
+ onOpen,
206
+ filter,
207
+ onFilter,
208
+ }: {
209
+ nodeId: string;
210
+ hidden: ColumnVisibilityState;
211
+ onHidden(next: ColumnVisibilityState): void;
212
+ onOpen(taskId: string): void;
213
+ filter: Partial<BoardTaskFilter>;
214
+ onFilter(next: Partial<BoardTaskFilter>): void;
215
+ }) {
62
216
  const board = useBoard(nodeId, filter);
217
+ // ⚠️ Sorting and grouping live INSIDE the table since #670 — they are the table's own state and
218
+ // never travel. The column choice and the filter are handed DOWN from the panel because both have
219
+ // to outlive this component; the kanban stays unfiltered because it reads a different query.
63
220
  return (
64
221
  <BoardTable
65
222
  board={board}
66
223
  filter={filter}
67
- onFilter={setFilter}
68
- sort={sort}
69
- onSort={setSort}
70
- group={group}
71
- onGroup={setGroup}
224
+ onFilter={onFilter}
225
+ hidden={hidden}
226
+ onHidden={onHidden}
227
+ onOpen={onOpen}
72
228
  />
73
229
  );
74
230
  }
231
+
232
+ /**
233
+ * The card's markdown text — the node's own document, in the editor every other document uses.
234
+ *
235
+ * ⚠️ **A second read, and the only one on this screen.** `board_get` answers with cards, not with
236
+ * documents: a card's text is a node version and is not in that answer at all. Building a second
237
+ * editor here instead would be the more expensive mistake — one text surface that saves, links and
238
+ * renders differently from the one next door.
239
+ */
240
+ function TaskBody({ nodeId }: { nodeId: string }) {
241
+ const i18n = useI18n();
242
+ const { data } = useIntelRouterContext();
243
+ const queryClient = useQueryClient();
244
+ const document = useQuery({
245
+ queryKey: ["node", nodeId],
246
+ queryFn: () => data.getNode(nodeId),
247
+ });
248
+
249
+ if (document.data === undefined) {
250
+ return <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
251
+ }
252
+ return (
253
+ <Suspense
254
+ fallback={<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>}
255
+ >
256
+ <NodeEditor
257
+ key={nodeId}
258
+ data={data}
259
+ document={document.data}
260
+ i18n={i18n}
261
+ onSaved={(saved) => queryClient.setQueryData(["node", nodeId], saved)}
262
+ />
263
+ </Suspense>
264
+ );
265
+ }
@@ -0,0 +1,209 @@
1
+ import { ARCHIVE_COLUMN_ID, type BoardColumn } from "@anchrd/intel-contract/board";
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
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import { useI18n } from "@/i18n/i18n-context.tsx";
14
+
15
+ /**
16
+ * A board's basic settings: its columns, and whether the archive is drawn.
17
+ *
18
+ * ⚠️ **The whole list is written at once and never patched.** Adding, renaming and reordering are
19
+ * one call, because the ORDER is the list's order — there is nothing to patch a position with. So
20
+ * the dialog holds a draft and sends it on save; a control that wrote per keystroke would write a
21
+ * version per letter.
22
+ *
23
+ * ⚠️ **The archive column is not in this list and cannot be.** It is derived from `archived_at`
24
+ * (D68) and the server refuses a configured column that claims its id. What the switch below
25
+ * changes is only whether it is drawn.
26
+ */
27
+ export function BoardSettings({ board, onClose }: { board: BoardHandle; onClose(): void }) {
28
+ const i18n = useI18n();
29
+ // ⚠️ Seeded once per OPENING, which is why the caller mounts this only while it is open rather
30
+ // than handing it an `open` flag: a draft re-seeded from a prop would lose what somebody typed
31
+ // the moment the board answers a write from somewhere else.
32
+ const [columns, setColumns] = useState<BoardColumn[]>(() =>
33
+ board.columns
34
+ .filter((entry) => entry.unknown !== true && entry.column.id !== ARCHIVE_COLUMN_ID)
35
+ .map((entry) => entry.column),
36
+ );
37
+ const [archiveVisible, setArchiveVisible] = useState(board.archiveVisible);
38
+ const [failed, setFailed] = useState(false);
39
+
40
+ /**
41
+ * How many cards each column carries, taken once from the board.
42
+ *
43
+ * ⚠️ **A column with cards is not removable here** — `destructive.md`: *verweigern schlägt
44
+ * kaskadieren*. Removed, its cards do not vanish from the database, but their `status` names a
45
+ * column nobody configured any more: they fall into the stray column, and nobody was asked where
46
+ * they should go. That is the same cascade one level delayed.
47
+ */
48
+ const cardsIn = new Map(board.columns.map((entry) => [entry.column.id, entry.cards.length]));
49
+
50
+ const move = (index: number, by: -1 | 1) => {
51
+ const next = [...columns];
52
+ const target = index + by;
53
+ const a = next[index];
54
+ const b = next[target];
55
+ if (a === undefined || b === undefined) return;
56
+ next[index] = b;
57
+ next[target] = a;
58
+ setColumns(next);
59
+ };
60
+
61
+ const save = () => {
62
+ setFailed(false);
63
+ void board
64
+ .setColumns({
65
+ columns: columns.map((column) => ({ ...column, title: column.title.trim() })),
66
+ archiveVisible,
67
+ idempotencyKey: crypto.randomUUID(),
68
+ })
69
+ .then(onClose)
70
+ .catch(() => setFailed(true));
71
+ };
72
+
73
+ // ⚠️ An empty title would be refused by the contract, and a list with none at all by the server.
74
+ // Saying so before the call is the difference between a form and a guessing game.
75
+ const titles = columns.map((column) => column.title.trim());
76
+ /**
77
+ * ⚠️ **Two columns with the same NAME are refused too**, although the server only refuses
78
+ * duplicate ids. An id nobody sees may repeat without confusing anybody; a name is what a person
79
+ * reads, and two columns called the same thing are two columns nobody can tell apart — on the
80
+ * board, in the filter, and in this very dialog.
81
+ */
82
+ const duplicate = titles.find((title, index) => titles.indexOf(title) !== index);
83
+ const incomplete =
84
+ columns.length === 0 || titles.some((title) => title.length === 0) || duplicate !== undefined;
85
+
86
+ return (
87
+ <Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
88
+ <DialogContent>
89
+ <DialogHeader>
90
+ <DialogTitle>{i18n.t("board.settings.title")}</DialogTitle>
91
+ <DialogDescription>{i18n.t("board.settings.description")}</DialogDescription>
92
+ </DialogHeader>
93
+
94
+ <ul className="space-y-2">
95
+ {columns.map((column, index) => (
96
+ <li key={column.id} className="flex items-center gap-2">
97
+ <input
98
+ value={column.title}
99
+ aria-label={i18n.t("board.settings.columnName", { column: column.title })}
100
+ onChange={(event) => {
101
+ const next = [...columns];
102
+ next[index] = { ...column, title: event.target.value };
103
+ setColumns(next);
104
+ }}
105
+ className="min-w-0 flex-1 rounded-md border bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
106
+ />
107
+ <button
108
+ type="button"
109
+ aria-label={i18n.t("board.settings.moveUp", { column: column.title })}
110
+ disabled={index === 0}
111
+ onClick={() => move(index, -1)}
112
+ className="rounded-md p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
113
+ >
114
+ <ChevronUp aria-hidden="true" className="size-4" />
115
+ </button>
116
+ <button
117
+ type="button"
118
+ aria-label={i18n.t("board.settings.moveDown", { column: column.title })}
119
+ disabled={index === columns.length - 1}
120
+ onClick={() => move(index, 1)}
121
+ className="rounded-md p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
122
+ >
123
+ <ChevronDown aria-hidden="true" className="size-4" />
124
+ </button>
125
+ <button
126
+ type="button"
127
+ aria-label={
128
+ (cardsIn.get(column.id) ?? 0) > 0
129
+ ? // ⚠️ No count in this sentence. `packages/ui/CLAUDE.md`: a sentence that
130
+ // carries a number has to read correctly at ONE, and "1 card(s)" is a written
131
+ // admission that it does not. The number is on the board anyway; here what
132
+ // matters is that the column is not empty.
133
+ i18n.t("board.settings.removeBlocked", { column: column.title })
134
+ : i18n.t("board.settings.remove", { column: column.title })
135
+ }
136
+ disabled={(cardsIn.get(column.id) ?? 0) > 0}
137
+ onClick={() => setColumns(columns.filter((entry) => entry.id !== column.id))}
138
+ className="rounded-md p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
139
+ >
140
+ <X aria-hidden="true" className="size-4" />
141
+ </button>
142
+ </li>
143
+ ))}
144
+ </ul>
145
+
146
+ <button
147
+ type="button"
148
+ onClick={() =>
149
+ setColumns([
150
+ ...columns,
151
+ {
152
+ id: crypto.randomUUID(),
153
+ title: i18n.t("board.settings.newColumn"),
154
+ terminal: false,
155
+ },
156
+ ])
157
+ }
158
+ className="self-start rounded-md px-2 py-1 text-sm text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
159
+ >
160
+ {i18n.t("board.settings.addColumn")}
161
+ </button>
162
+
163
+ <label className="flex items-start gap-3 border-t pt-3 text-sm">
164
+ <input
165
+ type="checkbox"
166
+ checked={archiveVisible}
167
+ onChange={(event) => setArchiveVisible(event.target.checked)}
168
+ className="mt-1"
169
+ />
170
+ <span>
171
+ {i18n.t("board.settings.showArchive")}
172
+ <span className="block text-xs text-muted-foreground">
173
+ {i18n.t("board.settings.showArchiveHint")}
174
+ </span>
175
+ </span>
176
+ </label>
177
+
178
+ {duplicate === undefined ? null : (
179
+ <p role="alert" className="text-sm text-destructive">
180
+ {i18n.t("board.settings.duplicateName", { column: duplicate })}
181
+ </p>
182
+ )}
183
+ {failed ? (
184
+ <p role="alert" className="text-sm text-destructive">
185
+ {i18n.t("board.settings.failed")}
186
+ </p>
187
+ ) : null}
188
+
189
+ <DialogFooter>
190
+ <button
191
+ type="button"
192
+ onClick={onClose}
193
+ className="rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
194
+ >
195
+ {i18n.t("common.cancel")}
196
+ </button>
197
+ <button
198
+ type="button"
199
+ disabled={incomplete || board.isWriting}
200
+ onClick={save}
201
+ className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
202
+ >
203
+ {i18n.t("common.save")}
204
+ </button>
205
+ </DialogFooter>
206
+ </DialogContent>
207
+ </Dialog>
208
+ );
209
+ }
@@ -0,0 +1,128 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+
3
+ /** How many steps the family ramp has before it repeats (`styles.css`). */
4
+ export const FAMILY_TONES = 6;
5
+
6
+ /**
7
+ * ⚠️ **A guard against a cycle in the parent chain, not a limit on nesting.** The depth itself is
8
+ * unbounded (D37) and the server refuses a move into a node's own descendant — but this walk runs
9
+ * on whatever answer arrived, and an answer that somehow carried a loop would hang the render
10
+ * rather than draw a wrong card. A number this large is never reached by real work.
11
+ */
12
+ const MAX_WALK = 1_000;
13
+
14
+ export interface Family {
15
+ /** 1 for a root task, 2 for its child, and so on — the number of stripes drawn. 0 means none. */
16
+ level: number;
17
+ /** Which step of the ramp, 1…`FAMILY_TONES`. Every card of one family shares it. */
18
+ tone: number;
19
+ /** The root task of the family, or `null` when this card is not in one. */
20
+ rootId: string | null;
21
+ }
22
+
23
+ /**
24
+ * Which family a card belongs to, and how deep it sits in it.
25
+ *
26
+ * ⚠️ **A card alone is in NO family and takes no stripes.** The pattern exists to relate cards to
27
+ * one another; drawn on a card that relates to nothing it says "family" where there is none, and a
28
+ * mark that appears on every card carries no information at all. That is a deliberate reading of
29
+ * "a root task has one stripe": a root WITH children has one, a card by itself has none.
30
+ *
31
+ * ⚠️ **An unknown parent is treated as no parent.** Under a filter a subtask can be in the answer
32
+ * while its parent is not (`board.ts` says so on `parentTaskId`), and a walk that insisted on
33
+ * finding it would lose the card entirely.
34
+ */
35
+ export function familyOf(task: BoardTask, tasks: BoardTask[]): Family {
36
+ return familiesOf(tasks).get(task.id) ?? { level: 0, tone: 0, rootId: null };
37
+ }
38
+
39
+ /**
40
+ * Every card's family, in ONE pass over the board.
41
+ *
42
+ * ⚠️ **Per board, not per card.** Asking `familyOf` once per card rebuilds the index every time,
43
+ * which is quadratic — measured at 800 cards it cost 36 ms per render, and `dnd-kit` re-renders on
44
+ * every pointer move while a card is being dragged. The whole board is one answer; its families are
45
+ * one derivation.
46
+ */
47
+ export function familiesOf(tasks: BoardTask[]): Map<string, Family> {
48
+ const byId = new Map(tasks.map((entry) => [entry.id, entry]));
49
+ const parents = new Set<string>();
50
+ for (const entry of tasks) if (entry.parentTaskId !== null) parents.add(entry.parentTaskId);
51
+
52
+ const families = new Map<string, Family>();
53
+ for (const task of tasks) families.set(task.id, walkFamily(task, byId, parents.has(task.id)));
54
+ return families;
55
+ }
56
+
57
+ function walkFamily(task: BoardTask, byId: Map<string, BoardTask>, hasChildren: boolean): Family {
58
+ let level = 1;
59
+ let root = task;
60
+ const seen = new Set<string>([task.id]);
61
+ for (let step = 0; step < MAX_WALK; step += 1) {
62
+ const parentId = root.parentTaskId;
63
+ if (parentId === null || seen.has(parentId)) break;
64
+ const parent = byId.get(parentId);
65
+ if (parent === undefined) break;
66
+ seen.add(parentId);
67
+ root = parent;
68
+ level += 1;
69
+ }
70
+
71
+ if (level === 1 && !hasChildren) return { level: 0, tone: 0, rootId: null };
72
+ return { level, tone: toneOf(root.id), rootId: root.id };
73
+ }
74
+
75
+ /**
76
+ * ⚠️ **Derived from the root's ID, not from its place in the list.** A position would change every
77
+ * time somebody drags a card, and with it every stripe on the board — the one thing a reader was
78
+ * meant to recognise across a whole session.
79
+ */
80
+ export function toneOf(rootId: string): number {
81
+ let sum = 0;
82
+ for (const character of rootId) sum = (sum * 31 + character.charCodeAt(0)) % 1_000_003;
83
+ return (sum % FAMILY_TONES) + 1;
84
+ }
85
+
86
+ /**
87
+ * The cards of ONE column, ordered so a subtask follows its parent.
88
+ *
89
+ * ⚠️ **Threaded, not nested.** A subtask that sits in the same column as its parent is drawn
90
+ * directly below it, and its own children below that — depth first. A subtask whose parent is in
91
+ * ANOTHER column has no thread to join here and takes its ordinary place by position. The cards
92
+ * stay the same width with the same left edge; the relation is carried by the stripes, not by an
93
+ * indent that would eat the width exactly where the title is.
94
+ */
95
+ export function threaded(cards: BoardTask[]): BoardTask[] {
96
+ const here = new Set(cards.map((card) => card.id));
97
+ const byPosition = [...cards].sort((a, b) => a.position - b.position);
98
+ const children = new Map<string, BoardTask[]>();
99
+ const roots: BoardTask[] = [];
100
+
101
+ for (const card of byPosition) {
102
+ const parentId = card.parentTaskId;
103
+ if (parentId === null || !here.has(parentId)) {
104
+ roots.push(card);
105
+ continue;
106
+ }
107
+ const siblings = children.get(parentId);
108
+ if (siblings === undefined) children.set(parentId, [card]);
109
+ else siblings.push(card);
110
+ }
111
+
112
+ const out: BoardTask[] = [];
113
+ const emitted = new Set<string>();
114
+ // ⚠️ A REAL guard, and against a real hole: with a loop in the parent chain (a under b, b under a,
115
+ // or a card under itself) neither card is a root, so the walk below never starts on them. Left at
116
+ // that, the column silently loses them — work falls off the board with no error anywhere.
117
+ const walk = (card: BoardTask) => {
118
+ if (emitted.has(card.id)) return;
119
+ emitted.add(card.id);
120
+ out.push(card);
121
+ for (const child of children.get(card.id) ?? []) walk(child);
122
+ };
123
+ for (const root of roots) walk(root);
124
+ // ⚠️ Whatever the walk did not reach is appended in the board's own order. A card in a loop has
125
+ // no sensible place in a thread, and the bottom of the column is the honest one — visible.
126
+ for (const card of byPosition) walk(card);
127
+ return out;
128
+ }