@anchrd/intel-ui 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +10 -2
  2. package/src/agent/agent-profile/agent-profile.tsx +2 -0
  3. package/src/board/board-calendar/board-calendar.tsx +89 -0
  4. package/src/board/board-card/board-card.tsx +106 -0
  5. package/src/board/board-data/board-data.ts +241 -0
  6. package/src/board/board-data/board-data.types.ts +63 -0
  7. package/src/board/board-detail/board-detail.tsx +629 -0
  8. package/src/board/board-gantt/board-gantt.ts +545 -0
  9. package/src/board/board-gantt/board-gantt.tsx +286 -0
  10. package/src/board/board-graph/board-graph.ts +174 -0
  11. package/src/board/board-graph/board-graph.tsx +168 -0
  12. package/src/board/board-items/board-items.ts +183 -0
  13. package/src/board/board-kanban/board-kanban.ts +97 -0
  14. package/src/board/board-kanban/board-kanban.tsx +211 -0
  15. package/src/board/board-status/board-status.ts +59 -0
  16. package/src/board/board-statuses/board-statuses.ts +63 -0
  17. package/src/board/board-statuses/board-statuses.tsx +228 -0
  18. package/src/board/board-table/board-table.ts +33 -0
  19. package/src/board/board-table/board-table.tsx +413 -0
  20. package/src/board/board-views/board-views.tsx +68 -0
  21. package/src/board/board-views/board-views.types.ts +29 -0
  22. package/src/board/board.tsx +251 -0
  23. package/src/components/ui/dropdown-menu.tsx +25 -0
  24. package/src/components/ui/item-calendar.tsx +181 -0
  25. package/src/components/ui/item-gantt.tsx +463 -0
  26. package/src/components/ui/kanban.tsx +245 -0
  27. package/src/components/ui/switch.tsx +25 -0
  28. package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
  29. package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
  30. package/src/i18n/de.json +88 -1
  31. package/src/i18n/en.json +88 -1
  32. package/src/i18n/es.json +88 -1
  33. package/src/kind-icon.ts +12 -1
  34. package/src/nodes/nodes.tsx +16 -0
  35. package/src/styles.css +33 -0
@@ -0,0 +1,545 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract";
2
+ import type { UpdateTask } from "@/board/board-data/board-data.types.ts";
3
+ import {
4
+ addDays,
5
+ type BoardItem,
6
+ daysBetween,
7
+ dayValue,
8
+ rootedParents,
9
+ } from "@/board/board-items/board-items.ts";
10
+
11
+ /**
12
+ * Everything a Gantt of a board is, as arithmetic (anchrd/intel#293).
13
+ *
14
+ * ⚠️ The pixels are here and the DOM is next door. A timeline is one coordinate system — a day is an
15
+ * x, a row is a y, an arrow is a list of points between them — and keeping that in a component would
16
+ * make every one of the cases below only testable by rendering: a dependency that points backwards
17
+ * in time, a resize that would put the start after the due date, a drag that ended where it started.
18
+ * Those are the cases the picture is wrong about, and a picture is the one thing that cannot assert.
19
+ */
20
+
21
+ // How wide a day is drawn, per scale. A board's dates are DAYS (contract), so pixels-per-day is the
22
+ // whole of the zoom: every offset and every width below is a day count times this number.
23
+ const DayWidth = { days: 34, weeks: 12, months: 4 } as const;
24
+
25
+ export type GanttScale = keyof typeof DayWidth;
26
+ export const GanttScales = ["days", "weeks", "months"] as const;
27
+
28
+ export function dayWidth(scale: GanttScale): number {
29
+ return DayWidth[scale];
30
+ }
31
+
32
+ export interface GanttSpan {
33
+ startAt: Date;
34
+ endAt: Date;
35
+ }
36
+
37
+ /**
38
+ * A tick on the time axis, measured in DAYS rather than pixels.
39
+ *
40
+ * ⚠️ Deliberately not a column of fixed width. A month is 28 to 31 days wide and a week that starts
41
+ * mid-month is however many days are left of it, so a header built from equal columns drifts away
42
+ * from the bars underneath it — slowly, which is the kind of wrong nobody notices until a bar sits
43
+ * under the wrong month.
44
+ */
45
+ export interface GanttTick {
46
+ key: string;
47
+ label: string;
48
+ days: number;
49
+ }
50
+
51
+ export interface GanttAxis {
52
+ // The first day drawn, always the first of a month so the header groups are whole.
53
+ start: Date;
54
+ days: number;
55
+ // The two header rows: the coarse one (months, or years at month scale) and the fine one.
56
+ groups: GanttTick[];
57
+ ticks: GanttTick[];
58
+ // Where today stands, as a day index, or `null` when the board is nowhere near it.
59
+ today: number | null;
60
+ }
61
+
62
+ function firstOfMonth(day: Date): Date {
63
+ return new Date(day.getFullYear(), day.getMonth(), 1);
64
+ }
65
+
66
+ /**
67
+ * The window the chart draws, from the work that is on it.
68
+ *
69
+ * ⚠️ A month of air on each side, and today is always inside it. A window cut exactly to the first
70
+ * and last bar leaves nowhere to drag a bar TO — the reader would have to make a task later before
71
+ * they could make it later — and a board whose work is all in the past would open with no clue that
72
+ * it is in the past.
73
+ */
74
+ export function ganttAxis(
75
+ spans: GanttSpan[],
76
+ today: Date,
77
+ scale: GanttScale,
78
+ locale: string,
79
+ ): GanttAxis {
80
+ let min = today;
81
+ let max = today;
82
+ for (const span of spans) {
83
+ if (span.startAt < min) min = span.startAt;
84
+ if (span.endAt > max) max = span.endAt;
85
+ }
86
+ const first = firstOfMonth(min);
87
+ const last = firstOfMonth(max);
88
+ const start = new Date(first.getFullYear(), first.getMonth() - 1, 1);
89
+ const stop = new Date(last.getFullYear(), last.getMonth() + 2, 1);
90
+ const days = daysBetween(start, stop);
91
+ const todayIndex = daysBetween(start, today);
92
+ return {
93
+ start,
94
+ days,
95
+ groups: groupTicks(start, days, scale, locale),
96
+ ticks: fineTicks(start, days, scale, locale),
97
+ today: todayIndex >= 0 && todayIndex < days ? todayIndex : null,
98
+ };
99
+ }
100
+
101
+ // The coarse row: months over days and weeks, years over months.
102
+ function groupTicks(start: Date, days: number, scale: GanttScale, locale: string): GanttTick[] {
103
+ const ticks: GanttTick[] = [];
104
+ if (scale === "months") {
105
+ const format = new Intl.DateTimeFormat(locale, { year: "numeric" });
106
+ for (let index = 0; index < days; ) {
107
+ const day = addDays(start, index);
108
+ const next = new Date(day.getFullYear() + 1, 0, 1);
109
+ const width = Math.min(daysBetween(day, next), days - index);
110
+ ticks.push({ key: `${day.getFullYear()}`, label: format.format(day), days: width });
111
+ index += width;
112
+ }
113
+ return ticks;
114
+ }
115
+ const format = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" });
116
+ for (let index = 0; index < days; ) {
117
+ const day = addDays(start, index);
118
+ const next = new Date(day.getFullYear(), day.getMonth() + 1, 1);
119
+ const width = Math.min(daysBetween(day, next), days - index);
120
+ ticks.push({
121
+ key: `${day.getFullYear()}-${day.getMonth()}`,
122
+ label: format.format(day),
123
+ days: width,
124
+ });
125
+ index += width;
126
+ }
127
+ return ticks;
128
+ }
129
+
130
+ /**
131
+ * How many ticks may be drawn, whatever the dates say.
132
+ *
133
+ * ⚠️ The window comes from the board's own dates, and a `BoardTaskDate` is any ISO date — one task
134
+ * typed as `2226-08-07` (or written that way over MCP) makes a window of seventy thousand days. At
135
+ * day ticks that is seventy thousand header cells and as many grid lines, which is not a slow
136
+ * screen but a dead tab. The unit widens instead: the bars keep their exact day width, so the chart
137
+ * stays as precise as it was and only the ruler gets coarser.
138
+ */
139
+ const MaxTicks = 1_200;
140
+
141
+ function fineTicks(start: Date, days: number, scale: GanttScale, locale: string): GanttTick[] {
142
+ const unit: GanttScale =
143
+ days <= MaxTicks ? scale : days <= MaxTicks * 7 && scale !== "months" ? "weeks" : "months";
144
+ if (unit !== scale) return fineTicks(start, days, unit, locale);
145
+ if (scale === "days") {
146
+ const format = new Intl.DateTimeFormat(locale, { day: "numeric" });
147
+ return Array.from({ length: days }, (_, index) => {
148
+ const day = addDays(start, index);
149
+ return { key: `d${index}`, label: format.format(day), days: 1 };
150
+ });
151
+ }
152
+ if (scale === "months") {
153
+ const format = new Intl.DateTimeFormat(locale, { month: "short" });
154
+ const ticks: GanttTick[] = [];
155
+ for (let index = 0; index < days; ) {
156
+ const day = addDays(start, index);
157
+ const next = new Date(day.getFullYear(), day.getMonth() + 1, 1);
158
+ const width = Math.min(daysBetween(day, next), days - index);
159
+ ticks.push({ key: `m${index}`, label: format.format(day), days: width });
160
+ index += width;
161
+ }
162
+ return ticks;
163
+ }
164
+ const format = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" });
165
+ const ticks: GanttTick[] = [];
166
+ for (let index = 0; index < days; index += 7) {
167
+ const day = addDays(start, index);
168
+ ticks.push({ key: `w${index}`, label: format.format(day), days: Math.min(7, days - index) });
169
+ }
170
+ return ticks;
171
+ }
172
+
173
+ /**
174
+ * A task with the tasks under it, and what the two of them span together.
175
+ *
176
+ * ⚠️ `span` is the subtree, `item` is the task's own dates. Keeping both is what lets an epic show a
177
+ * bar over its children without pretending those dates are its own — the derived one must not be
178
+ * dragged, because one bar cannot write five tasks.
179
+ */
180
+ export interface GanttNode {
181
+ task: BoardTask;
182
+ item: BoardItem | null;
183
+ span: GanttSpan | null;
184
+ children: GanttNode[];
185
+ }
186
+
187
+ export interface GanttTree {
188
+ roots: GanttNode[];
189
+ /**
190
+ * The subtrees on which no date exists anywhere — the leftover column.
191
+ *
192
+ * ⚠️ Whole subtrees, not loose tasks. A dateless epic with two dateless children is three tasks
193
+ * the DoD says must not vanish, and listing only the epic would lose the other two just as
194
+ * quietly as dropping all three. A dateless task UNDER a dated one is not in here at all: it is a
195
+ * row in the chart with no bar, which is where it belongs.
196
+ */
197
+ undated: GanttNode[];
198
+ }
199
+
200
+ /** The board as the tree the chart draws, over the one dated reading of it (`board-items.ts`). */
201
+ export function ganttTree(tasks: BoardTask[], items: Map<string, BoardItem>): GanttTree {
202
+ const parents = rootedParents(tasks);
203
+ const nodes = new Map<string, GanttNode>(
204
+ tasks.map((task) => [
205
+ task.id,
206
+ { task, item: items.get(task.id) ?? null, span: null, children: [] },
207
+ ]),
208
+ );
209
+ const roots: GanttNode[] = [];
210
+ for (const task of tasks) {
211
+ const node = nodes.get(task.id);
212
+ if (!node) continue;
213
+ const parent = parents.get(task.id) ?? null;
214
+ const target = parent === null ? undefined : nodes.get(parent);
215
+ if (target === undefined) roots.push(node);
216
+ else target.children.push(node);
217
+ }
218
+ for (const root of roots) fillSpan(root);
219
+ return {
220
+ roots: roots.filter((node) => node.span !== null),
221
+ undated: roots.filter((node) => node.span === null),
222
+ };
223
+ }
224
+
225
+ // Bottom-up, so a parent's span is its own dates and everything under it. Depth is capped at five
226
+ // (#285), so the recursion is bounded, and `rootedParents` has already broken any cycle.
227
+ function fillSpan(node: GanttNode): GanttSpan | null {
228
+ let span: GanttSpan | null =
229
+ node.item === null ? null : { startAt: node.item.startAt, endAt: node.item.endAt };
230
+ for (const child of node.children) {
231
+ const below = fillSpan(child);
232
+ if (below === null) continue;
233
+ span =
234
+ span === null
235
+ ? below
236
+ : {
237
+ startAt: below.startAt < span.startAt ? below.startAt : span.startAt,
238
+ endAt: below.endAt > span.endAt ? below.endAt : span.endAt,
239
+ };
240
+ }
241
+ node.span = span;
242
+ return span;
243
+ }
244
+
245
+ export interface GanttRow {
246
+ task: BoardTask;
247
+ depth: number;
248
+ item: BoardItem | null;
249
+ hasChildren: boolean;
250
+ collapsed: boolean;
251
+ /**
252
+ * The bar this row actually draws.
253
+ *
254
+ * ⚠️ `derived` is the load-bearing half. A derived bar is the subtree rolled into one — an epic
255
+ * over its children, or a collapsed parent standing in for everything beneath it — and dragging it
256
+ * would have to write every task it covers. One gesture, five writes, five versions and no way to
257
+ * say which of them the reader meant. It is drawn and never dragged; the tasks under it are.
258
+ */
259
+ bar: (GanttSpan & { derived: boolean }) | null;
260
+ }
261
+
262
+ /** The visible rows, in hierarchy order, with whatever is collapsed left out. */
263
+ export function ganttRows(nodes: GanttNode[], collapsed: ReadonlySet<string>): GanttRow[] {
264
+ const rows: GanttRow[] = [];
265
+ const walk = (list: GanttNode[], depth: number) => {
266
+ for (const node of list) {
267
+ const hasChildren = node.children.length > 0;
268
+ const shut = hasChildren && collapsed.has(node.task.id);
269
+ rows.push({
270
+ task: node.task,
271
+ depth,
272
+ item: node.item,
273
+ hasChildren,
274
+ collapsed: shut,
275
+ bar: barOf(node, shut, hasChildren),
276
+ });
277
+ if (hasChildren && !shut) walk(node.children, depth + 1);
278
+ }
279
+ };
280
+ walk(nodes, 0);
281
+ return rows;
282
+ }
283
+
284
+ function barOf(
285
+ node: GanttNode,
286
+ shut: boolean,
287
+ hasChildren: boolean,
288
+ ): (GanttSpan & { derived: boolean }) | null {
289
+ // Its own dates, whenever they are the whole truth about this row. A parent that is shut is
290
+ // standing in for its children as well, so its own bar is not that.
291
+ if (node.item !== null && !shut) {
292
+ return { startAt: node.item.startAt, endAt: node.item.endAt, derived: false };
293
+ }
294
+ // Otherwise the subtree, which is what an epic spanning its children is — the DoD's first reading
295
+ // of "the outline is visible", with collapsing as the second. Both are here rather than one.
296
+ if (hasChildren && node.span !== null) {
297
+ return { startAt: node.span.startAt, endAt: node.span.endAt, derived: true };
298
+ }
299
+ return null;
300
+ }
301
+
302
+ /**
303
+ * One `dependsOn` as an arrow — the point of the ticket.
304
+ *
305
+ * ⚠️ `blocking` is `board.blockedBy` and nothing else (anchrd/intel#311), so an arrow means exactly
306
+ * what the "Blocked" chip on a card and the thick edge in the graph mean. A second reading of "still
307
+ * open" here would be the third one on the same screen, and the day they disagree is the day the
308
+ * picture stops being evidence.
309
+ */
310
+ export interface GanttLink {
311
+ id: string;
312
+ fromId: string;
313
+ toId: string;
314
+ blocking: boolean;
315
+ }
316
+
317
+ export interface GanttLinks {
318
+ links: GanttLink[];
319
+ /**
320
+ * How many dependencies could not be drawn.
321
+ *
322
+ * ⚠️ Counted and said out loud, never dropped in silence. An arrow needs a bar at BOTH ends, and
323
+ * a dependency on a task with no date — or on one hidden inside a collapsed epic — has no second
324
+ * end to point at. Re-pointing it at the nearest visible ancestor would draw a relationship the
325
+ * board does not contain, which is worse than not drawing it; saying nothing would make a chart
326
+ * with three arrows look complete when the board has five.
327
+ */
328
+ hidden: number;
329
+ }
330
+
331
+ export function ganttLinks(
332
+ rows: GanttRow[],
333
+ blockedBy: (task: BoardTask) => BoardTask[],
334
+ ): GanttLinks {
335
+ const drawn = new Set(rows.filter((row) => row.bar !== null).map((row) => row.task.id));
336
+ const links: GanttLink[] = [];
337
+ let hidden = 0;
338
+ for (const row of rows) {
339
+ if (row.bar === null) {
340
+ // Its dependencies are not "hidden": this row has no bar of its own to point at either, so
341
+ // there was never an arrow to lose. Counting them would report a gap that does not exist.
342
+ continue;
343
+ }
344
+ const open = new Set(blockedBy(row.task).map((task) => task.id));
345
+ /**
346
+ * ⚠️ Once per pair, however often the list names it.
347
+ *
348
+ * Waiting twice for the same task is waiting for it once: two arrows on exactly the same path
349
+ * would draw nothing extra and hand React two children with one key.
350
+ *
351
+ * ⚠️ `dependsOn` carries a uniqueness rule since anchrd/intel#318 — the write path refuses a
352
+ * repeat and a stored one is folded away on read — so nothing arriving through Intel names a
353
+ * task twice any more. This stays for the same reason the graph view's copy of it does: a view
354
+ * takes whatever list it is handed, and it may not be the thing that turns odd data into a
355
+ * blank screen. Before that ticket the same duplicate made the graph THROW.
356
+ */
357
+ const seen = new Set<string>();
358
+ for (const fromId of row.task.dependsOn) {
359
+ if (seen.has(fromId)) continue;
360
+ seen.add(fromId);
361
+ if (!drawn.has(fromId)) {
362
+ hidden += 1;
363
+ continue;
364
+ }
365
+ links.push({
366
+ id: `${fromId}->${row.task.id}`,
367
+ fromId,
368
+ toId: row.task.id,
369
+ blocking: open.has(fromId),
370
+ });
371
+ }
372
+ }
373
+ return { links, hidden };
374
+ }
375
+
376
+ export interface GanttPoint {
377
+ x: number;
378
+ y: number;
379
+ }
380
+
381
+ /**
382
+ * The corners an arrow turns, from the end of what must finish to the start of what waits.
383
+ *
384
+ * ⚠️ Two shapes, because a dependency can point BACKWARDS in time — nothing stops a board from
385
+ * saying a task waits for something that ends after it starts, and that is precisely the state a
386
+ * reader needs to see. Drawn as the straight forward case it would be a line running right to left
387
+ * through both bars, which reads as a dependency in the other direction.
388
+ */
389
+ export function linkPoints(
390
+ from: { x: number; y: number },
391
+ to: { x: number; y: number },
392
+ gap: number,
393
+ ): GanttPoint[] {
394
+ if (to.x >= from.x + gap * 2) {
395
+ const turn = to.x - gap;
396
+ return [
397
+ { x: from.x, y: from.y },
398
+ { x: turn, y: from.y },
399
+ { x: turn, y: to.y },
400
+ { x: to.x, y: to.y },
401
+ ];
402
+ }
403
+ // Backwards: out to the right of the predecessor, along the seam between the two rows, back to
404
+ // the left of the dependent, and into it from the left — so the arrowhead still says "this waits
405
+ // for that" rather than the reverse.
406
+ const seam = (from.y + to.y) / 2;
407
+ return [
408
+ { x: from.x, y: from.y },
409
+ { x: from.x + gap, y: from.y },
410
+ { x: from.x + gap, y: seam },
411
+ { x: to.x - gap, y: seam },
412
+ { x: to.x - gap, y: to.y },
413
+ { x: to.x, y: to.y },
414
+ ];
415
+ }
416
+
417
+ export function pathOf(points: GanttPoint[]): string {
418
+ return points
419
+ .map(
420
+ (point, index) => `${index === 0 ? "M" : "L"}${Math.round(point.x)} ${Math.round(point.y)}`,
421
+ )
422
+ .join(" ");
423
+ }
424
+
425
+ // How tall a row is drawn. Here rather than in the component because the arrows are measured in it.
426
+ export const GanttRowHeight = 34;
427
+
428
+ // How far an arrow stands clear of a bar before it turns. Also the depth of the detour a backwards
429
+ // dependency takes, so it cannot come out flush with the bar it points at.
430
+ const LinkGap = 9;
431
+
432
+ /** A span as the day index it starts on and the number of days it covers, ends included. */
433
+ export function barDays(span: GanttSpan, start: Date): { from: number; days: number } {
434
+ return {
435
+ from: daysBetween(start, span.startAt),
436
+ // Ends included: a task that starts and is due on the same day is one day wide, not none.
437
+ days: daysBetween(span.startAt, span.endAt) + 1,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Every arrow, as the path it is drawn along.
443
+ *
444
+ * ⚠️ It leaves the END of what has to finish and arrives at the START of what waits — finish to
445
+ * start, which is the only reading `dependsOn` has (#285: a task waits for another task, not for a
446
+ * date). Anchoring either end anywhere else would draw a relationship the contract cannot express.
447
+ */
448
+ export function linkGeometry(
449
+ links: GanttLink[],
450
+ rows: GanttRow[],
451
+ axisStart: Date,
452
+ width: number,
453
+ rowHeight: number,
454
+ ): { id: string; path: string; blocking: boolean }[] {
455
+ const placed = new Map<string, { index: number; bar: GanttSpan }>();
456
+ rows.forEach((row, index) => {
457
+ if (row.bar !== null) placed.set(row.task.id, { index, bar: row.bar });
458
+ });
459
+ const anchors = (id: string) => {
460
+ const found = placed.get(id);
461
+ if (found === undefined) return null;
462
+ const { from, days } = barDays(found.bar, axisStart);
463
+ return {
464
+ left: from * width,
465
+ right: (from + days) * width,
466
+ y: found.index * rowHeight + rowHeight / 2,
467
+ };
468
+ };
469
+ return links.flatMap((link) => {
470
+ const from = anchors(link.fromId);
471
+ const to = anchors(link.toId);
472
+ // `ganttLinks` has already counted anything that cannot be drawn; this is only the type saying
473
+ // the same thing again.
474
+ if (from === null || to === null) return [];
475
+ return [
476
+ {
477
+ id: link.id,
478
+ blocking: link.blocking,
479
+ path: pathOf(linkPoints({ x: from.right, y: from.y }, { x: to.left, y: to.y }, LinkGap)),
480
+ },
481
+ ];
482
+ });
483
+ }
484
+
485
+ export type GanttEdit = "move" | "start" | "end";
486
+
487
+ /**
488
+ * What a finished drag is worth, as the one `board_task_update` it is — or as nothing.
489
+ *
490
+ * ⚠️ It answers `null` for a gesture that changed no date, and that is half of what it is for. A
491
+ * bar picked up and put back is the commonest drag there is; sent anyway it would mint a version,
492
+ * bump the board and stand in the audit as work that did not happen — the same rule `moveFromDrop`
493
+ * holds for a card dropped on its own place.
494
+ *
495
+ * ⚠️ A move shifts the dates the task HAS. A task with only a due date is drawn as the single day it
496
+ * is due on, and dragging that bar must not invent a start date for it: the reader moved a task, they
497
+ * did not say when it begins. Widening the bar from its left edge is how a task gets one, and that is
498
+ * a different gesture with a different handle.
499
+ *
500
+ * ⚠️ Neither edge may cross the other. The adapter already refuses to draw a backwards bar — a start
501
+ * after the due date is a board somebody typed wrong — so a resize that would produce one is pinned
502
+ * at the day it collides with instead. Writing it would store a span this chart cannot draw, which
503
+ * is the shape of a bug that survives a reload.
504
+ *
505
+ * ⚠️ "Changed" is measured against the edge that was DRAWN (`item`), never against the field that
506
+ * was stored (`task`), and the difference between those two is a real defect this once had. A task
507
+ * with no start date is drawn as the one day it is due on, so `item.startAt` is that day while
508
+ * `task.startDate` is `null`. Dragging the left grip to the RIGHT then pins at the due date, and
509
+ * comparing the pinned day against `null` says "changed" — for a gesture during which the bar does
510
+ * not move by a pixel, because the preview clamps at one day. The reader drags, watches nothing
511
+ * happen, lets go, and the task quietly acquires a start date. The rule that closes it is the one
512
+ * the preview already follows: a bar that ends where it was drawn is worth nothing.
513
+ */
514
+ export function ganttWrite(
515
+ task: BoardTask,
516
+ item: BoardItem,
517
+ edit: GanttEdit,
518
+ deltaDays: number,
519
+ ): UpdateTask | null {
520
+ if (edit === "move") {
521
+ if (deltaDays === 0) return null;
522
+ const dueDate = dayValue(addDays(item.endAt, deltaDays));
523
+ // `startDate` travels only if there is one. `item.startAt` falls back to the due date, so
524
+ // sending it unconditionally would write a start date onto every task that never had one.
525
+ return task.startDate === null
526
+ ? { taskId: task.id, dueDate }
527
+ : { taskId: task.id, startDate: dayValue(addDays(item.startAt, deltaDays)), dueDate };
528
+ }
529
+ if (edit === "start") {
530
+ const moved = addDays(item.startAt, deltaDays);
531
+ const startDate = dayValue(moved > item.endAt ? item.endAt : moved);
532
+ return startDate === dayValue(item.startAt) ? null : { taskId: task.id, startDate };
533
+ }
534
+ const moved = addDays(item.endAt, deltaDays);
535
+ // ⚠️ Pinned at the bar's own left edge, which for a task with no start date is the due date
536
+ // itself — so the shortest a bar can be dragged is the one day it already is, and the gesture
537
+ // answers `null` instead of moving a task that was not picked up. The clamp has to be the one the
538
+ // reader SAW: a preview that stopped at one day and a write that carried on past it would store a
539
+ // date nobody dragged to.
540
+ const dueDate = dayValue(moved < item.startAt ? item.startAt : moved);
541
+ // Against the drawn edge, as above. `item.endAt` IS `task.dueDate` for everything that reaches a
542
+ // bar, so this changes no answer — it states the rule once instead of twice, which is what kept
543
+ // the other branch wrong.
544
+ return dueDate === dayValue(item.endAt) ? null : { taskId: task.id, dueDate };
545
+ }