@anchrd/intel-ui 0.17.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 -213
- package/src/i18n/en.json +6 -213
- package/src/i18n/es.json +6 -213
- 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 +5 -4
- package/src/nodes/nodes.tsx +0 -34
- package/src/resource-menu/resource-menu.tsx +68 -20
- 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 -135
- 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 -988
- 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 -482
- 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 -97
- package/src/board/board-kanban/board-kanban.tsx +0 -211
- 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 -245
- 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,97 +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 means, as the one `board_task_move` it is worth.
|
|
7
|
-
*
|
|
8
|
-
* ⚠️ Neighbours, never an index. The server mints the fractional key between the two tasks named
|
|
9
|
-
* here (#285), so one drag writes one task and renumbers nothing — the reason a board is addressed
|
|
10
|
-
* by task id and not by position at all. A UI that sent a position would have to send the whole
|
|
11
|
-
* column with it to be unambiguous.
|
|
12
|
-
*
|
|
13
|
-
* ⚠️ `null` for a drag that changed nothing, and that is half the point of this function. dnd-kit
|
|
14
|
-
* reports a drop wherever the pointer was let go, including back on the card's own place; sending
|
|
15
|
-
* a move for that would write a version, bump the board and show up in the audit as work that did
|
|
16
|
-
* not happen.
|
|
17
|
-
*
|
|
18
|
-
* `lanes` is what the board DRAWS — root tasks per column, in order. Subtasks are not lane cards
|
|
19
|
-
* (they are a counter on their parent), so they are not in it and cannot be dropped on either.
|
|
20
|
-
*/
|
|
21
|
-
export function moveFromDrop(
|
|
22
|
-
drop: KanbanDrop,
|
|
23
|
-
lanes: Map<string, BoardTask[]>,
|
|
24
|
-
byId: Map<string, BoardTask>,
|
|
25
|
-
// The statuses the board actually lists. ⚠️ Not the same as the lanes: a lane is also drawn for a
|
|
26
|
-
// status the board no longer has, so that the cards left in it stay visible — and that lane must
|
|
27
|
-
// not be somewhere a card can be MOVED. The server refuses a task in a status the board does not
|
|
28
|
-
// have, so a drop there would be a refusal, an invalidation and no word to the reader.
|
|
29
|
-
writable: ReadonlySet<string>,
|
|
30
|
-
): MoveTask | null {
|
|
31
|
-
const active = byId.get(drop.activeId);
|
|
32
|
-
// ⚠️ Let go over itself is the commonest drag of all — a click that travelled five pixels — and
|
|
33
|
-
// it has to be nothing before any of the arithmetic below runs. Read as "dropped on a card", it
|
|
34
|
-
// would compute a landing place for a card that never left, which is the shape of a write that
|
|
35
|
-
// says work happened.
|
|
36
|
-
if (drop.overId === null || drop.overId === drop.activeId || active === undefined) return null;
|
|
37
|
-
|
|
38
|
-
// The drop landed either on a column's empty space — then its id is the status — or on a card,
|
|
39
|
-
// and then the column is that card's. A card the board does not draw (a subtask, or one filtered
|
|
40
|
-
// away) resolves to neither and the drag is a no-op rather than a guess.
|
|
41
|
-
const overTask = byId.get(drop.overId);
|
|
42
|
-
const status = overTask?.status ?? (lanes.has(drop.overId) ? drop.overId : null);
|
|
43
|
-
if (status === null || !writable.has(status)) return null;
|
|
44
|
-
|
|
45
|
-
const lane = lanes.get(status) ?? [];
|
|
46
|
-
const from = lane.findIndex((task) => task.id === active.id);
|
|
47
|
-
const rest = lane.filter((task) => task.id !== active.id);
|
|
48
|
-
|
|
49
|
-
let landing: number;
|
|
50
|
-
if (overTask === undefined) {
|
|
51
|
-
// Let go over the column rather than over a card: the end of it.
|
|
52
|
-
landing = rest.length;
|
|
53
|
-
} else {
|
|
54
|
-
const overIndex = rest.findIndex((task) => task.id === overTask.id);
|
|
55
|
-
if (overIndex === -1) return null;
|
|
56
|
-
// Dragged downwards inside its own column, the card takes the place BELOW the one it was let go
|
|
57
|
-
// over — the same reading `arrayMove` has, and the one the pointer suggests. Every other case
|
|
58
|
-
// puts it above.
|
|
59
|
-
const overInLane = lane.findIndex((task) => task.id === overTask.id);
|
|
60
|
-
landing = from !== -1 && from < overInLane ? overIndex + 1 : overIndex;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Re-inserting where it came from is the arrangement it already had.
|
|
64
|
-
if (from !== -1 && landing === from) return null;
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
taskId: active.id,
|
|
68
|
-
status,
|
|
69
|
-
afterTaskId: rest[landing - 1]?.id ?? null,
|
|
70
|
-
beforeTaskId: rest[landing]?.id ?? null,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* How much of a task's subtree is finished — the counter a card carries instead of its children.
|
|
76
|
-
*
|
|
77
|
-
* ⚠️ The whole subtree, not the direct children. A card that says "1/2" while three of the four
|
|
78
|
-
* things under it are open would be worse than no counter: the number would be true of a level
|
|
79
|
-
* nobody is looking at.
|
|
80
|
-
*/
|
|
81
|
-
export function subtaskCount(
|
|
82
|
-
taskId: string,
|
|
83
|
-
childrenOf: Map<string, BoardTask[]>,
|
|
84
|
-
settled: (task: BoardTask) => boolean,
|
|
85
|
-
): { done: number; total: number } {
|
|
86
|
-
let done = 0;
|
|
87
|
-
let total = 0;
|
|
88
|
-
const walk = (id: string) => {
|
|
89
|
-
for (const child of childrenOf.get(id) ?? []) {
|
|
90
|
-
total += 1;
|
|
91
|
-
if (settled(child)) done += 1;
|
|
92
|
-
walk(child.id);
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
walk(taskId);
|
|
96
|
-
return { done, total };
|
|
97
|
-
}
|
|
@@ -1,211 +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 { moveFromDrop, subtaskCount } from "./board-kanban.ts";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The board as lanes (anchrd/intel#286).
|
|
19
|
-
*
|
|
20
|
-
* ⚠️ Lanes are the board's `statuses` in their own order — not a list this view keeps. Renaming a
|
|
21
|
-
* column in `board_configure` renames the lane, and the id under it never moves, so no task has to
|
|
22
|
-
* be rewritten to follow.
|
|
23
|
-
*
|
|
24
|
-
* ⚠️ Only root tasks are cards. A subtask is a counter on its parent (`subtaskCount`); drawing it
|
|
25
|
-
* as a lane card as well would put one piece of work on the board twice, once inside its parent and
|
|
26
|
-
* once beside it. The whole hierarchy is the table's job and the detail panel's.
|
|
27
|
-
*/
|
|
28
|
-
export function BoardKanban({ board, select, showArchived }: BoardViewProps) {
|
|
29
|
-
const i18n = useI18n();
|
|
30
|
-
|
|
31
|
-
const columns = useMemo(() => {
|
|
32
|
-
// ⚠️ `archived` is not a lane by default and is not a stage either — it is the shelf a task
|
|
33
|
-
// is swept onto, and a board that shows it always spends a fifth of its width on work
|
|
34
|
-
// somebody has finished with. The switch that brings it back is in the bar above.
|
|
35
|
-
/**
|
|
36
|
-
* ⚠️ One lane per status id, however often the list names it (anchrd/intel#321).
|
|
37
|
-
*
|
|
38
|
-
* Two lanes under one id is the half of that ticket that does not throw, which is what makes it
|
|
39
|
-
* the worse half: `lanes.get(column.id)` answers both of them with the same cards, so the same
|
|
40
|
-
* work stands in two columns, `useDroppable` registers one id twice and a drop has two targets
|
|
41
|
-
* that are one — a screen that looks like it is working. The bundle import refuses such a
|
|
42
|
-
* document now; a board imported before it is still stored as it was written, and the first
|
|
43
|
-
* mention is the lane to keep because it is the one `labelOf` and `toneOf` already answer with
|
|
44
|
-
* — a lane drawn under the second label would have carried the first one's colour.
|
|
45
|
-
*/
|
|
46
|
-
const seen = new Set<string>();
|
|
47
|
-
const listed = (showArchived ? board.statuses : visibleStatuses(board.statuses))
|
|
48
|
-
.filter((status) => {
|
|
49
|
-
if (seen.has(status.id)) return false;
|
|
50
|
-
seen.add(status.id);
|
|
51
|
-
return true;
|
|
52
|
-
})
|
|
53
|
-
.map((status) => ({ id: status.id, name: status.label }));
|
|
54
|
-
/**
|
|
55
|
-
* ⚠️ And a lane at the end for every status a task still stands in that the board no longer
|
|
56
|
-
* lists.
|
|
57
|
-
*
|
|
58
|
-
* Configuring a column away does not rewrite the tasks that were in it (#285), so this is
|
|
59
|
-
* reachable — through `board_configure` over MCP, which has no dialog stopping it. Without the
|
|
60
|
-
* extra lane those cards would be in `board.tasks`, counted in the title line, and drawn
|
|
61
|
-
* nowhere: gone from the one view a board is opened for, with nothing saying so. The other
|
|
62
|
-
* others already keep them (`orderedTasks` sorts them last, `labelOf` falls back to the raw
|
|
63
|
-
* id), and no two views may disagree about what is on the board.
|
|
64
|
-
*/
|
|
65
|
-
const known = new Set(listed.map((column) => column.id));
|
|
66
|
-
const stray = new Set(
|
|
67
|
-
board.tasks
|
|
68
|
-
.filter((task) => task.parentId === null && !known.has(task.status))
|
|
69
|
-
.filter((task) => showArchived || task.status !== ArchivedBoardStatusId)
|
|
70
|
-
.map((task) => task.status),
|
|
71
|
-
);
|
|
72
|
-
return [
|
|
73
|
-
...listed.map((column) => ({ ...column, writable: true })),
|
|
74
|
-
// ⚠️ Drawn, never written into. The server refuses a task in a status the board does not have,
|
|
75
|
-
// so a card dropped here would be a refusal, an invalidation and nothing said to the reader.
|
|
76
|
-
// The lane is a place work is VISIBLE, not a place it can be put.
|
|
77
|
-
...[...stray].map((id) => ({ id, name: labelOf(id, board.statuses), writable: false })),
|
|
78
|
-
];
|
|
79
|
-
}, [board.statuses, board.tasks, showArchived]);
|
|
80
|
-
|
|
81
|
-
const writable = useMemo(
|
|
82
|
-
() => new Set(columns.filter((column) => column.writable).map((column) => column.id)),
|
|
83
|
-
[columns],
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
const lanes = useMemo(() => {
|
|
87
|
-
const byStatus = new Map<string, BoardTask[]>();
|
|
88
|
-
for (const column of columns) byStatus.set(column.id, []);
|
|
89
|
-
for (const task of board.tasks) {
|
|
90
|
-
if (task.parentId !== null) continue;
|
|
91
|
-
byStatus.get(task.status)?.push(task);
|
|
92
|
-
}
|
|
93
|
-
return byStatus;
|
|
94
|
-
}, [board.tasks, columns]);
|
|
95
|
-
|
|
96
|
-
const itemsByColumn = useMemo(
|
|
97
|
-
() => new Map([...lanes].map(([status, tasks]) => [status, tasks.map((task) => task.id)])),
|
|
98
|
-
[lanes],
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
const card = (task: BoardTask) => (
|
|
102
|
-
<BoardCardBody
|
|
103
|
-
task={task}
|
|
104
|
-
statusLabel={labelOf(task.status, board.statuses)}
|
|
105
|
-
toneDot={toneOf(task.status, board.statuses).dot}
|
|
106
|
-
blocked={board.blockedBy(task).length > 0}
|
|
107
|
-
subtasks={subtaskCount(task.id, board.childrenOf, board.settled)}
|
|
108
|
-
/>
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
return (
|
|
112
|
-
<div className="min-h-0 flex-1 overflow-x-auto p-6">
|
|
113
|
-
<KanbanProvider
|
|
114
|
-
columns={columns}
|
|
115
|
-
itemsByColumn={itemsByColumn}
|
|
116
|
-
className="min-w-max"
|
|
117
|
-
announcements={{
|
|
118
|
-
onDragStart: ({ active }) =>
|
|
119
|
-
i18n.t("board.dnd.start", { task: board.byId.get(String(active.id))?.title ?? "" }),
|
|
120
|
-
onDragOver: ({ active, over }) =>
|
|
121
|
-
i18n.t("board.dnd.over", {
|
|
122
|
-
task: board.byId.get(String(active.id))?.title ?? "",
|
|
123
|
-
lane: over === null ? "" : laneName(String(over.id), board, columns),
|
|
124
|
-
}),
|
|
125
|
-
onDragEnd: ({ active, over }) =>
|
|
126
|
-
i18n.t("board.dnd.end", {
|
|
127
|
-
task: board.byId.get(String(active.id))?.title ?? "",
|
|
128
|
-
lane: over === null ? "" : laneName(String(over.id), board, columns),
|
|
129
|
-
}),
|
|
130
|
-
onDragCancel: ({ active }) =>
|
|
131
|
-
i18n.t("board.dnd.cancel", { task: board.byId.get(String(active.id))?.title ?? "" }),
|
|
132
|
-
}}
|
|
133
|
-
overlay={(activeId) => {
|
|
134
|
-
const task = board.byId.get(activeId);
|
|
135
|
-
return task ? (
|
|
136
|
-
<div className="w-72 rounded-lg border bg-card p-3 shadow-lg ring-2 ring-ring">
|
|
137
|
-
{card(task)}
|
|
138
|
-
</div>
|
|
139
|
-
) : null;
|
|
140
|
-
}}
|
|
141
|
-
// ⚠️ Exactly one write per drag, and only when the card actually went somewhere.
|
|
142
|
-
// `moveFromDrop` answers `null` for a drop back onto its own place, and the mutation names
|
|
143
|
-
// the NEIGHBOURS rather than an index — the server mints the order key between them, so one
|
|
144
|
-
// drag writes one task and the other cards are not touched (#285).
|
|
145
|
-
onDrop={(drop) => {
|
|
146
|
-
const move = moveFromDrop(drop, lanes, board.byId, writable);
|
|
147
|
-
if (move) board.move.mutate(move);
|
|
148
|
-
}}
|
|
149
|
-
>
|
|
150
|
-
{(column) => (
|
|
151
|
-
<KanbanBoard
|
|
152
|
-
id={column.id}
|
|
153
|
-
// ⚠️ Read back off the list rather than off the argument: `KanbanProvider` hands its
|
|
154
|
-
// children a `KanbanColumn`, which is an id and a name — the primitive has no business
|
|
155
|
-
// knowing which of a board's columns may be written to.
|
|
156
|
-
droppable={columns.find((entry) => entry.id === column.id)?.writable !== false}
|
|
157
|
-
className="w-72 shrink-0"
|
|
158
|
-
>
|
|
159
|
-
<KanbanHeader>
|
|
160
|
-
<span
|
|
161
|
-
aria-hidden="true"
|
|
162
|
-
className={`size-2 rounded-full ${toneOf(column.id, board.statuses).dot}`}
|
|
163
|
-
/>
|
|
164
|
-
<span className="min-w-0 truncate">{column.name}</span>
|
|
165
|
-
<span className="ml-auto text-xs font-normal text-muted-foreground tabular-nums">
|
|
166
|
-
{lanes.get(column.id)?.length ?? 0}
|
|
167
|
-
</span>
|
|
168
|
-
</KanbanHeader>
|
|
169
|
-
<KanbanCards id={column.id}>
|
|
170
|
-
{(lanes.get(column.id) ?? []).map((task) => (
|
|
171
|
-
<KanbanCard
|
|
172
|
-
key={task.id}
|
|
173
|
-
id={task.id}
|
|
174
|
-
dragLabel={i18n.t("board.dragCard", { task: task.title })}
|
|
175
|
-
>
|
|
176
|
-
{/* Two controls, each with a name of its own: the handle beside this one drags,
|
|
177
|
-
this one opens. The pointer sensor's four-pixel threshold is what keeps a
|
|
178
|
-
click on it from counting as a drag of nothing. */}
|
|
179
|
-
<button
|
|
180
|
-
type="button"
|
|
181
|
-
className="w-full rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
182
|
-
onClick={() => select(task.id)}
|
|
183
|
-
>
|
|
184
|
-
{card(task)}
|
|
185
|
-
</button>
|
|
186
|
-
</KanbanCard>
|
|
187
|
-
))}
|
|
188
|
-
{(lanes.get(column.id) ?? []).length === 0 ? (
|
|
189
|
-
<p className="px-1 py-2 text-xs text-muted-foreground">
|
|
190
|
-
{column.id === ArchivedBoardStatusId
|
|
191
|
-
? i18n.t("board.archivedEmpty")
|
|
192
|
-
: i18n.t("board.laneEmpty")}
|
|
193
|
-
</p>
|
|
194
|
-
) : null}
|
|
195
|
-
</KanbanCards>
|
|
196
|
-
</KanbanBoard>
|
|
197
|
-
)}
|
|
198
|
-
</KanbanProvider>
|
|
199
|
-
</div>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function laneName(
|
|
204
|
-
overId: string,
|
|
205
|
-
board: BoardViewProps["board"],
|
|
206
|
-
columns: { id: string; name: string }[],
|
|
207
|
-
): string {
|
|
208
|
-
const task = board.byId.get(overId);
|
|
209
|
-
const statusId = task?.status ?? overId;
|
|
210
|
-
return columns.find((column) => column.id === statusId)?.name ?? statusId;
|
|
211
|
-
}
|
|
@@ -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
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { ArchivedBoardStatusId, type BoardStatus } from "@anchrd/intel-contract";
|
|
2
|
-
|
|
3
|
-
// One row of the dialog. `terminal` travels with it because it is written in the same call as the
|
|
4
|
-
// label and the order — `board_configure` takes the list whole (#285).
|
|
5
|
-
export interface BoardStatusDraft {
|
|
6
|
-
id: string;
|
|
7
|
-
label: string;
|
|
8
|
-
terminal: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* A status id out of the label somebody typed.
|
|
13
|
-
*
|
|
14
|
-
* ⚠️ The id is what every task in the column carries, so it is minted ONCE and never follows a
|
|
15
|
-
* rename — renaming changes the label and no task has to be rewritten (#285). This function is
|
|
16
|
-
* therefore only ever called for a NEW column.
|
|
17
|
-
*
|
|
18
|
-
* The shape is the contract's: lower case, digits and underscores, starting on an alphanumeric,
|
|
19
|
-
* forty characters at most. A label that leaves nothing usable — one written in a script that has
|
|
20
|
-
* no ASCII form — falls back to a generated id rather than being refused, because a column named
|
|
21
|
-
* in Japanese is a column somebody meant to make.
|
|
22
|
-
*/
|
|
23
|
-
export function statusIdFrom(label: string, taken: ReadonlySet<string>): string {
|
|
24
|
-
const base = label
|
|
25
|
-
.toLowerCase()
|
|
26
|
-
.normalize("NFKD")
|
|
27
|
-
// Combining marks removed, so "Prüfung" becomes "prufung" rather than losing the letter.
|
|
28
|
-
.replace(/[\u0300-\u036f]/g, "")
|
|
29
|
-
.replace(/[^a-z0-9]+/g, "_")
|
|
30
|
-
.replace(/^_+|_+$/g, "")
|
|
31
|
-
.slice(0, 40)
|
|
32
|
-
.replace(/^[^a-z0-9]+/, "");
|
|
33
|
-
const seed = base === "" ? "status" : base;
|
|
34
|
-
if (!taken.has(seed) && seed !== ArchivedBoardStatusId) return seed;
|
|
35
|
-
for (let suffix = 2; ; suffix += 1) {
|
|
36
|
-
const candidate = `${seed.slice(0, 37)}_${suffix}`;
|
|
37
|
-
if (!taken.has(candidate) && candidate !== ArchivedBoardStatusId) return candidate;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The list as `board_configure` takes it: whole, in the order it should be drawn.
|
|
43
|
-
*
|
|
44
|
-
* ⚠️ `archived` is put back at the end if the editor lost it. The contract refuses a list without
|
|
45
|
-
* it, and a refusal here would be a dialog that cannot be saved for a reason the reader never
|
|
46
|
-
* caused — the shelf is not a column anybody is editing.
|
|
47
|
-
*/
|
|
48
|
-
export function configurableStatuses(statuses: BoardStatus[]): BoardStatusDraft[] {
|
|
49
|
-
const working = statuses.filter((status) => status.id !== ArchivedBoardStatusId);
|
|
50
|
-
const shelf = statuses.find((status) => status.id === ArchivedBoardStatusId);
|
|
51
|
-
return [
|
|
52
|
-
...working.map((status) => ({
|
|
53
|
-
id: status.id,
|
|
54
|
-
label: status.label,
|
|
55
|
-
terminal: status.terminal,
|
|
56
|
-
})),
|
|
57
|
-
// ⚠️ Always `true` for the shelf, whatever a stored board says. The contract refuses an explicit
|
|
58
|
-
// `false` for it (anchrd/intel#311), so sending anything else would be a dialog that cannot be
|
|
59
|
-
// saved — and a shelf that did not mean finished would hold every archived task open as a
|
|
60
|
-
// blocker forever.
|
|
61
|
-
{ id: ArchivedBoardStatusId, label: shelf?.label ?? "Archived", terminal: true },
|
|
62
|
-
];
|
|
63
|
-
}
|