@anchrd/intel-ui 0.18.0 → 0.19.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 +2 -9
- package/src/app/app-tree/app-tree.tsx +59 -147
- package/src/app/settings-dialog/settings-dialog.tsx +10 -32
- package/src/data/intel-data-provider/intel-data-provider.ts +0 -163
- package/src/data/intel-data-provider/intel-data-provider.types.ts +0 -100
- package/src/entry-picker/entry-picker.tsx +6 -6
- package/src/i18n/de.json +6 -217
- package/src/i18n/en.json +6 -217
- package/src/i18n/es.json +6 -217
- package/src/import-bundle/import-bundle.tsx +152 -0
- package/src/kind-icon.ts +1 -15
- package/src/main.tsx +7 -10
- package/src/node-table/node-table.tsx +2 -2
- package/src/nodes/nodes.tsx +0 -34
- package/src/resource-menu/resource-menu.tsx +42 -14
- package/src/section-hint/section-hint.tsx +2 -2
- package/src/title-row/title-row.tsx +5 -22
- package/src/tools/tools.tsx +0 -2
- package/vite.config.ts +0 -4
- package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
- package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
- package/src/agent/agent-chat/agent-chat.tsx +0 -118
- package/src/agent/agent-costs/agent-costs.ts +0 -95
- package/src/agent/agent-cron/agent-cron.ts +0 -68
- package/src/agent/agent-definition/agent-definition.ts +0 -96
- package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
- package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
- package/src/agent/agent-log/agent-log.tsx +0 -308
- package/src/agent/agent-models/agent-models.ts +0 -166
- package/src/agent/agent-models/agent-models.types.ts +0 -24
- package/src/agent/agent-profile/agent-profile.tsx +0 -1006
- package/src/agent/agent-state/agent-state.ts +0 -85
- package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
- package/src/agent/agent.tsx +0 -507
- package/src/board/board-calendar/board-calendar.tsx +0 -89
- package/src/board/board-card/board-card.tsx +0 -106
- package/src/board/board-data/board-data.ts +0 -241
- package/src/board/board-data/board-data.types.ts +0 -63
- package/src/board/board-detail/board-detail.tsx +0 -629
- package/src/board/board-gantt/board-gantt.ts +0 -545
- package/src/board/board-gantt/board-gantt.tsx +0 -286
- package/src/board/board-graph/board-graph.ts +0 -174
- package/src/board/board-graph/board-graph.tsx +0 -168
- package/src/board/board-items/board-items.ts +0 -183
- package/src/board/board-kanban/board-kanban.ts +0 -137
- package/src/board/board-kanban/board-kanban.tsx +0 -257
- package/src/board/board-status/board-status.ts +0 -59
- package/src/board/board-statuses/board-statuses.ts +0 -63
- package/src/board/board-statuses/board-statuses.tsx +0 -228
- package/src/board/board-table/board-table.ts +0 -33
- package/src/board/board-table/board-table.tsx +0 -413
- package/src/board/board-views/board-views.tsx +0 -68
- package/src/board/board-views/board-views.types.ts +0 -29
- package/src/board/board.tsx +0 -251
- package/src/components/ui/item-calendar.tsx +0 -181
- package/src/components/ui/item-gantt.tsx +0 -463
- package/src/components/ui/kanban.tsx +0 -282
- package/src/data/agent-runtime/agent-runtime.ts +0 -131
- package/src/hooks/use-capabilities.ts +0 -22
- package/src/hooks/use-model-catalog.ts +0 -26
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
- package/src/timezone/timezone-context.tsx +0 -65
- package/src/timezone/timezone.ts +0 -174
|
@@ -1,183 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,137 +0,0 @@
|
|
|
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 DID: the one `board_task_move` it is worth, or the reason there is none.
|
|
7
|
-
*
|
|
8
|
-
* ⚠️ Three answers rather than a move or a `null`, and the third one is the whole of
|
|
9
|
-
* anchrd/intel#345. Two of the ways a drag ends write nothing, and they are not the same event to
|
|
10
|
-
* whoever is being told about it: a card let go on its own place stayed put, and a card let go over
|
|
11
|
-
* a lane the board cannot write into was REFUSED. Announced as one — or, as it was, announced as a
|
|
12
|
-
* move that never happened — the difference is exactly the one a reader needs, because since
|
|
13
|
-
* anchrd/intel#315 the announcement is the only feedback the keyboard path has.
|
|
14
|
-
*/
|
|
15
|
-
export type DropOutcome =
|
|
16
|
-
// ⚠️ `status` is optional on the wire — a move may be a reparent and name no column — but a drop
|
|
17
|
-
// always lands in a lane and always names it. Narrowed here so the sentence a reader hears can
|
|
18
|
-
// name the lane the card really went into rather than the one the pointer was last over.
|
|
19
|
-
| { kind: "move"; move: MoveTask & { status: string } }
|
|
20
|
-
// The status the drop resolved to: drawn by the board, and not one it may write into. Carried
|
|
21
|
-
// rather than looked up again, so the sentence a reader hears names the lane that said no.
|
|
22
|
-
| { kind: "refused"; status: string }
|
|
23
|
-
// Nothing to write and nothing refused: the card ends where it began, or it was let go over
|
|
24
|
-
// something the board does not draw.
|
|
25
|
-
| { kind: "unchanged" };
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* ⚠️ Neighbours, never an index. The server mints the fractional key between the two tasks named
|
|
29
|
-
* here (#285), so one drag writes one task and renumbers nothing — the reason a board is addressed
|
|
30
|
-
* by task id and not by position at all. A UI that sent a position would have to send the whole
|
|
31
|
-
* column with it to be unambiguous.
|
|
32
|
-
*
|
|
33
|
-
* ⚠️ Nothing to write for a drag that changed nothing, and that is half the point of this function.
|
|
34
|
-
* dnd-kit reports a drop wherever the pointer was let go, including back on the card's own place;
|
|
35
|
-
* sending a move for that would write a version, bump the board and show up in the audit as work
|
|
36
|
-
* that did not happen.
|
|
37
|
-
*
|
|
38
|
-
* `lanes` is what the board DRAWS — root tasks per column, in order. Subtasks are not lane cards
|
|
39
|
-
* (they are a counter on their parent), so they are not in it and cannot be dropped on either.
|
|
40
|
-
*/
|
|
41
|
-
export function dropOutcome(
|
|
42
|
-
drop: KanbanDrop,
|
|
43
|
-
lanes: Map<string, BoardTask[]>,
|
|
44
|
-
byId: Map<string, BoardTask>,
|
|
45
|
-
// The statuses the board actually lists. ⚠️ Not the same as the lanes: a lane is also drawn for a
|
|
46
|
-
// status the board no longer has, so that the cards left in it stay visible — and that lane must
|
|
47
|
-
// not be somewhere a card can be MOVED. The server refuses a task in a status the board does not
|
|
48
|
-
// have, so a drop there would be a refusal, an invalidation and no word to the reader.
|
|
49
|
-
writable: ReadonlySet<string>,
|
|
50
|
-
): DropOutcome {
|
|
51
|
-
const active = byId.get(drop.activeId);
|
|
52
|
-
// ⚠️ Let go over itself is the commonest drag of all — a click that travelled five pixels — and
|
|
53
|
-
// it has to be nothing before any of the arithmetic below runs. Read as "dropped on a card", it
|
|
54
|
-
// would compute a landing place for a card that never left, which is the shape of a write that
|
|
55
|
-
// says work happened.
|
|
56
|
-
//
|
|
57
|
-
// ⚠️ And it is `unchanged` even when the card stands in a lane that cannot be written into: what
|
|
58
|
-
// happened there is that a card came back to its own place, not that a lane turned a card away.
|
|
59
|
-
if (drop.overId === null || drop.overId === drop.activeId || active === undefined)
|
|
60
|
-
return { kind: "unchanged" };
|
|
61
|
-
|
|
62
|
-
// The drop landed either on a column's empty space — then its id is the status — or on a card,
|
|
63
|
-
// and then the column is that card's. A card the board does not draw (a subtask, or one filtered
|
|
64
|
-
// away) resolves to neither and the drag is a no-op rather than a guess.
|
|
65
|
-
const overTask = byId.get(drop.overId);
|
|
66
|
-
const status = overTask?.status ?? (lanes.has(drop.overId) ? drop.overId : null);
|
|
67
|
-
if (status === null) return { kind: "unchanged" };
|
|
68
|
-
|
|
69
|
-
const lane = lanes.get(status) ?? [];
|
|
70
|
-
// ⚠️ "Is this a card the board draws" is read BEFORE the refusal, and the order is the whole of
|
|
71
|
-
// it. `byId` holds subtasks too — they are counters on their parent, not lane cards — and one of
|
|
72
|
-
// them left in a status the board no longer lists resolves to a status without ever standing in
|
|
73
|
-
// that lane. Refused first, such a drop would be announced as a lane turning the card away while
|
|
74
|
-
// no lane was involved at all. Nothing renders a subtask as a droppable today; this is a guard on
|
|
75
|
-
// the order, so that the day one does, the sentence does not start lying.
|
|
76
|
-
if (overTask !== undefined && !lane.some((task) => task.id === overTask.id))
|
|
77
|
-
return { kind: "unchanged" };
|
|
78
|
-
// ⚠️ Refused BEFORE the request, and said so. The lane is drawn and its cards are still sortables,
|
|
79
|
-
// so a drop lands here on both the pointer path and the keyboard one; the server would answer a
|
|
80
|
-
// task in a status the board does not have with a refusal.
|
|
81
|
-
if (!writable.has(status)) return { kind: "refused", status };
|
|
82
|
-
|
|
83
|
-
const from = lane.findIndex((task) => task.id === active.id);
|
|
84
|
-
const rest = lane.filter((task) => task.id !== active.id);
|
|
85
|
-
|
|
86
|
-
let landing: number;
|
|
87
|
-
if (overTask === undefined) {
|
|
88
|
-
// Let go over the column rather than over a card: the end of it.
|
|
89
|
-
landing = rest.length;
|
|
90
|
-
} else {
|
|
91
|
-
// In the lane (checked above) and not the active card (checked at the top), so it is in `rest`.
|
|
92
|
-
const overIndex = rest.findIndex((task) => task.id === overTask.id);
|
|
93
|
-
// Dragged downwards inside its own column, the card takes the place BELOW the one it was let go
|
|
94
|
-
// over — the same reading `arrayMove` has, and the one the pointer suggests. Every other case
|
|
95
|
-
// puts it above.
|
|
96
|
-
const overInLane = lane.findIndex((task) => task.id === overTask.id);
|
|
97
|
-
landing = from !== -1 && from < overInLane ? overIndex + 1 : overIndex;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Re-inserting where it came from is the arrangement it already had.
|
|
101
|
-
if (from !== -1 && landing === from) return { kind: "unchanged" };
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
kind: "move",
|
|
105
|
-
move: {
|
|
106
|
-
taskId: active.id,
|
|
107
|
-
status,
|
|
108
|
-
afterTaskId: rest[landing - 1]?.id ?? null,
|
|
109
|
-
beforeTaskId: rest[landing]?.id ?? null,
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* How much of a task's subtree is finished — the counter a card carries instead of its children.
|
|
116
|
-
*
|
|
117
|
-
* ⚠️ The whole subtree, not the direct children. A card that says "1/2" while three of the four
|
|
118
|
-
* things under it are open would be worse than no counter: the number would be true of a level
|
|
119
|
-
* nobody is looking at.
|
|
120
|
-
*/
|
|
121
|
-
export function subtaskCount(
|
|
122
|
-
taskId: string,
|
|
123
|
-
childrenOf: Map<string, BoardTask[]>,
|
|
124
|
-
settled: (task: BoardTask) => boolean,
|
|
125
|
-
): { done: number; total: number } {
|
|
126
|
-
let done = 0;
|
|
127
|
-
let total = 0;
|
|
128
|
-
const walk = (id: string) => {
|
|
129
|
-
for (const child of childrenOf.get(id) ?? []) {
|
|
130
|
-
total += 1;
|
|
131
|
-
if (settled(child)) done += 1;
|
|
132
|
-
walk(child.id);
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
walk(taskId);
|
|
136
|
-
return { done, total };
|
|
137
|
-
}
|
|
@@ -1,257 +0,0 @@
|
|
|
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 { dropOutcome, 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
|
-
/**
|
|
102
|
-
* What a finished drag is announced as — and it is the OUTCOME that is announced, not what the
|
|
103
|
-
* pointer was last over (anchrd/intel#345).
|
|
104
|
-
*
|
|
105
|
-
* ⚠️ Asked of the same function the write is asked of, which is the point: a second reading of a
|
|
106
|
-
* drop would be a second opinion about it, and the one a reader hears would be the one nothing
|
|
107
|
-
* checks. Two of the three answers write nothing, and each says so in its own words — a lane that
|
|
108
|
-
* cannot take the card names itself, and a card that came back to its own place says it stayed.
|
|
109
|
-
* Announced as "dropped into X" (which is what all three used to say), a keyboard user who never
|
|
110
|
-
* sees the card jump has nothing to correct it with.
|
|
111
|
-
*/
|
|
112
|
-
const dropped = (activeId: string, overId: string | null) => {
|
|
113
|
-
const active = board.byId.get(activeId);
|
|
114
|
-
const task = active?.title ?? "";
|
|
115
|
-
const from = columnName(active?.status ?? "", columns);
|
|
116
|
-
const outcome = dropOutcome({ activeId, overId }, lanes, board.byId, writable);
|
|
117
|
-
if (outcome.kind === "move")
|
|
118
|
-
return i18n.t("board.dnd.end", { task, lane: columnName(outcome.move.status, columns) });
|
|
119
|
-
if (outcome.kind === "refused")
|
|
120
|
-
return i18n.t("board.dnd.endRefused", {
|
|
121
|
-
task,
|
|
122
|
-
lane: columnName(outcome.status, columns),
|
|
123
|
-
from,
|
|
124
|
-
});
|
|
125
|
-
return i18n.t("board.dnd.endUnchanged", { task, lane: from });
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const card = (task: BoardTask) => (
|
|
129
|
-
<BoardCardBody
|
|
130
|
-
task={task}
|
|
131
|
-
statusLabel={labelOf(task.status, board.statuses)}
|
|
132
|
-
toneDot={toneOf(task.status, board.statuses).dot}
|
|
133
|
-
blocked={board.blockedBy(task).length > 0}
|
|
134
|
-
subtasks={subtaskCount(task.id, board.childrenOf, board.settled)}
|
|
135
|
-
/>
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
return (
|
|
139
|
-
<div className="min-h-0 flex-1 overflow-x-auto p-6">
|
|
140
|
-
<KanbanProvider
|
|
141
|
-
columns={columns}
|
|
142
|
-
itemsByColumn={itemsByColumn}
|
|
143
|
-
className="min-w-max"
|
|
144
|
-
announcements={{
|
|
145
|
-
onDragStart: ({ active }) =>
|
|
146
|
-
i18n.t("board.dnd.start", { task: board.byId.get(String(active.id))?.title ?? "" }),
|
|
147
|
-
onDragOver: ({ active, over }) =>
|
|
148
|
-
i18n.t("board.dnd.over", {
|
|
149
|
-
task: board.byId.get(String(active.id))?.title ?? "",
|
|
150
|
-
lane: over === null ? "" : laneName(String(over.id), board, columns),
|
|
151
|
-
}),
|
|
152
|
-
onDragEnd: ({ active, over }) =>
|
|
153
|
-
dropped(String(active.id), over === null ? null : String(over.id)),
|
|
154
|
-
onDragCancel: ({ active }) =>
|
|
155
|
-
i18n.t("board.dnd.cancel", { task: board.byId.get(String(active.id))?.title ?? "" }),
|
|
156
|
-
}}
|
|
157
|
-
instructions={i18n.t("board.dnd.instructions")}
|
|
158
|
-
overlay={(activeId) => {
|
|
159
|
-
const task = board.byId.get(activeId);
|
|
160
|
-
return task ? (
|
|
161
|
-
<div
|
|
162
|
-
data-slot="board-card-overlay"
|
|
163
|
-
className="w-72 rounded-lg border bg-card p-3 shadow-lg ring-2 ring-ring"
|
|
164
|
-
>
|
|
165
|
-
{card(task)}
|
|
166
|
-
</div>
|
|
167
|
-
) : null;
|
|
168
|
-
}}
|
|
169
|
-
// ⚠️ Exactly one write per drag, and only when the card actually went somewhere.
|
|
170
|
-
// `dropOutcome` answers with something other than a move for a drop back onto its own place
|
|
171
|
-
// and for a lane the board cannot write into, and the mutation names the NEIGHBOURS rather
|
|
172
|
-
// than an index — the server mints the order key between them, so one drag writes one task
|
|
173
|
-
// and the other cards are not touched (#285). The same answer is what the reader is told
|
|
174
|
-
// about (anchrd/intel#345), so the sentence and the write can never drift apart.
|
|
175
|
-
onDrop={(drop) => {
|
|
176
|
-
const outcome = dropOutcome(drop, lanes, board.byId, writable);
|
|
177
|
-
if (outcome.kind === "move") board.move.mutate(outcome.move);
|
|
178
|
-
}}
|
|
179
|
-
>
|
|
180
|
-
{(column) => (
|
|
181
|
-
<KanbanBoard
|
|
182
|
-
id={column.id}
|
|
183
|
-
// ⚠️ Read back off the list rather than off the argument: `KanbanProvider` hands its
|
|
184
|
-
// children a `KanbanColumn`, which is an id and a name — the primitive has no business
|
|
185
|
-
// knowing which of a board's columns may be written to.
|
|
186
|
-
droppable={columns.find((entry) => entry.id === column.id)?.writable !== false}
|
|
187
|
-
className="w-72 shrink-0"
|
|
188
|
-
>
|
|
189
|
-
<KanbanHeader>
|
|
190
|
-
<span
|
|
191
|
-
aria-hidden="true"
|
|
192
|
-
className={`size-2 rounded-full ${toneOf(column.id, board.statuses).dot}`}
|
|
193
|
-
/>
|
|
194
|
-
<span className="min-w-0 truncate">{column.name}</span>
|
|
195
|
-
<span className="ml-auto text-xs font-normal text-muted-foreground tabular-nums">
|
|
196
|
-
{lanes.get(column.id)?.length ?? 0}
|
|
197
|
-
</span>
|
|
198
|
-
</KanbanHeader>
|
|
199
|
-
<KanbanCards id={column.id}>
|
|
200
|
-
{(lanes.get(column.id) ?? []).map((task) => (
|
|
201
|
-
<KanbanCard
|
|
202
|
-
key={task.id}
|
|
203
|
-
id={task.id}
|
|
204
|
-
roleDescription={i18n.t("board.card.roleDescription")}
|
|
205
|
-
>
|
|
206
|
-
{/* ⚠️ ONE control per card, and it means two things — click or Enter opens the
|
|
207
|
-
task, Space picks it up to be moved by the arrow keys (anchrd/intel#315).
|
|
208
|
-
`control` is what makes the second half true: it carries dnd-kit's activator
|
|
209
|
-
ref, so the keyboard sensor accepts a key press here and nowhere else in the
|
|
210
|
-
card, and the `aria-roledescription`/`aria-describedby` that say so.
|
|
211
|
-
|
|
212
|
-
The pointer sensor's four-pixel threshold is what keeps a click from counting
|
|
213
|
-
as a drag of nothing. */}
|
|
214
|
-
{({ ref, ...control }) => (
|
|
215
|
-
<button
|
|
216
|
-
type="button"
|
|
217
|
-
ref={ref}
|
|
218
|
-
{...control}
|
|
219
|
-
className="w-full rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
220
|
-
onClick={() => select(task.id)}
|
|
221
|
-
>
|
|
222
|
-
{card(task)}
|
|
223
|
-
</button>
|
|
224
|
-
)}
|
|
225
|
-
</KanbanCard>
|
|
226
|
-
))}
|
|
227
|
-
{(lanes.get(column.id) ?? []).length === 0 ? (
|
|
228
|
-
<p className="px-1 py-2 text-xs text-muted-foreground">
|
|
229
|
-
{column.id === ArchivedBoardStatusId
|
|
230
|
-
? i18n.t("board.archivedEmpty")
|
|
231
|
-
: i18n.t("board.laneEmpty")}
|
|
232
|
-
</p>
|
|
233
|
-
) : null}
|
|
234
|
-
</KanbanCards>
|
|
235
|
-
</KanbanBoard>
|
|
236
|
-
)}
|
|
237
|
-
</KanbanProvider>
|
|
238
|
-
</div>
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// What stands over a lane: the column's name, and the raw status id for a lane the board no longer
|
|
243
|
-
// lists — the same fallback `labelOf` makes, so one status is not called two things on one screen.
|
|
244
|
-
function columnName(statusId: string, columns: { id: string; name: string }[]): string {
|
|
245
|
-
return columns.find((column) => column.id === statusId)?.name ?? statusId;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// What a drag is currently OVER, read as a lane: a card answers with its own lane, a column with
|
|
249
|
-
// itself. Only the running commentary needs this — what a finished drag is announced as comes from
|
|
250
|
-
// the outcome, not from the thing under the pointer (anchrd/intel#345).
|
|
251
|
-
function laneName(
|
|
252
|
-
overId: string,
|
|
253
|
-
board: BoardViewProps["board"],
|
|
254
|
-
columns: { id: string; name: string }[],
|
|
255
|
-
): string {
|
|
256
|
-
return columnName(board.byId.get(overId)?.status ?? overId, columns);
|
|
257
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
}
|