@hiroleague/taskmanager 0.0.3 → 0.0.4
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/README.md +1 -1
- package/dist/assets/index-BpzHnKdP.css +1 -0
- package/dist/assets/index-DmNErTAP.js +273 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/hiro-task-manager-cli/SKILL.md +6 -4
- package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +1 -0
- package/skills/hiro-task-manager-cli/reference/releases.md +14 -0
- package/src/cli/commands/query.ts +56 -56
- package/src/cli/commands/releases.ts +22 -0
- package/src/cli/handlers/boards.test.ts +669 -669
- package/src/cli/handlers/cli-wiring.test.ts +38 -1
- package/src/cli/handlers/releases.ts +15 -0
- package/src/cli/handlers/search.test.ts +374 -374
- package/src/cli/handlers/search.ts +17 -17
- package/src/cli/lib/cli-http-errors.test.ts +85 -85
- package/src/cli/lib/write/releases.ts +64 -1
- package/src/cli/lib/write-result.test.ts +3 -0
- package/src/cli/lib/write-result.ts +3 -0
- package/src/cli/lib/writeCommands.breadth.test.ts +143 -0
- package/src/cli/lib/writeCommands.ts +1 -0
- package/src/cli/subprocess.real-stack.test.ts +625 -611
- package/src/cli/subprocess.smoke.test.ts +954 -954
- package/src/client/api/useBoardChangeStream.ts +421 -168
- package/src/client/api/useBoardIndexStream.ts +35 -0
- package/src/client/components/board/BoardStatsChips.tsx +233 -233
- package/src/client/components/board/BoardStatsContext.tsx +41 -41
- package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
- package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
- package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
- package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
- package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
- package/src/client/components/layout/AppShell.tsx +5 -2
- package/src/client/components/layout/NotificationToasts.tsx +38 -1
- package/src/client/components/multi-select.tsx +1206 -1206
- package/src/client/components/routing/BoardPage.tsx +20 -20
- package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
- package/src/client/components/task/TaskCard.tsx +643 -643
- package/src/client/components/ui/badge.tsx +49 -49
- package/src/client/components/ui/button.tsx +65 -65
- package/src/client/components/ui/command.tsx +193 -193
- package/src/client/components/ui/dialog.tsx +163 -163
- package/src/client/components/ui/input-group.tsx +155 -155
- package/src/client/components/ui/input.tsx +19 -19
- package/src/client/components/ui/popover.tsx +87 -87
- package/src/client/components/ui/separator.tsx +28 -28
- package/src/client/components/ui/textarea.tsx +18 -18
- package/src/client/index.css +248 -248
- package/src/client/lib/appNavigate.ts +16 -16
- package/src/client/lib/taskCardDate.ts +111 -111
- package/src/client/lib/utils.ts +6 -6
- package/src/client/store/notificationUi.ts +14 -0
- package/src/server/auth.ts +351 -351
- package/src/server/events.ts +31 -4
- package/src/server/migrations/registry.ts +43 -43
- package/src/server/notificationEvents.ts +8 -1
- package/src/server/routes/boards.ts +15 -1
- package/src/server/routes/trash.ts +6 -1
- package/src/shared/boardEvents.ts +6 -0
- package/src/shared/runtimeConfig.ts +256 -256
- package/dist/assets/index-hMFTu7sr.css +0 -1
- package/dist/assets/index-oKG1C41_.js +0 -273
|
@@ -1,643 +1,643 @@
|
|
|
1
|
-
import {
|
|
2
|
-
memo,
|
|
3
|
-
useLayoutEffect,
|
|
4
|
-
useRef,
|
|
5
|
-
type CSSProperties,
|
|
6
|
-
type ReactNode,
|
|
7
|
-
} from "react";
|
|
8
|
-
import { Bot, Check, Clock } from "lucide-react";
|
|
9
|
-
import {
|
|
10
|
-
NONE_TASK_PRIORITY_VALUE,
|
|
11
|
-
priorityDisplayLabel,
|
|
12
|
-
formatTaskIdForDisplay,
|
|
13
|
-
taskDisplayTitleOnCard,
|
|
14
|
-
type Board,
|
|
15
|
-
type Task,
|
|
16
|
-
type TaskPriorityDefinition,
|
|
17
|
-
type TaskStatus,
|
|
18
|
-
} from "../../../shared/models";
|
|
19
|
-
import { useBoardKeyboardNavOptional } from "@/components/board/shortcuts/BoardKeyboardNavContext";
|
|
20
|
-
import {
|
|
21
|
-
getTaskCardViewSpec,
|
|
22
|
-
type TaskCardViewMode,
|
|
23
|
-
} from "@/store/preferences";
|
|
24
|
-
import { cn } from "@/lib/utils";
|
|
25
|
-
import {
|
|
26
|
-
formatTaskCardDateTooltip,
|
|
27
|
-
getTaskCardRelativeDateParts,
|
|
28
|
-
getTaskCardTimeline,
|
|
29
|
-
} from "@/lib/taskCardDate";
|
|
30
|
-
import { clampTaskTitleInput } from "../../../shared/taskTitle";
|
|
31
|
-
|
|
32
|
-
function taskCardBodyPaddingClass(viewMode: TaskCardViewMode): string {
|
|
33
|
-
return viewMode === "small" ? "px-2 py-2" : "px-2.5 py-2";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function previewBody(body: string, max = 100): string {
|
|
37
|
-
const plain = body.replace(/\s+/g, " ").trim();
|
|
38
|
-
if (!plain) return "";
|
|
39
|
-
return plain.length > max ? `${plain.slice(0, max)}…` : plain;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function autoSizeInlineTitleTextarea(textarea: HTMLTextAreaElement | null): void {
|
|
43
|
-
if (!textarea) return;
|
|
44
|
-
textarea.style.height = "auto";
|
|
45
|
-
const computed = window.getComputedStyle(textarea);
|
|
46
|
-
const lineHeight = Number.parseFloat(computed.lineHeight) || 20;
|
|
47
|
-
const paddingY =
|
|
48
|
-
(Number.parseFloat(computed.paddingTop) || 0) +
|
|
49
|
-
(Number.parseFloat(computed.paddingBottom) || 0);
|
|
50
|
-
const borderY =
|
|
51
|
-
(Number.parseFloat(computed.borderTopWidth) || 0) +
|
|
52
|
-
(Number.parseFloat(computed.borderBottomWidth) || 0);
|
|
53
|
-
const maxHeight = lineHeight * 3 + paddingY + borderY;
|
|
54
|
-
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
55
|
-
textarea.style.height = `${nextHeight}px`;
|
|
56
|
-
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function statusAriaLabel(status: TaskStatus): string {
|
|
60
|
-
switch (status) {
|
|
61
|
-
case "open":
|
|
62
|
-
return "Open";
|
|
63
|
-
case "in-progress":
|
|
64
|
-
return "In progress";
|
|
65
|
-
case "closed":
|
|
66
|
-
return "Closed";
|
|
67
|
-
default:
|
|
68
|
-
return status || "Status";
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function OpenStatusCircle() {
|
|
73
|
-
return (
|
|
74
|
-
<span
|
|
75
|
-
className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
|
|
76
|
-
aria-hidden
|
|
77
|
-
>
|
|
78
|
-
<span className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent" />
|
|
79
|
-
</span>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function TaskStatusIndicator({ status }: { status: TaskStatus }) {
|
|
84
|
-
const label = statusAriaLabel(status);
|
|
85
|
-
return (
|
|
86
|
-
<span
|
|
87
|
-
className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
|
|
88
|
-
aria-hidden
|
|
89
|
-
>
|
|
90
|
-
{status === "open" ? (
|
|
91
|
-
<span
|
|
92
|
-
className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent"
|
|
93
|
-
title={label}
|
|
94
|
-
/>
|
|
95
|
-
) : null}
|
|
96
|
-
{status === "in-progress" ? (
|
|
97
|
-
<span
|
|
98
|
-
className="size-3.5 rounded-full bg-amber-400 shadow-sm dark:bg-amber-500"
|
|
99
|
-
title={label}
|
|
100
|
-
/>
|
|
101
|
-
) : null}
|
|
102
|
-
{status === "closed" ? (
|
|
103
|
-
<span
|
|
104
|
-
className="flex size-3.5 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm dark:bg-emerald-600"
|
|
105
|
-
title={label}
|
|
106
|
-
>
|
|
107
|
-
<Check className="size-2.5 stroke-[3]" aria-hidden />
|
|
108
|
-
</span>
|
|
109
|
-
) : null}
|
|
110
|
-
{status !== "open" &&
|
|
111
|
-
status !== "in-progress" &&
|
|
112
|
-
status !== "closed" ? (
|
|
113
|
-
<span
|
|
114
|
-
className="size-3.5 rounded-full bg-muted-foreground/35"
|
|
115
|
-
title={label}
|
|
116
|
-
/>
|
|
117
|
-
) : null}
|
|
118
|
-
</span>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
interface TaskCardProps {
|
|
123
|
-
task: Task;
|
|
124
|
-
taskPriorities: TaskPriorityDefinition[];
|
|
125
|
-
viewMode: TaskCardViewMode;
|
|
126
|
-
/** Display label for `task.groupId` (resolved from board definitions). */
|
|
127
|
-
groupLabel: string;
|
|
128
|
-
/** Optional release chip (same styling as priority when color is set). */
|
|
129
|
-
releasePill?: { label: string; color?: string | null } | null;
|
|
130
|
-
onOpen: () => void;
|
|
131
|
-
/** When true, render an inline title-only editor instead of the normal task title. */
|
|
132
|
-
editingTitle?: boolean;
|
|
133
|
-
titleDraft?: string;
|
|
134
|
-
onTitleDraftChange?: (value: string) => void;
|
|
135
|
-
onTitleCommit?: () => void;
|
|
136
|
-
onTitleCancel?: () => void;
|
|
137
|
-
titleEditBusy?: boolean;
|
|
138
|
-
/** When set, only for `open` tasks: click the empty circle to complete (does not open the editor). */
|
|
139
|
-
onCompleteFromCircle?: (anchorEl: HTMLElement) => void;
|
|
140
|
-
/** When true, dim the card to indicate it's being dragged. */
|
|
141
|
-
isDragging?: boolean;
|
|
142
|
-
/**
|
|
143
|
-
* When false (default), this card registers its root for keyboard scroll targeting.
|
|
144
|
-
* SortableTaskRow sets true so only the row wrapper registers once per task.
|
|
145
|
-
*/
|
|
146
|
-
skipNavRegistration?: boolean;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Shown when the task was created by the CLI principal (Phase 2 provenance). */
|
|
150
|
-
function CliCreatedIndicator({
|
|
151
|
-
task,
|
|
152
|
-
compact,
|
|
153
|
-
}: {
|
|
154
|
-
task: Task;
|
|
155
|
-
/** Smaller icon for dense / small card layout. */
|
|
156
|
-
compact?: boolean;
|
|
157
|
-
}) {
|
|
158
|
-
if (task.createdByPrincipal !== "cli") return null;
|
|
159
|
-
const tip =
|
|
160
|
-
task.createdByLabel?.trim() || "Created via hirotm CLI";
|
|
161
|
-
return (
|
|
162
|
-
<span
|
|
163
|
-
className="inline-flex shrink-0 text-muted-foreground"
|
|
164
|
-
title={tip}
|
|
165
|
-
aria-label={tip}
|
|
166
|
-
>
|
|
167
|
-
<Bot
|
|
168
|
-
className={compact ? "size-3" : "size-3.5"}
|
|
169
|
-
strokeWidth={2}
|
|
170
|
-
aria-hidden
|
|
171
|
-
/>
|
|
172
|
-
</span>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Light priority swatches need a border so the pill stays visible on light card backgrounds. */
|
|
177
|
-
function isVeryLightPriorityBackground(color: string): boolean {
|
|
178
|
-
const c = color.trim().toLowerCase();
|
|
179
|
-
return c === "#ffffff" || c === "#fff" || c === "white";
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Resolved release label/color for a task, or null when unassigned / unknown id. */
|
|
183
|
-
export function taskReleasePill(
|
|
184
|
-
board: Pick<Board, "releases">,
|
|
185
|
-
task: Pick<Task, "releaseId">,
|
|
186
|
-
): { label: string; color?: string | null } | null {
|
|
187
|
-
const rid = task.releaseId;
|
|
188
|
-
if (rid == null) return null;
|
|
189
|
-
const r = board.releases.find((x) => x.releaseId === rid);
|
|
190
|
-
if (!r) return null;
|
|
191
|
-
return { label: r.name, color: r.color };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Bold “r” + space before release name in chips (priority pills stay unchanged). */
|
|
195
|
-
function ReleaseChipPrefix(): ReactNode {
|
|
196
|
-
return (
|
|
197
|
-
<>
|
|
198
|
-
<span className="font-bold">r </span>
|
|
199
|
-
</>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function releaseChipTitle(label: string): string {
|
|
204
|
-
return `r ${label.trim()}`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Open/created vs closed timestamp for large/larger task cards.
|
|
209
|
-
* Returns a bare `<span>` — the caller decides placement (metadata row, inline after preview, etc.).
|
|
210
|
-
*/
|
|
211
|
-
function TaskCardTimelineChip({
|
|
212
|
-
task,
|
|
213
|
-
className,
|
|
214
|
-
}: {
|
|
215
|
-
task: Task;
|
|
216
|
-
className?: string;
|
|
217
|
-
}) {
|
|
218
|
-
const timeline = getTaskCardTimeline(task);
|
|
219
|
-
if (!timeline) return null;
|
|
220
|
-
const { label: compact, showRecentDot } = getTaskCardRelativeDateParts(timeline.iso);
|
|
221
|
-
if (!compact) return null;
|
|
222
|
-
const tip = formatTaskCardDateTooltip(timeline.kind, timeline.iso);
|
|
223
|
-
return (
|
|
224
|
-
<span
|
|
225
|
-
className={cn(
|
|
226
|
-
"inline-flex items-center gap-0.5 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground/80",
|
|
227
|
-
className,
|
|
228
|
-
)}
|
|
229
|
-
title={tip}
|
|
230
|
-
aria-label={tip}
|
|
231
|
-
>
|
|
232
|
-
{showRecentDot ? (
|
|
233
|
-
<span
|
|
234
|
-
className="size-1.5 shrink-0 rounded-full bg-blue-500 dark:bg-blue-400"
|
|
235
|
-
aria-hidden
|
|
236
|
-
/>
|
|
237
|
-
) : (
|
|
238
|
-
<Clock className="size-2.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
|
|
239
|
-
)}
|
|
240
|
-
{compact}
|
|
241
|
-
</span>
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function PriorityPill({
|
|
246
|
-
label,
|
|
247
|
-
color,
|
|
248
|
-
prefixNode,
|
|
249
|
-
}: {
|
|
250
|
-
label: string;
|
|
251
|
-
color: string;
|
|
252
|
-
/** Rendered before the display label (not passed through `priorityDisplayLabel`). */
|
|
253
|
-
prefixNode?: ReactNode;
|
|
254
|
-
}) {
|
|
255
|
-
const displayLabel = priorityDisplayLabel(label);
|
|
256
|
-
if (!displayLabel) return null;
|
|
257
|
-
const light = isVeryLightPriorityBackground(color);
|
|
258
|
-
return (
|
|
259
|
-
<span
|
|
260
|
-
className={cn(
|
|
261
|
-
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
|
262
|
-
light
|
|
263
|
-
? "border border-border/70 text-foreground"
|
|
264
|
-
: "text-black/85",
|
|
265
|
-
)}
|
|
266
|
-
title={prefixNode != null ? releaseChipTitle(label) : label}
|
|
267
|
-
style={{
|
|
268
|
-
backgroundColor: color,
|
|
269
|
-
}}
|
|
270
|
-
>
|
|
271
|
-
{prefixNode}
|
|
272
|
-
{displayLabel}
|
|
273
|
-
</span>
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Content section shared by both open and non-open card layouts.
|
|
279
|
-
* Extracted so the two branches render identical markup for the text area.
|
|
280
|
-
*/
|
|
281
|
-
function TaskCardContent({
|
|
282
|
-
task,
|
|
283
|
-
taskPriorities,
|
|
284
|
-
viewMode,
|
|
285
|
-
groupLabel,
|
|
286
|
-
releasePill,
|
|
287
|
-
preview,
|
|
288
|
-
onOpen,
|
|
289
|
-
editingTitle = false,
|
|
290
|
-
titleDraft = "",
|
|
291
|
-
onTitleDraftChange,
|
|
292
|
-
onTitleCommit,
|
|
293
|
-
onTitleCancel,
|
|
294
|
-
titleEditBusy = false,
|
|
295
|
-
}: {
|
|
296
|
-
task: Task;
|
|
297
|
-
taskPriorities: TaskPriorityDefinition[];
|
|
298
|
-
viewMode: TaskCardViewMode;
|
|
299
|
-
groupLabel: string;
|
|
300
|
-
releasePill?: { label: string; color?: string | null } | null;
|
|
301
|
-
preview: string;
|
|
302
|
-
onOpen: () => void;
|
|
303
|
-
editingTitle?: boolean;
|
|
304
|
-
titleDraft?: string;
|
|
305
|
-
onTitleDraftChange?: (value: string) => void;
|
|
306
|
-
onTitleCommit?: () => void;
|
|
307
|
-
onTitleCancel?: () => void;
|
|
308
|
-
titleEditBusy?: boolean;
|
|
309
|
-
}) {
|
|
310
|
-
const viewSpec = getTaskCardViewSpec(viewMode);
|
|
311
|
-
const priorityRow = taskPriorities.find((p) => p.priorityId === task.priorityId);
|
|
312
|
-
// Default builtin `none` is not surfaced as a chip (same UX as “no priority”).
|
|
313
|
-
const showPriorityPill =
|
|
314
|
-
priorityRow != null && priorityRow.value !== NONE_TASK_PRIORITY_VALUE;
|
|
315
|
-
const showReleasePill =
|
|
316
|
-
releasePill != null && releasePill.label.trim().length > 0;
|
|
317
|
-
const titleInputRef = useRef<HTMLTextAreaElement>(null);
|
|
318
|
-
const titleBlurModeRef = useRef<"commit" | "cancel">("commit");
|
|
319
|
-
|
|
320
|
-
useLayoutEffect(() => {
|
|
321
|
-
if (!editingTitle) return;
|
|
322
|
-
titleInputRef.current?.focus();
|
|
323
|
-
titleInputRef.current?.select();
|
|
324
|
-
titleBlurModeRef.current = "commit";
|
|
325
|
-
}, [editingTitle, task.taskId]);
|
|
326
|
-
|
|
327
|
-
useLayoutEffect(() => {
|
|
328
|
-
if (!editingTitle) return;
|
|
329
|
-
autoSizeInlineTitleTextarea(titleInputRef.current);
|
|
330
|
-
}, [editingTitle, titleDraft]);
|
|
331
|
-
|
|
332
|
-
return (
|
|
333
|
-
<div
|
|
334
|
-
className="min-w-0 flex-1 text-left"
|
|
335
|
-
onClick={editingTitle ? undefined : onOpen}
|
|
336
|
-
>
|
|
337
|
-
{editingTitle ? (
|
|
338
|
-
<div className="flex min-w-0 gap-1.5">
|
|
339
|
-
{task.emoji ? (
|
|
340
|
-
<span
|
|
341
|
-
className="shrink-0 text-lg leading-tight"
|
|
342
|
-
aria-hidden
|
|
343
|
-
>
|
|
344
|
-
{task.emoji}
|
|
345
|
-
</span>
|
|
346
|
-
) : null}
|
|
347
|
-
{/* Inline rename auto-fits up to three lines without reselecting text while typing. */}
|
|
348
|
-
<textarea
|
|
349
|
-
ref={titleInputRef}
|
|
350
|
-
rows={3}
|
|
351
|
-
className={cn(
|
|
352
|
-
"min-w-0 flex-1 resize-y rounded border border-input bg-background px-2 py-1 text-foreground select-text",
|
|
353
|
-
viewSpec.titleClassName,
|
|
354
|
-
)}
|
|
355
|
-
value={titleDraft}
|
|
356
|
-
disabled={titleEditBusy}
|
|
357
|
-
onChange={(e) => {
|
|
358
|
-
// Auto-grow up to three lines, then keep the native resize handle available.
|
|
359
|
-
autoSizeInlineTitleTextarea(e.currentTarget);
|
|
360
|
-
onTitleDraftChange?.(clampTaskTitleInput(e.target.value));
|
|
361
|
-
}}
|
|
362
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
363
|
-
onClick={(e) => e.stopPropagation()}
|
|
364
|
-
onBlur={() => {
|
|
365
|
-
if (titleBlurModeRef.current === "cancel") {
|
|
366
|
-
titleBlurModeRef.current = "commit";
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
onTitleCommit?.();
|
|
370
|
-
}}
|
|
371
|
-
onKeyDown={(e) => {
|
|
372
|
-
if (e.key === "Enter") {
|
|
373
|
-
e.preventDefault();
|
|
374
|
-
onTitleCommit?.();
|
|
375
|
-
}
|
|
376
|
-
if (e.key === "Escape") {
|
|
377
|
-
e.preventDefault();
|
|
378
|
-
titleBlurModeRef.current = "cancel";
|
|
379
|
-
onTitleCancel?.();
|
|
380
|
-
}
|
|
381
|
-
}}
|
|
382
|
-
/>
|
|
383
|
-
</div>
|
|
384
|
-
) : (
|
|
385
|
-
<div
|
|
386
|
-
className={cn(
|
|
387
|
-
"flex min-w-0 items-start gap-1.5",
|
|
388
|
-
viewMode === "small" && "items-center",
|
|
389
|
-
)}
|
|
390
|
-
>
|
|
391
|
-
<div
|
|
392
|
-
className={cn(
|
|
393
|
-
"min-w-0 flex-1 font-medium",
|
|
394
|
-
viewSpec.titleClassName,
|
|
395
|
-
)}
|
|
396
|
-
>
|
|
397
|
-
{taskDisplayTitleOnCard(task)}
|
|
398
|
-
</div>
|
|
399
|
-
{viewMode === "small" ? (
|
|
400
|
-
<CliCreatedIndicator task={task} compact />
|
|
401
|
-
) : null}
|
|
402
|
-
</div>
|
|
403
|
-
)}
|
|
404
|
-
{viewMode !== "small" ? (
|
|
405
|
-
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px]">
|
|
406
|
-
<span className="uppercase tracking-wide text-muted-foreground/80">
|
|
407
|
-
{groupLabel}
|
|
408
|
-
</span>
|
|
409
|
-
{showPriorityPill && priorityRow ? (
|
|
410
|
-
<PriorityPill label={priorityRow.label} color={priorityRow.color} />
|
|
411
|
-
) : null}
|
|
412
|
-
{showReleasePill && releasePill?.color ? (
|
|
413
|
-
<PriorityPill
|
|
414
|
-
label={releasePill.label}
|
|
415
|
-
color={releasePill.color}
|
|
416
|
-
prefixNode={<ReleaseChipPrefix />}
|
|
417
|
-
/>
|
|
418
|
-
) : showReleasePill ? (
|
|
419
|
-
<span
|
|
420
|
-
className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
|
|
421
|
-
title={releaseChipTitle(releasePill!.label)}
|
|
422
|
-
>
|
|
423
|
-
<ReleaseChipPrefix />
|
|
424
|
-
{releasePill!.label}
|
|
425
|
-
</span>
|
|
426
|
-
) : null}
|
|
427
|
-
{viewMode === "large" || viewMode === "larger" ? (
|
|
428
|
-
<span
|
|
429
|
-
className="text-muted-foreground/55"
|
|
430
|
-
title={`Task id ${formatTaskIdForDisplay(task.taskId)}`}
|
|
431
|
-
>
|
|
432
|
-
#{formatTaskIdForDisplay(task.taskId)}
|
|
433
|
-
</span>
|
|
434
|
-
) : null}
|
|
435
|
-
<CliCreatedIndicator task={task} />
|
|
436
|
-
{/* Date chip in metadata row when there is no preview body below (large mode). */}
|
|
437
|
-
{!preview && (viewMode === "large" || viewMode === "larger") ? (
|
|
438
|
-
<TaskCardTimelineChip task={task} className="ml-auto" />
|
|
439
|
-
) : null}
|
|
440
|
-
</div>
|
|
441
|
-
) : null}
|
|
442
|
-
{/* Larger mode: preview body + date at bottom-right.
|
|
443
|
-
An invisible inline spacer reserves room on the last text line;
|
|
444
|
-
when text fills that line the spacer wraps, creating vertical space.
|
|
445
|
-
The date is absolutely positioned at bottom-right over the spacer.
|
|
446
|
-
No line-clamp here — JS truncation (previewBody) controls length,
|
|
447
|
-
avoiding the double-ellipsis caused by CSS clamp + JS "…". */}
|
|
448
|
-
{preview && (viewMode === "large" || viewMode === "larger") ? (
|
|
449
|
-
<p className="relative mt-1 text-xs text-muted-foreground">
|
|
450
|
-
{preview}
|
|
451
|
-
{getTaskCardTimeline(task) != null ? (
|
|
452
|
-
<>
|
|
453
|
-
{/* Reserve width for clock + longest relative labels (e.g. “3 days ago”). */}
|
|
454
|
-
<span
|
|
455
|
-
className="inline-block h-4 w-24 select-none align-bottom"
|
|
456
|
-
aria-hidden
|
|
457
|
-
>
|
|
458
|
-
{"\u00A0"}
|
|
459
|
-
</span>
|
|
460
|
-
<TaskCardTimelineChip
|
|
461
|
-
task={task}
|
|
462
|
-
className="absolute bottom-0 right-0"
|
|
463
|
-
/>
|
|
464
|
-
</>
|
|
465
|
-
) : null}
|
|
466
|
-
</p>
|
|
467
|
-
) : preview ? (
|
|
468
|
-
<div
|
|
469
|
-
className={cn(
|
|
470
|
-
"mt-1 text-xs text-muted-foreground",
|
|
471
|
-
viewSpec.previewClassName,
|
|
472
|
-
)}
|
|
473
|
-
>
|
|
474
|
-
{preview}
|
|
475
|
-
</div>
|
|
476
|
-
) : null}
|
|
477
|
-
</div>
|
|
478
|
-
);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const TASK_CARD_INLINE_PADDING_REM = 0.625;
|
|
482
|
-
const TASK_CARD_STATUS_SLOT_REM = 1.375;
|
|
483
|
-
const TASK_CARD_RIGHT_SLOT_REM = 0.25;
|
|
484
|
-
|
|
485
|
-
function openTaskContentRailStyle(viewMode: TaskCardViewMode): CSSProperties {
|
|
486
|
-
const inlinePaddingRem = viewMode === "small" ? 0.5 : TASK_CARD_INLINE_PADDING_REM;
|
|
487
|
-
const blockPaddingRem = viewMode === "small" ? 0.375 : 0.5;
|
|
488
|
-
return {
|
|
489
|
-
// Keep the movement distance and content width derived from the same slots
|
|
490
|
-
// so view-mode changes do not expose part of the hidden open circle or shift it vertically.
|
|
491
|
-
["--task-card-inline-padding" as string]: `${inlinePaddingRem}rem`,
|
|
492
|
-
["--task-card-block-padding" as string]: `${blockPaddingRem}rem`,
|
|
493
|
-
["--task-card-status-slot" as string]: `${TASK_CARD_STATUS_SLOT_REM}rem`,
|
|
494
|
-
["--task-card-right-slot" as string]: `${TASK_CARD_RIGHT_SLOT_REM}rem`,
|
|
495
|
-
width:
|
|
496
|
-
"calc(100% - var(--task-card-status-slot) - var(--task-card-right-slot))",
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Memoized to avoid re-rendering every card on each drag-over event
|
|
501
|
-
export const TaskCard = memo(function TaskCard({
|
|
502
|
-
task,
|
|
503
|
-
taskPriorities,
|
|
504
|
-
viewMode,
|
|
505
|
-
groupLabel,
|
|
506
|
-
releasePill = null,
|
|
507
|
-
onOpen,
|
|
508
|
-
editingTitle = false,
|
|
509
|
-
titleDraft,
|
|
510
|
-
onTitleDraftChange,
|
|
511
|
-
onTitleCommit,
|
|
512
|
-
onTitleCancel,
|
|
513
|
-
titleEditBusy = false,
|
|
514
|
-
onCompleteFromCircle,
|
|
515
|
-
isDragging,
|
|
516
|
-
skipNavRegistration = false,
|
|
517
|
-
}: TaskCardProps) {
|
|
518
|
-
const nav = useBoardKeyboardNavOptional();
|
|
519
|
-
const rootRef = useRef<HTMLDivElement>(null);
|
|
520
|
-
const viewSpec = getTaskCardViewSpec(viewMode);
|
|
521
|
-
|
|
522
|
-
useLayoutEffect(() => {
|
|
523
|
-
if (skipNavRegistration || !nav) return;
|
|
524
|
-
const el = rootRef.current;
|
|
525
|
-
if (el) nav.registerTaskElement(task.taskId, el);
|
|
526
|
-
return () => {
|
|
527
|
-
nav.registerTaskElement(task.taskId, null);
|
|
528
|
-
};
|
|
529
|
-
}, [nav, skipNavRegistration, task.taskId]);
|
|
530
|
-
|
|
531
|
-
const preview = viewSpec.showDescriptionPreview
|
|
532
|
-
? previewBody(task.body, viewSpec.previewMaxLength)
|
|
533
|
-
: "";
|
|
534
|
-
const canCompleteFromCircle =
|
|
535
|
-
task.status === "open" && onCompleteFromCircle !== undefined;
|
|
536
|
-
const isOpenTask = task.status === "open";
|
|
537
|
-
const openTaskRailStyle = openTaskContentRailStyle(viewMode);
|
|
538
|
-
const bodyPaddingClass = taskCardBodyPaddingClass(viewMode);
|
|
539
|
-
|
|
540
|
-
return (
|
|
541
|
-
<div
|
|
542
|
-
ref={rootRef}
|
|
543
|
-
data-task-card-root
|
|
544
|
-
data-task-id={task.taskId}
|
|
545
|
-
onPointerEnter={(e) => {
|
|
546
|
-
if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
|
|
547
|
-
nav.setHoveredTaskId(task.taskId);
|
|
548
|
-
}}
|
|
549
|
-
onPointerLeave={(e) => {
|
|
550
|
-
if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
|
|
551
|
-
nav.setHoveredTaskId(null);
|
|
552
|
-
}}
|
|
553
|
-
onPointerDown={() => {
|
|
554
|
-
if (editingTitle) return;
|
|
555
|
-
// Clicking into a task should make it current before any editor/dialog opens.
|
|
556
|
-
nav?.selectTask(task.taskId);
|
|
557
|
-
}}
|
|
558
|
-
className={cn(
|
|
559
|
-
"group/task-card relative w-full overflow-hidden rounded-md border border-border bg-task-card text-sm text-task-card-foreground shadow-sm transition-colors select-none",
|
|
560
|
-
"hover:bg-accent/45",
|
|
561
|
-
task.color && "border-l-4",
|
|
562
|
-
isDragging && "opacity-40",
|
|
563
|
-
)}
|
|
564
|
-
style={task.color ? { borderLeftColor: task.color } : undefined}
|
|
565
|
-
>
|
|
566
|
-
{isOpenTask ? (
|
|
567
|
-
<div
|
|
568
|
-
className={cn("relative", bodyPaddingClass)}
|
|
569
|
-
style={openTaskRailStyle}
|
|
570
|
-
>
|
|
571
|
-
{canCompleteFromCircle ? (
|
|
572
|
-
<button
|
|
573
|
-
type="button"
|
|
574
|
-
data-task-complete-button
|
|
575
|
-
className={cn(
|
|
576
|
-
"absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start rounded-sm opacity-0 outline-none transition-opacity duration-150 ring-offset-background pointer-events-none group-hover/task-card:pointer-events-auto group-hover/task-card:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring",
|
|
577
|
-
)}
|
|
578
|
-
aria-label="Mark complete"
|
|
579
|
-
title="Mark complete"
|
|
580
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
581
|
-
onClick={(e) => {
|
|
582
|
-
e.stopPropagation();
|
|
583
|
-
// Completing from the card is still a task interaction, so keep
|
|
584
|
-
// this task current before applying the status change.
|
|
585
|
-
nav?.selectTask(task.taskId);
|
|
586
|
-
onCompleteFromCircle(e.currentTarget);
|
|
587
|
-
}}
|
|
588
|
-
>
|
|
589
|
-
<OpenStatusCircle />
|
|
590
|
-
</button>
|
|
591
|
-
) : (
|
|
592
|
-
<div className="absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start">
|
|
593
|
-
<TaskStatusIndicator status={task.status} />
|
|
594
|
-
</div>
|
|
595
|
-
)}
|
|
596
|
-
<div
|
|
597
|
-
className={cn(
|
|
598
|
-
"min-w-0 translate-x-0 transition-transform duration-150 ease-out group-hover/task-card:translate-x-[var(--task-card-status-slot)]",
|
|
599
|
-
)}
|
|
600
|
-
>
|
|
601
|
-
<TaskCardContent
|
|
602
|
-
task={task}
|
|
603
|
-
taskPriorities={taskPriorities}
|
|
604
|
-
viewMode={viewMode}
|
|
605
|
-
groupLabel={groupLabel}
|
|
606
|
-
releasePill={releasePill}
|
|
607
|
-
preview={preview}
|
|
608
|
-
onOpen={onOpen}
|
|
609
|
-
editingTitle={editingTitle}
|
|
610
|
-
titleDraft={titleDraft}
|
|
611
|
-
onTitleDraftChange={onTitleDraftChange}
|
|
612
|
-
onTitleCommit={onTitleCommit}
|
|
613
|
-
onTitleCancel={onTitleCancel}
|
|
614
|
-
titleEditBusy={titleEditBusy}
|
|
615
|
-
/>
|
|
616
|
-
</div>
|
|
617
|
-
</div>
|
|
618
|
-
) : (
|
|
619
|
-
// Non-open tasks: normal flex layout with status always visible.
|
|
620
|
-
<div className={cn("flex gap-2", bodyPaddingClass)}>
|
|
621
|
-
<div className="shrink-0">
|
|
622
|
-
<TaskStatusIndicator status={task.status} />
|
|
623
|
-
</div>
|
|
624
|
-
<TaskCardContent
|
|
625
|
-
task={task}
|
|
626
|
-
taskPriorities={taskPriorities}
|
|
627
|
-
viewMode={viewMode}
|
|
628
|
-
groupLabel={groupLabel}
|
|
629
|
-
releasePill={releasePill}
|
|
630
|
-
preview={preview}
|
|
631
|
-
onOpen={onOpen}
|
|
632
|
-
editingTitle={editingTitle}
|
|
633
|
-
titleDraft={titleDraft}
|
|
634
|
-
onTitleDraftChange={onTitleDraftChange}
|
|
635
|
-
onTitleCommit={onTitleCommit}
|
|
636
|
-
onTitleCancel={onTitleCancel}
|
|
637
|
-
titleEditBusy={titleEditBusy}
|
|
638
|
-
/>
|
|
639
|
-
</div>
|
|
640
|
-
)}
|
|
641
|
-
</div>
|
|
642
|
-
);
|
|
643
|
-
});
|
|
1
|
+
import {
|
|
2
|
+
memo,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Bot, Check, Clock } from "lucide-react";
|
|
9
|
+
import {
|
|
10
|
+
NONE_TASK_PRIORITY_VALUE,
|
|
11
|
+
priorityDisplayLabel,
|
|
12
|
+
formatTaskIdForDisplay,
|
|
13
|
+
taskDisplayTitleOnCard,
|
|
14
|
+
type Board,
|
|
15
|
+
type Task,
|
|
16
|
+
type TaskPriorityDefinition,
|
|
17
|
+
type TaskStatus,
|
|
18
|
+
} from "../../../shared/models";
|
|
19
|
+
import { useBoardKeyboardNavOptional } from "@/components/board/shortcuts/BoardKeyboardNavContext";
|
|
20
|
+
import {
|
|
21
|
+
getTaskCardViewSpec,
|
|
22
|
+
type TaskCardViewMode,
|
|
23
|
+
} from "@/store/preferences";
|
|
24
|
+
import { cn } from "@/lib/utils";
|
|
25
|
+
import {
|
|
26
|
+
formatTaskCardDateTooltip,
|
|
27
|
+
getTaskCardRelativeDateParts,
|
|
28
|
+
getTaskCardTimeline,
|
|
29
|
+
} from "@/lib/taskCardDate";
|
|
30
|
+
import { clampTaskTitleInput } from "../../../shared/taskTitle";
|
|
31
|
+
|
|
32
|
+
function taskCardBodyPaddingClass(viewMode: TaskCardViewMode): string {
|
|
33
|
+
return viewMode === "small" ? "px-2 py-2" : "px-2.5 py-2";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function previewBody(body: string, max = 100): string {
|
|
37
|
+
const plain = body.replace(/\s+/g, " ").trim();
|
|
38
|
+
if (!plain) return "";
|
|
39
|
+
return plain.length > max ? `${plain.slice(0, max)}…` : plain;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function autoSizeInlineTitleTextarea(textarea: HTMLTextAreaElement | null): void {
|
|
43
|
+
if (!textarea) return;
|
|
44
|
+
textarea.style.height = "auto";
|
|
45
|
+
const computed = window.getComputedStyle(textarea);
|
|
46
|
+
const lineHeight = Number.parseFloat(computed.lineHeight) || 20;
|
|
47
|
+
const paddingY =
|
|
48
|
+
(Number.parseFloat(computed.paddingTop) || 0) +
|
|
49
|
+
(Number.parseFloat(computed.paddingBottom) || 0);
|
|
50
|
+
const borderY =
|
|
51
|
+
(Number.parseFloat(computed.borderTopWidth) || 0) +
|
|
52
|
+
(Number.parseFloat(computed.borderBottomWidth) || 0);
|
|
53
|
+
const maxHeight = lineHeight * 3 + paddingY + borderY;
|
|
54
|
+
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
55
|
+
textarea.style.height = `${nextHeight}px`;
|
|
56
|
+
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function statusAriaLabel(status: TaskStatus): string {
|
|
60
|
+
switch (status) {
|
|
61
|
+
case "open":
|
|
62
|
+
return "Open";
|
|
63
|
+
case "in-progress":
|
|
64
|
+
return "In progress";
|
|
65
|
+
case "closed":
|
|
66
|
+
return "Closed";
|
|
67
|
+
default:
|
|
68
|
+
return status || "Status";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function OpenStatusCircle() {
|
|
73
|
+
return (
|
|
74
|
+
<span
|
|
75
|
+
className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
|
|
76
|
+
aria-hidden
|
|
77
|
+
>
|
|
78
|
+
<span className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent" />
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function TaskStatusIndicator({ status }: { status: TaskStatus }) {
|
|
84
|
+
const label = statusAriaLabel(status);
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
|
|
88
|
+
aria-hidden
|
|
89
|
+
>
|
|
90
|
+
{status === "open" ? (
|
|
91
|
+
<span
|
|
92
|
+
className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent"
|
|
93
|
+
title={label}
|
|
94
|
+
/>
|
|
95
|
+
) : null}
|
|
96
|
+
{status === "in-progress" ? (
|
|
97
|
+
<span
|
|
98
|
+
className="size-3.5 rounded-full bg-amber-400 shadow-sm dark:bg-amber-500"
|
|
99
|
+
title={label}
|
|
100
|
+
/>
|
|
101
|
+
) : null}
|
|
102
|
+
{status === "closed" ? (
|
|
103
|
+
<span
|
|
104
|
+
className="flex size-3.5 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm dark:bg-emerald-600"
|
|
105
|
+
title={label}
|
|
106
|
+
>
|
|
107
|
+
<Check className="size-2.5 stroke-[3]" aria-hidden />
|
|
108
|
+
</span>
|
|
109
|
+
) : null}
|
|
110
|
+
{status !== "open" &&
|
|
111
|
+
status !== "in-progress" &&
|
|
112
|
+
status !== "closed" ? (
|
|
113
|
+
<span
|
|
114
|
+
className="size-3.5 rounded-full bg-muted-foreground/35"
|
|
115
|
+
title={label}
|
|
116
|
+
/>
|
|
117
|
+
) : null}
|
|
118
|
+
</span>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface TaskCardProps {
|
|
123
|
+
task: Task;
|
|
124
|
+
taskPriorities: TaskPriorityDefinition[];
|
|
125
|
+
viewMode: TaskCardViewMode;
|
|
126
|
+
/** Display label for `task.groupId` (resolved from board definitions). */
|
|
127
|
+
groupLabel: string;
|
|
128
|
+
/** Optional release chip (same styling as priority when color is set). */
|
|
129
|
+
releasePill?: { label: string; color?: string | null } | null;
|
|
130
|
+
onOpen: () => void;
|
|
131
|
+
/** When true, render an inline title-only editor instead of the normal task title. */
|
|
132
|
+
editingTitle?: boolean;
|
|
133
|
+
titleDraft?: string;
|
|
134
|
+
onTitleDraftChange?: (value: string) => void;
|
|
135
|
+
onTitleCommit?: () => void;
|
|
136
|
+
onTitleCancel?: () => void;
|
|
137
|
+
titleEditBusy?: boolean;
|
|
138
|
+
/** When set, only for `open` tasks: click the empty circle to complete (does not open the editor). */
|
|
139
|
+
onCompleteFromCircle?: (anchorEl: HTMLElement) => void;
|
|
140
|
+
/** When true, dim the card to indicate it's being dragged. */
|
|
141
|
+
isDragging?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* When false (default), this card registers its root for keyboard scroll targeting.
|
|
144
|
+
* SortableTaskRow sets true so only the row wrapper registers once per task.
|
|
145
|
+
*/
|
|
146
|
+
skipNavRegistration?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Shown when the task was created by the CLI principal (Phase 2 provenance). */
|
|
150
|
+
function CliCreatedIndicator({
|
|
151
|
+
task,
|
|
152
|
+
compact,
|
|
153
|
+
}: {
|
|
154
|
+
task: Task;
|
|
155
|
+
/** Smaller icon for dense / small card layout. */
|
|
156
|
+
compact?: boolean;
|
|
157
|
+
}) {
|
|
158
|
+
if (task.createdByPrincipal !== "cli") return null;
|
|
159
|
+
const tip =
|
|
160
|
+
task.createdByLabel?.trim() || "Created via hirotm CLI";
|
|
161
|
+
return (
|
|
162
|
+
<span
|
|
163
|
+
className="inline-flex shrink-0 text-muted-foreground"
|
|
164
|
+
title={tip}
|
|
165
|
+
aria-label={tip}
|
|
166
|
+
>
|
|
167
|
+
<Bot
|
|
168
|
+
className={compact ? "size-3" : "size-3.5"}
|
|
169
|
+
strokeWidth={2}
|
|
170
|
+
aria-hidden
|
|
171
|
+
/>
|
|
172
|
+
</span>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Light priority swatches need a border so the pill stays visible on light card backgrounds. */
|
|
177
|
+
function isVeryLightPriorityBackground(color: string): boolean {
|
|
178
|
+
const c = color.trim().toLowerCase();
|
|
179
|
+
return c === "#ffffff" || c === "#fff" || c === "white";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Resolved release label/color for a task, or null when unassigned / unknown id. */
|
|
183
|
+
export function taskReleasePill(
|
|
184
|
+
board: Pick<Board, "releases">,
|
|
185
|
+
task: Pick<Task, "releaseId">,
|
|
186
|
+
): { label: string; color?: string | null } | null {
|
|
187
|
+
const rid = task.releaseId;
|
|
188
|
+
if (rid == null) return null;
|
|
189
|
+
const r = board.releases.find((x) => x.releaseId === rid);
|
|
190
|
+
if (!r) return null;
|
|
191
|
+
return { label: r.name, color: r.color };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Bold “r” + space before release name in chips (priority pills stay unchanged). */
|
|
195
|
+
function ReleaseChipPrefix(): ReactNode {
|
|
196
|
+
return (
|
|
197
|
+
<>
|
|
198
|
+
<span className="font-bold">r </span>
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function releaseChipTitle(label: string): string {
|
|
204
|
+
return `r ${label.trim()}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Open/created vs closed timestamp for large/larger task cards.
|
|
209
|
+
* Returns a bare `<span>` — the caller decides placement (metadata row, inline after preview, etc.).
|
|
210
|
+
*/
|
|
211
|
+
function TaskCardTimelineChip({
|
|
212
|
+
task,
|
|
213
|
+
className,
|
|
214
|
+
}: {
|
|
215
|
+
task: Task;
|
|
216
|
+
className?: string;
|
|
217
|
+
}) {
|
|
218
|
+
const timeline = getTaskCardTimeline(task);
|
|
219
|
+
if (!timeline) return null;
|
|
220
|
+
const { label: compact, showRecentDot } = getTaskCardRelativeDateParts(timeline.iso);
|
|
221
|
+
if (!compact) return null;
|
|
222
|
+
const tip = formatTaskCardDateTooltip(timeline.kind, timeline.iso);
|
|
223
|
+
return (
|
|
224
|
+
<span
|
|
225
|
+
className={cn(
|
|
226
|
+
"inline-flex items-center gap-0.5 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground/80",
|
|
227
|
+
className,
|
|
228
|
+
)}
|
|
229
|
+
title={tip}
|
|
230
|
+
aria-label={tip}
|
|
231
|
+
>
|
|
232
|
+
{showRecentDot ? (
|
|
233
|
+
<span
|
|
234
|
+
className="size-1.5 shrink-0 rounded-full bg-blue-500 dark:bg-blue-400"
|
|
235
|
+
aria-hidden
|
|
236
|
+
/>
|
|
237
|
+
) : (
|
|
238
|
+
<Clock className="size-2.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
|
|
239
|
+
)}
|
|
240
|
+
{compact}
|
|
241
|
+
</span>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function PriorityPill({
|
|
246
|
+
label,
|
|
247
|
+
color,
|
|
248
|
+
prefixNode,
|
|
249
|
+
}: {
|
|
250
|
+
label: string;
|
|
251
|
+
color: string;
|
|
252
|
+
/** Rendered before the display label (not passed through `priorityDisplayLabel`). */
|
|
253
|
+
prefixNode?: ReactNode;
|
|
254
|
+
}) {
|
|
255
|
+
const displayLabel = priorityDisplayLabel(label);
|
|
256
|
+
if (!displayLabel) return null;
|
|
257
|
+
const light = isVeryLightPriorityBackground(color);
|
|
258
|
+
return (
|
|
259
|
+
<span
|
|
260
|
+
className={cn(
|
|
261
|
+
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
|
262
|
+
light
|
|
263
|
+
? "border border-border/70 text-foreground"
|
|
264
|
+
: "text-black/85",
|
|
265
|
+
)}
|
|
266
|
+
title={prefixNode != null ? releaseChipTitle(label) : label}
|
|
267
|
+
style={{
|
|
268
|
+
backgroundColor: color,
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
{prefixNode}
|
|
272
|
+
{displayLabel}
|
|
273
|
+
</span>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Content section shared by both open and non-open card layouts.
|
|
279
|
+
* Extracted so the two branches render identical markup for the text area.
|
|
280
|
+
*/
|
|
281
|
+
function TaskCardContent({
|
|
282
|
+
task,
|
|
283
|
+
taskPriorities,
|
|
284
|
+
viewMode,
|
|
285
|
+
groupLabel,
|
|
286
|
+
releasePill,
|
|
287
|
+
preview,
|
|
288
|
+
onOpen,
|
|
289
|
+
editingTitle = false,
|
|
290
|
+
titleDraft = "",
|
|
291
|
+
onTitleDraftChange,
|
|
292
|
+
onTitleCommit,
|
|
293
|
+
onTitleCancel,
|
|
294
|
+
titleEditBusy = false,
|
|
295
|
+
}: {
|
|
296
|
+
task: Task;
|
|
297
|
+
taskPriorities: TaskPriorityDefinition[];
|
|
298
|
+
viewMode: TaskCardViewMode;
|
|
299
|
+
groupLabel: string;
|
|
300
|
+
releasePill?: { label: string; color?: string | null } | null;
|
|
301
|
+
preview: string;
|
|
302
|
+
onOpen: () => void;
|
|
303
|
+
editingTitle?: boolean;
|
|
304
|
+
titleDraft?: string;
|
|
305
|
+
onTitleDraftChange?: (value: string) => void;
|
|
306
|
+
onTitleCommit?: () => void;
|
|
307
|
+
onTitleCancel?: () => void;
|
|
308
|
+
titleEditBusy?: boolean;
|
|
309
|
+
}) {
|
|
310
|
+
const viewSpec = getTaskCardViewSpec(viewMode);
|
|
311
|
+
const priorityRow = taskPriorities.find((p) => p.priorityId === task.priorityId);
|
|
312
|
+
// Default builtin `none` is not surfaced as a chip (same UX as “no priority”).
|
|
313
|
+
const showPriorityPill =
|
|
314
|
+
priorityRow != null && priorityRow.value !== NONE_TASK_PRIORITY_VALUE;
|
|
315
|
+
const showReleasePill =
|
|
316
|
+
releasePill != null && releasePill.label.trim().length > 0;
|
|
317
|
+
const titleInputRef = useRef<HTMLTextAreaElement>(null);
|
|
318
|
+
const titleBlurModeRef = useRef<"commit" | "cancel">("commit");
|
|
319
|
+
|
|
320
|
+
useLayoutEffect(() => {
|
|
321
|
+
if (!editingTitle) return;
|
|
322
|
+
titleInputRef.current?.focus();
|
|
323
|
+
titleInputRef.current?.select();
|
|
324
|
+
titleBlurModeRef.current = "commit";
|
|
325
|
+
}, [editingTitle, task.taskId]);
|
|
326
|
+
|
|
327
|
+
useLayoutEffect(() => {
|
|
328
|
+
if (!editingTitle) return;
|
|
329
|
+
autoSizeInlineTitleTextarea(titleInputRef.current);
|
|
330
|
+
}, [editingTitle, titleDraft]);
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div
|
|
334
|
+
className="min-w-0 flex-1 text-left"
|
|
335
|
+
onClick={editingTitle ? undefined : onOpen}
|
|
336
|
+
>
|
|
337
|
+
{editingTitle ? (
|
|
338
|
+
<div className="flex min-w-0 gap-1.5">
|
|
339
|
+
{task.emoji ? (
|
|
340
|
+
<span
|
|
341
|
+
className="shrink-0 text-lg leading-tight"
|
|
342
|
+
aria-hidden
|
|
343
|
+
>
|
|
344
|
+
{task.emoji}
|
|
345
|
+
</span>
|
|
346
|
+
) : null}
|
|
347
|
+
{/* Inline rename auto-fits up to three lines without reselecting text while typing. */}
|
|
348
|
+
<textarea
|
|
349
|
+
ref={titleInputRef}
|
|
350
|
+
rows={3}
|
|
351
|
+
className={cn(
|
|
352
|
+
"min-w-0 flex-1 resize-y rounded border border-input bg-background px-2 py-1 text-foreground select-text",
|
|
353
|
+
viewSpec.titleClassName,
|
|
354
|
+
)}
|
|
355
|
+
value={titleDraft}
|
|
356
|
+
disabled={titleEditBusy}
|
|
357
|
+
onChange={(e) => {
|
|
358
|
+
// Auto-grow up to three lines, then keep the native resize handle available.
|
|
359
|
+
autoSizeInlineTitleTextarea(e.currentTarget);
|
|
360
|
+
onTitleDraftChange?.(clampTaskTitleInput(e.target.value));
|
|
361
|
+
}}
|
|
362
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
363
|
+
onClick={(e) => e.stopPropagation()}
|
|
364
|
+
onBlur={() => {
|
|
365
|
+
if (titleBlurModeRef.current === "cancel") {
|
|
366
|
+
titleBlurModeRef.current = "commit";
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
onTitleCommit?.();
|
|
370
|
+
}}
|
|
371
|
+
onKeyDown={(e) => {
|
|
372
|
+
if (e.key === "Enter") {
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
onTitleCommit?.();
|
|
375
|
+
}
|
|
376
|
+
if (e.key === "Escape") {
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
titleBlurModeRef.current = "cancel";
|
|
379
|
+
onTitleCancel?.();
|
|
380
|
+
}
|
|
381
|
+
}}
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
) : (
|
|
385
|
+
<div
|
|
386
|
+
className={cn(
|
|
387
|
+
"flex min-w-0 items-start gap-1.5",
|
|
388
|
+
viewMode === "small" && "items-center",
|
|
389
|
+
)}
|
|
390
|
+
>
|
|
391
|
+
<div
|
|
392
|
+
className={cn(
|
|
393
|
+
"min-w-0 flex-1 font-medium",
|
|
394
|
+
viewSpec.titleClassName,
|
|
395
|
+
)}
|
|
396
|
+
>
|
|
397
|
+
{taskDisplayTitleOnCard(task)}
|
|
398
|
+
</div>
|
|
399
|
+
{viewMode === "small" ? (
|
|
400
|
+
<CliCreatedIndicator task={task} compact />
|
|
401
|
+
) : null}
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
{viewMode !== "small" ? (
|
|
405
|
+
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px]">
|
|
406
|
+
<span className="uppercase tracking-wide text-muted-foreground/80">
|
|
407
|
+
{groupLabel}
|
|
408
|
+
</span>
|
|
409
|
+
{showPriorityPill && priorityRow ? (
|
|
410
|
+
<PriorityPill label={priorityRow.label} color={priorityRow.color} />
|
|
411
|
+
) : null}
|
|
412
|
+
{showReleasePill && releasePill?.color ? (
|
|
413
|
+
<PriorityPill
|
|
414
|
+
label={releasePill.label}
|
|
415
|
+
color={releasePill.color}
|
|
416
|
+
prefixNode={<ReleaseChipPrefix />}
|
|
417
|
+
/>
|
|
418
|
+
) : showReleasePill ? (
|
|
419
|
+
<span
|
|
420
|
+
className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
|
|
421
|
+
title={releaseChipTitle(releasePill!.label)}
|
|
422
|
+
>
|
|
423
|
+
<ReleaseChipPrefix />
|
|
424
|
+
{releasePill!.label}
|
|
425
|
+
</span>
|
|
426
|
+
) : null}
|
|
427
|
+
{viewMode === "large" || viewMode === "larger" ? (
|
|
428
|
+
<span
|
|
429
|
+
className="text-muted-foreground/55"
|
|
430
|
+
title={`Task id ${formatTaskIdForDisplay(task.taskId)}`}
|
|
431
|
+
>
|
|
432
|
+
#{formatTaskIdForDisplay(task.taskId)}
|
|
433
|
+
</span>
|
|
434
|
+
) : null}
|
|
435
|
+
<CliCreatedIndicator task={task} />
|
|
436
|
+
{/* Date chip in metadata row when there is no preview body below (large mode). */}
|
|
437
|
+
{!preview && (viewMode === "large" || viewMode === "larger") ? (
|
|
438
|
+
<TaskCardTimelineChip task={task} className="ml-auto" />
|
|
439
|
+
) : null}
|
|
440
|
+
</div>
|
|
441
|
+
) : null}
|
|
442
|
+
{/* Larger mode: preview body + date at bottom-right.
|
|
443
|
+
An invisible inline spacer reserves room on the last text line;
|
|
444
|
+
when text fills that line the spacer wraps, creating vertical space.
|
|
445
|
+
The date is absolutely positioned at bottom-right over the spacer.
|
|
446
|
+
No line-clamp here — JS truncation (previewBody) controls length,
|
|
447
|
+
avoiding the double-ellipsis caused by CSS clamp + JS "…". */}
|
|
448
|
+
{preview && (viewMode === "large" || viewMode === "larger") ? (
|
|
449
|
+
<p className="relative mt-1 text-xs text-muted-foreground">
|
|
450
|
+
{preview}
|
|
451
|
+
{getTaskCardTimeline(task) != null ? (
|
|
452
|
+
<>
|
|
453
|
+
{/* Reserve width for clock + longest relative labels (e.g. “3 days ago”). */}
|
|
454
|
+
<span
|
|
455
|
+
className="inline-block h-4 w-24 select-none align-bottom"
|
|
456
|
+
aria-hidden
|
|
457
|
+
>
|
|
458
|
+
{"\u00A0"}
|
|
459
|
+
</span>
|
|
460
|
+
<TaskCardTimelineChip
|
|
461
|
+
task={task}
|
|
462
|
+
className="absolute bottom-0 right-0"
|
|
463
|
+
/>
|
|
464
|
+
</>
|
|
465
|
+
) : null}
|
|
466
|
+
</p>
|
|
467
|
+
) : preview ? (
|
|
468
|
+
<div
|
|
469
|
+
className={cn(
|
|
470
|
+
"mt-1 text-xs text-muted-foreground",
|
|
471
|
+
viewSpec.previewClassName,
|
|
472
|
+
)}
|
|
473
|
+
>
|
|
474
|
+
{preview}
|
|
475
|
+
</div>
|
|
476
|
+
) : null}
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const TASK_CARD_INLINE_PADDING_REM = 0.625;
|
|
482
|
+
const TASK_CARD_STATUS_SLOT_REM = 1.375;
|
|
483
|
+
const TASK_CARD_RIGHT_SLOT_REM = 0.25;
|
|
484
|
+
|
|
485
|
+
function openTaskContentRailStyle(viewMode: TaskCardViewMode): CSSProperties {
|
|
486
|
+
const inlinePaddingRem = viewMode === "small" ? 0.5 : TASK_CARD_INLINE_PADDING_REM;
|
|
487
|
+
const blockPaddingRem = viewMode === "small" ? 0.375 : 0.5;
|
|
488
|
+
return {
|
|
489
|
+
// Keep the movement distance and content width derived from the same slots
|
|
490
|
+
// so view-mode changes do not expose part of the hidden open circle or shift it vertically.
|
|
491
|
+
["--task-card-inline-padding" as string]: `${inlinePaddingRem}rem`,
|
|
492
|
+
["--task-card-block-padding" as string]: `${blockPaddingRem}rem`,
|
|
493
|
+
["--task-card-status-slot" as string]: `${TASK_CARD_STATUS_SLOT_REM}rem`,
|
|
494
|
+
["--task-card-right-slot" as string]: `${TASK_CARD_RIGHT_SLOT_REM}rem`,
|
|
495
|
+
width:
|
|
496
|
+
"calc(100% - var(--task-card-status-slot) - var(--task-card-right-slot))",
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Memoized to avoid re-rendering every card on each drag-over event
|
|
501
|
+
export const TaskCard = memo(function TaskCard({
|
|
502
|
+
task,
|
|
503
|
+
taskPriorities,
|
|
504
|
+
viewMode,
|
|
505
|
+
groupLabel,
|
|
506
|
+
releasePill = null,
|
|
507
|
+
onOpen,
|
|
508
|
+
editingTitle = false,
|
|
509
|
+
titleDraft,
|
|
510
|
+
onTitleDraftChange,
|
|
511
|
+
onTitleCommit,
|
|
512
|
+
onTitleCancel,
|
|
513
|
+
titleEditBusy = false,
|
|
514
|
+
onCompleteFromCircle,
|
|
515
|
+
isDragging,
|
|
516
|
+
skipNavRegistration = false,
|
|
517
|
+
}: TaskCardProps) {
|
|
518
|
+
const nav = useBoardKeyboardNavOptional();
|
|
519
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
520
|
+
const viewSpec = getTaskCardViewSpec(viewMode);
|
|
521
|
+
|
|
522
|
+
useLayoutEffect(() => {
|
|
523
|
+
if (skipNavRegistration || !nav) return;
|
|
524
|
+
const el = rootRef.current;
|
|
525
|
+
if (el) nav.registerTaskElement(task.taskId, el);
|
|
526
|
+
return () => {
|
|
527
|
+
nav.registerTaskElement(task.taskId, null);
|
|
528
|
+
};
|
|
529
|
+
}, [nav, skipNavRegistration, task.taskId]);
|
|
530
|
+
|
|
531
|
+
const preview = viewSpec.showDescriptionPreview
|
|
532
|
+
? previewBody(task.body, viewSpec.previewMaxLength)
|
|
533
|
+
: "";
|
|
534
|
+
const canCompleteFromCircle =
|
|
535
|
+
task.status === "open" && onCompleteFromCircle !== undefined;
|
|
536
|
+
const isOpenTask = task.status === "open";
|
|
537
|
+
const openTaskRailStyle = openTaskContentRailStyle(viewMode);
|
|
538
|
+
const bodyPaddingClass = taskCardBodyPaddingClass(viewMode);
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<div
|
|
542
|
+
ref={rootRef}
|
|
543
|
+
data-task-card-root
|
|
544
|
+
data-task-id={task.taskId}
|
|
545
|
+
onPointerEnter={(e) => {
|
|
546
|
+
if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
|
|
547
|
+
nav.setHoveredTaskId(task.taskId);
|
|
548
|
+
}}
|
|
549
|
+
onPointerLeave={(e) => {
|
|
550
|
+
if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
|
|
551
|
+
nav.setHoveredTaskId(null);
|
|
552
|
+
}}
|
|
553
|
+
onPointerDown={() => {
|
|
554
|
+
if (editingTitle) return;
|
|
555
|
+
// Clicking into a task should make it current before any editor/dialog opens.
|
|
556
|
+
nav?.selectTask(task.taskId);
|
|
557
|
+
}}
|
|
558
|
+
className={cn(
|
|
559
|
+
"group/task-card relative w-full overflow-hidden rounded-md border border-border bg-task-card text-sm text-task-card-foreground shadow-sm transition-colors select-none",
|
|
560
|
+
"hover:bg-accent/45",
|
|
561
|
+
task.color && "border-l-4",
|
|
562
|
+
isDragging && "opacity-40",
|
|
563
|
+
)}
|
|
564
|
+
style={task.color ? { borderLeftColor: task.color } : undefined}
|
|
565
|
+
>
|
|
566
|
+
{isOpenTask ? (
|
|
567
|
+
<div
|
|
568
|
+
className={cn("relative", bodyPaddingClass)}
|
|
569
|
+
style={openTaskRailStyle}
|
|
570
|
+
>
|
|
571
|
+
{canCompleteFromCircle ? (
|
|
572
|
+
<button
|
|
573
|
+
type="button"
|
|
574
|
+
data-task-complete-button
|
|
575
|
+
className={cn(
|
|
576
|
+
"absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start rounded-sm opacity-0 outline-none transition-opacity duration-150 ring-offset-background pointer-events-none group-hover/task-card:pointer-events-auto group-hover/task-card:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring",
|
|
577
|
+
)}
|
|
578
|
+
aria-label="Mark complete"
|
|
579
|
+
title="Mark complete"
|
|
580
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
581
|
+
onClick={(e) => {
|
|
582
|
+
e.stopPropagation();
|
|
583
|
+
// Completing from the card is still a task interaction, so keep
|
|
584
|
+
// this task current before applying the status change.
|
|
585
|
+
nav?.selectTask(task.taskId);
|
|
586
|
+
onCompleteFromCircle(e.currentTarget);
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
<OpenStatusCircle />
|
|
590
|
+
</button>
|
|
591
|
+
) : (
|
|
592
|
+
<div className="absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start">
|
|
593
|
+
<TaskStatusIndicator status={task.status} />
|
|
594
|
+
</div>
|
|
595
|
+
)}
|
|
596
|
+
<div
|
|
597
|
+
className={cn(
|
|
598
|
+
"min-w-0 translate-x-0 transition-transform duration-150 ease-out group-hover/task-card:translate-x-[var(--task-card-status-slot)]",
|
|
599
|
+
)}
|
|
600
|
+
>
|
|
601
|
+
<TaskCardContent
|
|
602
|
+
task={task}
|
|
603
|
+
taskPriorities={taskPriorities}
|
|
604
|
+
viewMode={viewMode}
|
|
605
|
+
groupLabel={groupLabel}
|
|
606
|
+
releasePill={releasePill}
|
|
607
|
+
preview={preview}
|
|
608
|
+
onOpen={onOpen}
|
|
609
|
+
editingTitle={editingTitle}
|
|
610
|
+
titleDraft={titleDraft}
|
|
611
|
+
onTitleDraftChange={onTitleDraftChange}
|
|
612
|
+
onTitleCommit={onTitleCommit}
|
|
613
|
+
onTitleCancel={onTitleCancel}
|
|
614
|
+
titleEditBusy={titleEditBusy}
|
|
615
|
+
/>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
) : (
|
|
619
|
+
// Non-open tasks: normal flex layout with status always visible.
|
|
620
|
+
<div className={cn("flex gap-2", bodyPaddingClass)}>
|
|
621
|
+
<div className="shrink-0">
|
|
622
|
+
<TaskStatusIndicator status={task.status} />
|
|
623
|
+
</div>
|
|
624
|
+
<TaskCardContent
|
|
625
|
+
task={task}
|
|
626
|
+
taskPriorities={taskPriorities}
|
|
627
|
+
viewMode={viewMode}
|
|
628
|
+
groupLabel={groupLabel}
|
|
629
|
+
releasePill={releasePill}
|
|
630
|
+
preview={preview}
|
|
631
|
+
onOpen={onOpen}
|
|
632
|
+
editingTitle={editingTitle}
|
|
633
|
+
titleDraft={titleDraft}
|
|
634
|
+
onTitleDraftChange={onTitleDraftChange}
|
|
635
|
+
onTitleCommit={onTitleCommit}
|
|
636
|
+
onTitleCancel={onTitleCancel}
|
|
637
|
+
titleEditBusy={titleEditBusy}
|
|
638
|
+
/>
|
|
639
|
+
</div>
|
|
640
|
+
)}
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
});
|