@allpepper/task-orchestrator-tui 1.0.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 (50) hide show
  1. package/README.md +78 -0
  2. package/package.json +54 -0
  3. package/src/tui/app.tsx +308 -0
  4. package/src/tui/components/column-filter-bar.tsx +52 -0
  5. package/src/tui/components/confirm-dialog.tsx +45 -0
  6. package/src/tui/components/dependency-list.tsx +115 -0
  7. package/src/tui/components/empty-state.tsx +28 -0
  8. package/src/tui/components/entity-table.tsx +120 -0
  9. package/src/tui/components/error-message.tsx +41 -0
  10. package/src/tui/components/feature-kanban-card.tsx +216 -0
  11. package/src/tui/components/footer.tsx +34 -0
  12. package/src/tui/components/form-dialog.tsx +338 -0
  13. package/src/tui/components/header.tsx +54 -0
  14. package/src/tui/components/index.ts +16 -0
  15. package/src/tui/components/kanban-board.tsx +335 -0
  16. package/src/tui/components/kanban-card.tsx +70 -0
  17. package/src/tui/components/kanban-column.tsx +173 -0
  18. package/src/tui/components/priority-badge.tsx +16 -0
  19. package/src/tui/components/section-list.tsx +96 -0
  20. package/src/tui/components/status-actions.tsx +87 -0
  21. package/src/tui/components/status-badge.tsx +22 -0
  22. package/src/tui/components/tree-view.tsx +295 -0
  23. package/src/tui/components/view-mode-chips.tsx +23 -0
  24. package/src/tui/index.tsx +33 -0
  25. package/src/tui/screens/dashboard.tsx +248 -0
  26. package/src/tui/screens/feature-detail.tsx +312 -0
  27. package/src/tui/screens/index.ts +6 -0
  28. package/src/tui/screens/kanban-view.tsx +251 -0
  29. package/src/tui/screens/project-detail.tsx +305 -0
  30. package/src/tui/screens/project-view.tsx +498 -0
  31. package/src/tui/screens/search.tsx +257 -0
  32. package/src/tui/screens/task-detail.tsx +294 -0
  33. package/src/ui/adapters/direct.ts +429 -0
  34. package/src/ui/adapters/index.ts +14 -0
  35. package/src/ui/adapters/types.ts +269 -0
  36. package/src/ui/context/adapter-context.tsx +31 -0
  37. package/src/ui/context/theme-context.tsx +43 -0
  38. package/src/ui/hooks/index.ts +20 -0
  39. package/src/ui/hooks/use-data.ts +919 -0
  40. package/src/ui/hooks/use-debounce.ts +37 -0
  41. package/src/ui/hooks/use-feature-kanban.ts +151 -0
  42. package/src/ui/hooks/use-kanban.ts +96 -0
  43. package/src/ui/hooks/use-navigation.tsx +94 -0
  44. package/src/ui/index.ts +73 -0
  45. package/src/ui/lib/colors.ts +79 -0
  46. package/src/ui/lib/format.ts +114 -0
  47. package/src/ui/lib/types.ts +157 -0
  48. package/src/ui/themes/dark.ts +63 -0
  49. package/src/ui/themes/light.ts +63 -0
  50. package/src/ui/themes/types.ts +71 -0
@@ -0,0 +1,919 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react';
2
+ import { useAdapter } from '../context/adapter-context';
3
+ import type { Project, Task, Section, EntityType, TaskStatus, Priority, FeatureStatus } from 'task-orchestrator-bun/src/domain/types';
4
+ import type { FeatureWithTasks, ProjectOverview, SearchResults, DependencyInfo, BoardCard, BoardTask } from '../lib/types';
5
+ import type { TreeRow } from '../../tui/components/tree-view';
6
+
7
+ /**
8
+ * Task counts structure
9
+ */
10
+ export interface TaskCounts {
11
+ total: number;
12
+ completed: number;
13
+ }
14
+
15
+ /**
16
+ * Calculate task counts from an array of tasks
17
+ */
18
+ export function calculateTaskCounts(tasks: Task[]): TaskCounts {
19
+ return {
20
+ total: tasks.length,
21
+ completed: tasks.filter(t => t.status === 'COMPLETED').length,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Group tasks by project ID and calculate counts for each
27
+ */
28
+ export function calculateTaskCountsByProject(tasks: Task[]): Map<string, TaskCounts> {
29
+ const countsByProject = new Map<string, TaskCounts>();
30
+
31
+ for (const task of tasks) {
32
+ if (task.projectId) {
33
+ const counts = countsByProject.get(task.projectId) || { total: 0, completed: 0 };
34
+ counts.total++;
35
+ if (task.status === 'COMPLETED') {
36
+ counts.completed++;
37
+ }
38
+ countsByProject.set(task.projectId, counts);
39
+ }
40
+ }
41
+
42
+ return countsByProject;
43
+ }
44
+
45
+ /**
46
+ * Project with task count information for dashboard display
47
+ */
48
+ export interface ProjectWithCounts extends Project {
49
+ taskCounts: TaskCounts;
50
+ }
51
+
52
+ /**
53
+ * Hook for fetching and managing the list of projects for the dashboard.
54
+ * Includes task counts for each project.
55
+ *
56
+ * @returns Project list state with loading/error states and refresh function
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * const { projects, loading, error, refresh } = useProjects();
61
+ * ```
62
+ */
63
+ export function useProjects() {
64
+ const { adapter } = useAdapter();
65
+ const [projects, setProjects] = useState<ProjectWithCounts[]>([]);
66
+ const [loading, setLoading] = useState(true);
67
+ const [error, setError] = useState<string | null>(null);
68
+
69
+ const loadProjects = useCallback(async () => {
70
+ setLoading(true);
71
+ setError(null);
72
+
73
+ // Fetch projects and all tasks in parallel
74
+ const [projectsResult, tasksResult] = await Promise.all([
75
+ adapter.getProjects(),
76
+ adapter.getTasks({ limit: 1000 }), // Get all tasks to count by project
77
+ ]);
78
+
79
+ if (!projectsResult.success) {
80
+ setError(projectsResult.error);
81
+ setLoading(false);
82
+ return;
83
+ }
84
+
85
+ // Build task counts by project using shared utility
86
+ const taskCountsByProject = tasksResult.success
87
+ ? calculateTaskCountsByProject(tasksResult.data)
88
+ : new Map<string, TaskCounts>();
89
+
90
+ // Merge task counts into projects
91
+ const projectsWithCounts: ProjectWithCounts[] = projectsResult.data.map(project => ({
92
+ ...project,
93
+ taskCounts: taskCountsByProject.get(project.id) || { total: 0, completed: 0 },
94
+ }));
95
+
96
+ setProjects(projectsWithCounts);
97
+ setLoading(false);
98
+ }, [adapter]);
99
+
100
+ useEffect(() => {
101
+ loadProjects();
102
+ }, [loadProjects]);
103
+
104
+ return {
105
+ projects,
106
+ loading,
107
+ error,
108
+ refresh: loadProjects,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Hook for fetching a single project with its overview statistics.
114
+ *
115
+ * @param id - The project ID
116
+ * @returns Project and overview state with loading/error states and refresh function
117
+ *
118
+ * @example
119
+ * ```tsx
120
+ * const { project, overview, loading, error, refresh } = useProjectOverview('proj-123');
121
+ * ```
122
+ */
123
+ export function useProjectOverview(id: string) {
124
+ const { adapter } = useAdapter();
125
+ const [project, setProject] = useState<Project | null>(null);
126
+ const [overview, setOverview] = useState<ProjectOverview | null>(null);
127
+ const [loading, setLoading] = useState(true);
128
+ const [error, setError] = useState<string | null>(null);
129
+
130
+ const loadProjectOverview = useCallback(async () => {
131
+ setLoading(true);
132
+ setError(null);
133
+
134
+ const [projectResult, overviewResult] = await Promise.all([
135
+ adapter.getProject(id),
136
+ adapter.getProjectOverview(id),
137
+ ]);
138
+
139
+ if (projectResult.success) {
140
+ setProject(projectResult.data);
141
+ } else {
142
+ setError(projectResult.error);
143
+ }
144
+
145
+ if (overviewResult.success) {
146
+ setOverview(overviewResult.data);
147
+ } else if (!error) {
148
+ // Only set error if project fetch didn't already fail
149
+ setError(overviewResult.error);
150
+ }
151
+
152
+ setLoading(false);
153
+ }, [adapter, id, error]);
154
+
155
+ useEffect(() => {
156
+ loadProjectOverview();
157
+ }, [loadProjectOverview]);
158
+
159
+ return {
160
+ project,
161
+ overview,
162
+ loading,
163
+ error,
164
+ refresh: loadProjectOverview,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Status order for grouping tasks - active statuses first, then terminal statuses
170
+ */
171
+ const STATUS_ORDER: TaskStatus[] = [
172
+ 'PENDING' as TaskStatus,
173
+ 'IN_PROGRESS' as TaskStatus,
174
+ 'IN_REVIEW' as TaskStatus,
175
+ 'BLOCKED' as TaskStatus,
176
+ 'ON_HOLD' as TaskStatus,
177
+ 'COMPLETED' as TaskStatus,
178
+ 'CANCELLED' as TaskStatus,
179
+ ];
180
+
181
+ /**
182
+ * Priority order for sorting tasks within status groups
183
+ */
184
+ const PRIORITY_ORDER: Record<Priority, number> = {
185
+ HIGH: 3,
186
+ MEDIUM: 2,
187
+ LOW: 1,
188
+ };
189
+
190
+ const BOARD_STATUS_ORDER: TaskStatus[] = [
191
+ 'PENDING' as TaskStatus,
192
+ 'IN_PROGRESS' as TaskStatus,
193
+ 'IN_REVIEW' as TaskStatus,
194
+ 'BLOCKED' as TaskStatus,
195
+ 'COMPLETED' as TaskStatus,
196
+ ];
197
+
198
+ /**
199
+ * Display names for task statuses
200
+ */
201
+ const STATUS_DISPLAY_NAMES: Record<string, string> = {
202
+ BACKLOG: 'Backlog',
203
+ PENDING: 'Pending',
204
+ IN_PROGRESS: 'In Progress',
205
+ IN_REVIEW: 'In Review',
206
+ CHANGES_REQUESTED: 'Changes Requested',
207
+ TESTING: 'Testing',
208
+ READY_FOR_QA: 'Ready for QA',
209
+ INVESTIGATING: 'Investigating',
210
+ BLOCKED: 'Blocked',
211
+ ON_HOLD: 'On Hold',
212
+ DEPLOYED: 'Deployed',
213
+ COMPLETED: 'Completed',
214
+ CANCELLED: 'Cancelled',
215
+ DEFERRED: 'Deferred',
216
+ DRAFT: 'Draft',
217
+ PLANNING: 'Planning',
218
+ IN_DEVELOPMENT: 'In Development',
219
+ VALIDATING: 'Validating',
220
+ PENDING_REVIEW: 'Pending Review',
221
+ ARCHIVED: 'Archived',
222
+ };
223
+
224
+ /**
225
+ * Feature status order for grouping features by their own status
226
+ */
227
+ const FEATURE_STATUS_ORDER: FeatureStatus[] = [
228
+ 'DRAFT' as FeatureStatus,
229
+ 'PLANNING' as FeatureStatus,
230
+ 'IN_DEVELOPMENT' as FeatureStatus,
231
+ 'TESTING' as FeatureStatus,
232
+ 'VALIDATING' as FeatureStatus,
233
+ 'PENDING_REVIEW' as FeatureStatus,
234
+ 'BLOCKED' as FeatureStatus,
235
+ 'ON_HOLD' as FeatureStatus,
236
+ 'DEPLOYED' as FeatureStatus,
237
+ 'COMPLETED' as FeatureStatus,
238
+ 'ARCHIVED' as FeatureStatus,
239
+ ];
240
+
241
+ /**
242
+ * Build status-grouped tree rows for tasks
243
+ * Groups tasks by status, then by feature within each status
244
+ *
245
+ * @param tasks - All tasks to group
246
+ * @param features - Features to lookup task feature info
247
+ * @param expandedGroups - Set of expanded group IDs (both status groups and composite feature keys)
248
+ * @returns TreeRow[] grouped by status → feature → tasks
249
+ */
250
+ function buildStatusGroupedRows(
251
+ tasks: Task[],
252
+ features: FeatureWithTasks[],
253
+ expandedGroups: Set<string>
254
+ ): TreeRow[] {
255
+ const rows: TreeRow[] = [];
256
+
257
+ const featureMap = new Map<string, FeatureWithTasks>();
258
+ for (const feature of features) {
259
+ featureMap.set(feature.id, feature);
260
+ }
261
+
262
+ // Group tasks by status
263
+ const tasksByStatus = new Map<TaskStatus, Task[]>();
264
+ for (const task of tasks) {
265
+ const status = task.status as TaskStatus;
266
+ const group = tasksByStatus.get(status) || [];
267
+ group.push(task);
268
+ tasksByStatus.set(status, group);
269
+ }
270
+
271
+ const featureStatusToTaskStatus = (status: FeatureStatus): TaskStatus => {
272
+ switch (status) {
273
+ case 'COMPLETED':
274
+ case 'DEPLOYED':
275
+ return 'COMPLETED' as TaskStatus;
276
+ case 'BLOCKED':
277
+ return 'BLOCKED' as TaskStatus;
278
+ case 'ON_HOLD':
279
+ return 'ON_HOLD' as TaskStatus;
280
+ case 'ARCHIVED':
281
+ return 'CANCELLED' as TaskStatus;
282
+ case 'PENDING_REVIEW':
283
+ return 'IN_REVIEW' as TaskStatus;
284
+ case 'IN_DEVELOPMENT':
285
+ case 'TESTING':
286
+ case 'VALIDATING':
287
+ return 'IN_PROGRESS' as TaskStatus;
288
+ case 'PLANNING':
289
+ case 'DRAFT':
290
+ default:
291
+ return 'PENDING' as TaskStatus;
292
+ }
293
+ };
294
+
295
+ // Only inject empty features into status buckets to avoid duplicate feature rows across statuses.
296
+ const featureHasTasks = new Set<string>();
297
+ for (const task of tasks) {
298
+ if (task.featureId) {
299
+ featureHasTasks.add(task.featureId);
300
+ }
301
+ }
302
+
303
+ // Group features by their mapped status bucket
304
+ const featuresByStatus = new Map<TaskStatus, FeatureWithTasks[]>();
305
+ for (const feature of features) {
306
+ if (featureHasTasks.has(feature.id)) {
307
+ continue;
308
+ }
309
+ const mappedStatus = featureStatusToTaskStatus(feature.status as FeatureStatus);
310
+ const group = featuresByStatus.get(mappedStatus) || [];
311
+ group.push(feature);
312
+ featuresByStatus.set(mappedStatus, group);
313
+ }
314
+
315
+ // Build rows in status order
316
+ for (const status of STATUS_ORDER) {
317
+ const statusTasks = tasksByStatus.get(status) || [];
318
+ const statusFeatures = featuresByStatus.get(status) || [];
319
+ if (statusTasks.length === 0 && statusFeatures.length === 0) continue;
320
+
321
+ const statusGroupId = status;
322
+ const statusExpanded = expandedGroups.has(statusGroupId);
323
+ const statusExpandable = statusTasks.length > 0 || statusFeatures.length > 0;
324
+
325
+ // Add status group row (depth 0)
326
+ rows.push({
327
+ type: 'group',
328
+ id: statusGroupId,
329
+ label: STATUS_DISPLAY_NAMES[status] || status,
330
+ status,
331
+ taskCount: statusTasks.length,
332
+ expanded: statusExpanded,
333
+ depth: 0,
334
+ expandable: statusExpandable,
335
+ });
336
+
337
+ // If status group is expanded, group tasks by feature
338
+ if (statusExpanded) {
339
+ // Group tasks by featureId (with null for unassigned)
340
+ const tasksByFeature = new Map<string | null, Task[]>();
341
+ for (const task of statusTasks) {
342
+ const featureId = task.featureId || null;
343
+ const group = tasksByFeature.get(featureId) || [];
344
+ group.push(task);
345
+ tasksByFeature.set(featureId, group);
346
+ }
347
+
348
+ // Sort tasks within each feature by priority (descending) then title
349
+ for (const [_, featureTasks] of tasksByFeature.entries()) {
350
+ featureTasks.sort((a, b) => {
351
+ const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
352
+ if (priorityDiff !== 0) return priorityDiff;
353
+ return a.title.localeCompare(b.title);
354
+ });
355
+ }
356
+
357
+ // Build feature sub-groups
358
+ // First, collect features that have tasks in this status (sorted by creation date)
359
+ const tasksByFeatureId = new Map<string, Task[]>();
360
+ for (const [featureId, featureTasks] of tasksByFeature.entries()) {
361
+ if (featureId !== null) {
362
+ tasksByFeatureId.set(featureId, featureTasks);
363
+ }
364
+ }
365
+
366
+ // Sort mapped features by creation date (ascending, oldest first), including empty features
367
+ const statusFeatureMap = new Map<string, FeatureWithTasks>();
368
+ for (const feature of statusFeatures) {
369
+ statusFeatureMap.set(feature.id, feature);
370
+ }
371
+ for (const featureId of tasksByFeatureId.keys()) {
372
+ const feature = featureMap.get(featureId);
373
+ if (feature) {
374
+ statusFeatureMap.set(feature.id, feature);
375
+ }
376
+ }
377
+
378
+ const sortedStatusFeatures = [...statusFeatureMap.values()].sort((a, b) =>
379
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
380
+ );
381
+
382
+ // Add feature sub-group rows
383
+ for (const feature of sortedStatusFeatures) {
384
+ const featureId = feature.id;
385
+ const featureTasks = tasksByFeatureId.get(featureId) || [];
386
+ const compositeFeatureId = `${status}:${featureId}`;
387
+ const featureExpandable = featureTasks.length > 0;
388
+ const featureExpanded = featureExpandable && expandedGroups.has(compositeFeatureId);
389
+
390
+ // Add feature group row (depth 1)
391
+ rows.push({
392
+ type: 'group',
393
+ id: compositeFeatureId,
394
+ label: feature.name,
395
+ status: feature.status,
396
+ taskCount: featureTasks.length,
397
+ expanded: featureExpanded,
398
+ depth: 1,
399
+ expandable: featureExpandable,
400
+ featureId,
401
+ });
402
+
403
+ // Add task rows if feature is expanded (depth 1)
404
+ if (featureExpanded) {
405
+ featureTasks.forEach((task, index) => {
406
+ const isLast = index === featureTasks.length - 1;
407
+ rows.push({
408
+ type: 'task',
409
+ task,
410
+ isLast,
411
+ depth: 2,
412
+ // No featureName needed - tasks are nested under their feature
413
+ });
414
+ });
415
+ }
416
+ }
417
+
418
+ // Add unassigned tasks (if any)
419
+ const unassignedTasks = tasksByFeature.get(null);
420
+ if (unassignedTasks && unassignedTasks.length > 0) {
421
+ const unassignedId = `${status}:unassigned`;
422
+ const unassignedExpanded = expandedGroups.has(unassignedId);
423
+
424
+ // Sort unassigned tasks by priority (descending) then title
425
+ unassignedTasks.sort((a, b) => {
426
+ const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
427
+ if (priorityDiff !== 0) return priorityDiff;
428
+ return a.title.localeCompare(b.title);
429
+ });
430
+
431
+ // Add unassigned group row (depth 1)
432
+ rows.push({
433
+ type: 'group',
434
+ id: unassignedId,
435
+ label: 'Unassigned',
436
+ status: status, // Use parent status for consistency
437
+ taskCount: unassignedTasks.length,
438
+ expanded: unassignedExpanded,
439
+ depth: 1,
440
+ });
441
+
442
+ // Add task rows if unassigned group is expanded (depth 1)
443
+ if (unassignedExpanded) {
444
+ unassignedTasks.forEach((task, index) => {
445
+ const isLast = index === unassignedTasks.length - 1;
446
+ rows.push({
447
+ type: 'task',
448
+ task,
449
+ isLast,
450
+ depth: 2,
451
+ });
452
+ });
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ return rows;
459
+ }
460
+
461
+ /**
462
+ * Build feature-status-grouped tree rows
463
+ * Groups features by their feature status, then nests tasks within each feature
464
+ *
465
+ * @param features - All features with their tasks
466
+ * @param expandedGroups - Set of expanded group IDs
467
+ * @returns TreeRow[] grouped by feature status → feature → tasks
468
+ */
469
+ function buildFeatureStatusGroupedRows(
470
+ features: FeatureWithTasks[],
471
+ expandedGroups: Set<string>
472
+ ): TreeRow[] {
473
+ const rows: TreeRow[] = [];
474
+
475
+ // Group features by their status
476
+ const featuresByStatus = new Map<string, FeatureWithTasks[]>();
477
+ for (const feature of features) {
478
+ const status = feature.status as string;
479
+ const group = featuresByStatus.get(status) || [];
480
+ group.push(feature);
481
+ featuresByStatus.set(status, group);
482
+ }
483
+
484
+ // Build rows in feature status order
485
+ for (const status of FEATURE_STATUS_ORDER) {
486
+ const statusFeatures = featuresByStatus.get(status) || [];
487
+ if (statusFeatures.length === 0) continue;
488
+
489
+ const statusGroupId = `fs:${status}`;
490
+ const statusExpanded = expandedGroups.has(statusGroupId);
491
+
492
+ // Count total tasks across all features in this status
493
+ const totalTasks = statusFeatures.reduce((sum, f) => sum + f.tasks.length, 0);
494
+
495
+ // Add status group row (depth 0)
496
+ rows.push({
497
+ type: 'group',
498
+ id: statusGroupId,
499
+ label: STATUS_DISPLAY_NAMES[status] || status,
500
+ status,
501
+ taskCount: statusFeatures.length,
502
+ expanded: statusExpanded,
503
+ depth: 0,
504
+ expandable: true,
505
+ });
506
+
507
+ if (statusExpanded) {
508
+ // Sort features by creation date (oldest first)
509
+ const sortedFeatures = [...statusFeatures].sort(
510
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
511
+ );
512
+
513
+ for (const feature of sortedFeatures) {
514
+ const compositeFeatureId = `fs:${status}:${feature.id}`;
515
+ const featureExpandable = feature.tasks.length > 0;
516
+ const featureExpanded = featureExpandable && expandedGroups.has(compositeFeatureId);
517
+
518
+ // Add feature group row (depth 1)
519
+ rows.push({
520
+ type: 'group',
521
+ id: compositeFeatureId,
522
+ label: feature.name,
523
+ status: feature.status,
524
+ taskCount: feature.tasks.length,
525
+ expanded: featureExpanded,
526
+ depth: 1,
527
+ expandable: featureExpandable,
528
+ featureId: feature.id,
529
+ });
530
+
531
+ // Add task rows if feature is expanded (depth 2)
532
+ if (featureExpanded) {
533
+ // Sort tasks by priority (descending) then title
534
+ const sortedTasks = [...feature.tasks].sort((a, b) => {
535
+ const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
536
+ if (priorityDiff !== 0) return priorityDiff;
537
+ return a.title.localeCompare(b.title);
538
+ });
539
+
540
+ sortedTasks.forEach((task, index) => {
541
+ rows.push({
542
+ type: 'task',
543
+ task,
544
+ isLast: index === sortedTasks.length - 1,
545
+ depth: 2,
546
+ });
547
+ });
548
+ }
549
+ }
550
+ }
551
+ }
552
+
553
+ return rows;
554
+ }
555
+
556
+ /**
557
+ * Hook for fetching a project tree with features and their tasks.
558
+ * Also includes unassigned tasks (tasks without a feature).
559
+ *
560
+ * @param projectId - The project ID
561
+ * @returns Project, features with nested tasks, unassigned tasks, task counts, status-grouped rows, loading/error states, and refresh function
562
+ *
563
+ * @example
564
+ * ```tsx
565
+ * const { project, features, unassignedTasks, taskCounts, statusGroupedRows, loading, error, refresh } = useProjectTree('proj-123');
566
+ * ```
567
+ */
568
+ export function useProjectTree(projectId: string, expandedGroups: Set<string> = new Set()) {
569
+ const { adapter } = useAdapter();
570
+ const [project, setProject] = useState<Project | null>(null);
571
+ const [features, setFeatures] = useState<FeatureWithTasks[]>([]);
572
+ const [unassignedTasks, setUnassignedTasks] = useState<Task[]>([]);
573
+ const [taskCounts, setTaskCounts] = useState<TaskCounts>({ total: 0, completed: 0 });
574
+ const [allTasks, setAllTasks] = useState<Task[]>([]);
575
+ const [loading, setLoading] = useState(true);
576
+ const [error, setError] = useState<string | null>(null);
577
+
578
+ const loadProjectTree = useCallback(async () => {
579
+ setLoading(true);
580
+ setError(null);
581
+
582
+ const [projectResult, featuresResult, tasksResult] = await Promise.all([
583
+ adapter.getProject(projectId),
584
+ adapter.getFeatures({ projectId }),
585
+ adapter.getTasks({ projectId }),
586
+ ]);
587
+
588
+ if (!projectResult.success) {
589
+ setError(projectResult.error);
590
+ setLoading(false);
591
+ return;
592
+ }
593
+
594
+ if (!featuresResult.success) {
595
+ setError(featuresResult.error);
596
+ setLoading(false);
597
+ return;
598
+ }
599
+
600
+ if (!tasksResult.success) {
601
+ setError(tasksResult.error);
602
+ setLoading(false);
603
+ return;
604
+ }
605
+
606
+ const projectData = projectResult.data;
607
+ const allFeatures = featuresResult.data;
608
+ const tasks = tasksResult.data;
609
+
610
+ // Sort features by creation date ascending (oldest first)
611
+ const sortedFeatures = [...allFeatures].sort(
612
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
613
+ );
614
+
615
+ // Build feature tree with nested tasks
616
+ const featuresWithTasks: FeatureWithTasks[] = sortedFeatures.map((feature) => ({
617
+ ...feature,
618
+ tasks: tasks.filter((task) => task.featureId === feature.id),
619
+ }));
620
+
621
+ // Find unassigned tasks (no featureId)
622
+ const unassigned = tasks.filter((task) => !task.featureId);
623
+
624
+ setProject(projectData);
625
+ setFeatures(featuresWithTasks);
626
+ setUnassignedTasks(unassigned);
627
+ setAllTasks(tasks);
628
+ setTaskCounts(calculateTaskCounts(tasks));
629
+ setLoading(false);
630
+ }, [adapter, projectId]);
631
+
632
+ useEffect(() => {
633
+ loadProjectTree();
634
+ }, [loadProjectTree]);
635
+
636
+ // Build status-grouped rows using useMemo to recalculate when data changes
637
+ const statusGroupedRows = useMemo(() => {
638
+ return buildStatusGroupedRows(allTasks, features, expandedGroups);
639
+ }, [allTasks, features, expandedGroups]);
640
+
641
+ // Build feature-status-grouped rows
642
+ const featureStatusGroupedRows = useMemo(() => {
643
+ return buildFeatureStatusGroupedRows(features, expandedGroups);
644
+ }, [features, expandedGroups]);
645
+
646
+ return {
647
+ project,
648
+ features,
649
+ unassignedTasks,
650
+ taskCounts,
651
+ statusGroupedRows,
652
+ featureStatusGroupedRows,
653
+ loading,
654
+ error,
655
+ refresh: loadProjectTree,
656
+ };
657
+ }
658
+
659
+ /**
660
+ * Hook for fetching board data (kanban columns) for a project.
661
+ * Returns tasks grouped by status with feature labels for each card.
662
+ */
663
+ export function useBoardData(projectId: string) {
664
+ const { adapter } = useAdapter();
665
+ const [columnsByStatus, setColumnsByStatus] = useState<Map<string, BoardCard[]>>(new Map());
666
+ const [loading, setLoading] = useState(true);
667
+ const [error, setError] = useState<string | null>(null);
668
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
669
+
670
+ const refresh = useCallback(() => {
671
+ setRefreshTrigger((prev) => prev + 1);
672
+ }, []);
673
+
674
+ const loadBoardData = useCallback(async () => {
675
+ setLoading(true);
676
+ setError(null);
677
+
678
+ const [tasksResult, featuresResult] = await Promise.all([
679
+ adapter.getTasks({ projectId }),
680
+ adapter.getFeatures({ projectId }),
681
+ ]);
682
+
683
+ if (!tasksResult.success) {
684
+ setError(tasksResult.error);
685
+ setLoading(false);
686
+ return;
687
+ }
688
+
689
+ if (!featuresResult.success) {
690
+ setError(featuresResult.error);
691
+ setLoading(false);
692
+ return;
693
+ }
694
+
695
+ const featureNameById = new Map(featuresResult.data.map((feature) => [feature.id, feature.name] as const));
696
+ const grouped = new Map<string, BoardCard[]>();
697
+
698
+ for (const status of BOARD_STATUS_ORDER) {
699
+ grouped.set(status, []);
700
+ }
701
+
702
+ for (const task of tasksResult.data) {
703
+ if (!grouped.has(task.status)) continue;
704
+
705
+ const featureName = task.featureId ? featureNameById.get(task.featureId) ?? null : null;
706
+ const boardTask: BoardTask = {
707
+ ...task,
708
+ featureName: featureName ?? undefined,
709
+ };
710
+
711
+ const card: BoardCard = {
712
+ id: task.id,
713
+ title: task.title,
714
+ featureName,
715
+ priority: task.priority,
716
+ task: boardTask,
717
+ };
718
+
719
+ grouped.get(task.status)?.push(card);
720
+ }
721
+
722
+ setColumnsByStatus(grouped);
723
+ setLoading(false);
724
+ }, [adapter, projectId]);
725
+
726
+ useEffect(() => {
727
+ loadBoardData();
728
+ }, [loadBoardData, refreshTrigger]);
729
+
730
+ return {
731
+ columnsByStatus,
732
+ loading,
733
+ error,
734
+ refresh,
735
+ };
736
+ }
737
+
738
+ /**
739
+ * Hook for fetching a single task with its sections and dependencies.
740
+ *
741
+ * @param id - The task ID
742
+ * @returns Task, sections, dependencies, loading/error states, and refresh function
743
+ *
744
+ * @example
745
+ * ```tsx
746
+ * const { task, sections, dependencies, loading, error, refresh } = useTask('task-123');
747
+ * ```
748
+ */
749
+ export function useTask(id: string) {
750
+ const { adapter } = useAdapter();
751
+ const [task, setTask] = useState<Task | null>(null);
752
+ const [sections, setSections] = useState<Section[]>([]);
753
+ const [dependencies, setDependencies] = useState<DependencyInfo | null>(null);
754
+ const [loading, setLoading] = useState(true);
755
+ const [error, setError] = useState<string | null>(null);
756
+
757
+ const loadTask = useCallback(async () => {
758
+ setLoading(true);
759
+ setError(null);
760
+
761
+ const [taskResult, sectionsResult, dependenciesResult] = await Promise.all([
762
+ adapter.getTask(id),
763
+ adapter.getSections('TASK' as EntityType, id),
764
+ adapter.getDependencies(id),
765
+ ]);
766
+
767
+ if (taskResult.success) {
768
+ setTask(taskResult.data);
769
+ } else {
770
+ setError(taskResult.error);
771
+ }
772
+
773
+ if (sectionsResult.success) {
774
+ setSections(sectionsResult.data);
775
+ } else if (!error) {
776
+ setError(sectionsResult.error);
777
+ }
778
+
779
+ if (dependenciesResult.success) {
780
+ setDependencies(dependenciesResult.data);
781
+ } else if (!error) {
782
+ setError(dependenciesResult.error);
783
+ }
784
+
785
+ setLoading(false);
786
+ }, [adapter, id, error]);
787
+
788
+ useEffect(() => {
789
+ loadTask();
790
+ }, [loadTask]);
791
+
792
+ return {
793
+ task,
794
+ sections,
795
+ dependencies,
796
+ loading,
797
+ error,
798
+ refresh: loadTask,
799
+ };
800
+ }
801
+
802
+ /**
803
+ * Hook for fetching a single feature with its tasks and sections.
804
+ *
805
+ * @param id - The feature ID
806
+ * @returns Feature, tasks, sections, loading/error states, and refresh function
807
+ *
808
+ * @example
809
+ * ```tsx
810
+ * const { feature, tasks, sections, loading, error, refresh } = useFeature('feat-123');
811
+ * ```
812
+ */
813
+ export function useFeature(id: string) {
814
+ const { adapter } = useAdapter();
815
+ const [feature, setFeature] = useState<FeatureWithTasks | null>(null);
816
+ const [sections, setSections] = useState<Section[]>([]);
817
+ const [loading, setLoading] = useState(true);
818
+ const [error, setError] = useState<string | null>(null);
819
+
820
+ const loadFeature = useCallback(async () => {
821
+ setLoading(true);
822
+ setError(null);
823
+
824
+ const [featureResult, tasksResult, sectionsResult] = await Promise.all([
825
+ adapter.getFeature(id),
826
+ adapter.getTasks({ featureId: id }),
827
+ adapter.getSections('FEATURE' as EntityType, id),
828
+ ]);
829
+
830
+ let loadError: string | null = null;
831
+
832
+ if (featureResult.success && featureResult.data) {
833
+ const featureWithTasks: FeatureWithTasks = {
834
+ ...featureResult.data,
835
+ tasks: tasksResult.success ? tasksResult.data : [],
836
+ };
837
+ setFeature(featureWithTasks);
838
+ } else if (!featureResult.success) {
839
+ loadError = featureResult.error;
840
+ }
841
+
842
+ if (sectionsResult.success) {
843
+ setSections(sectionsResult.data);
844
+ } else if (!loadError) {
845
+ loadError = sectionsResult.error;
846
+ }
847
+
848
+ setError(loadError);
849
+ setLoading(false);
850
+ }, [adapter, id]);
851
+
852
+ useEffect(() => {
853
+ loadFeature();
854
+ }, [loadFeature]);
855
+
856
+ return {
857
+ feature,
858
+ tasks: feature?.tasks || [],
859
+ sections,
860
+ loading,
861
+ error,
862
+ refresh: loadFeature,
863
+ };
864
+ }
865
+
866
+ /**
867
+ * Hook for performing full-text search across all entities.
868
+ * Use with useDebounce to avoid excessive API calls.
869
+ *
870
+ * @param query - The search query string
871
+ * @returns Search results with loading/error states
872
+ *
873
+ * @example
874
+ * ```tsx
875
+ * const [inputValue, setInputValue] = useState('');
876
+ * const debouncedQuery = useDebounce(inputValue, 300);
877
+ * const { results, loading, error } = useSearch(debouncedQuery);
878
+ * ```
879
+ */
880
+ export function useSearch(query: string) {
881
+ const { adapter } = useAdapter();
882
+ const [results, setResults] = useState<SearchResults | null>(null);
883
+ const [loading, setLoading] = useState(false);
884
+ const [error, setError] = useState<string | null>(null);
885
+
886
+ useEffect(() => {
887
+ // Don't search for empty queries
888
+ if (!query.trim()) {
889
+ setResults(null);
890
+ setLoading(false);
891
+ setError(null);
892
+ return;
893
+ }
894
+
895
+ const performSearch = async () => {
896
+ setLoading(true);
897
+ setError(null);
898
+
899
+ const result = await adapter.search(query);
900
+
901
+ if (result.success) {
902
+ setResults(result.data);
903
+ } else {
904
+ setError(result.error);
905
+ setResults(null);
906
+ }
907
+
908
+ setLoading(false);
909
+ };
910
+
911
+ performSearch();
912
+ }, [adapter, query]);
913
+
914
+ return {
915
+ results,
916
+ loading,
917
+ error,
918
+ };
919
+ }