@allpepper/task-orchestrator-tui 1.2.1 → 2.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.
- package/package.json +2 -2
- package/src/tui/components/status-actions.tsx +59 -21
- package/src/tui/screens/dashboard.tsx +9 -69
- package/src/tui/screens/feature-detail.tsx +77 -49
- package/src/tui/screens/project-detail.tsx +8 -65
- package/src/tui/screens/project-view.tsx +76 -80
- package/src/tui/screens/task-detail.tsx +46 -19
- package/src/ui/adapters/direct.ts +323 -94
- package/src/ui/adapters/types.ts +75 -72
- package/src/ui/hooks/use-data.ts +106 -193
- package/src/ui/hooks/use-feature-kanban.ts +45 -30
- package/src/ui/hooks/use-kanban.ts +35 -27
- package/src/ui/index.ts +1 -0
- package/src/ui/lib/colors.ts +27 -24
- package/src/ui/lib/markdown.tsx +5 -5
- package/src/ui/lib/types.ts +0 -1
- package/src/ui/themes/dark.ts +10 -28
- package/src/ui/themes/light.ts +16 -34
- package/src/ui/themes/types.ts +14 -26
package/src/ui/hooks/use-data.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
2
|
import { useAdapter } from '../context/adapter-context';
|
|
3
|
-
import type { Project, Task, Section, EntityType,
|
|
3
|
+
import type { Project, Task, Feature, Section, EntityType, Priority } from '@allpepper/task-orchestrator';
|
|
4
4
|
import type { FeatureWithTasks, ProjectOverview, SearchResults, DependencyInfo, BoardCard, BoardTask } from '../lib/types';
|
|
5
5
|
import type { TreeRow } from '../../tui/components/tree-view';
|
|
6
|
+
import { isCompletedStatus } from '../lib/colors';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Task counts structure
|
|
@@ -18,7 +19,7 @@ export interface TaskCounts {
|
|
|
18
19
|
export function calculateTaskCounts(tasks: Task[]): TaskCounts {
|
|
19
20
|
return {
|
|
20
21
|
total: tasks.length,
|
|
21
|
-
completed: tasks.filter(t => t.status
|
|
22
|
+
completed: tasks.filter(t => isCompletedStatus(t.status)).length,
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -32,7 +33,7 @@ export function calculateTaskCountsByProject(tasks: Task[]): Map<string, TaskCou
|
|
|
32
33
|
if (task.projectId) {
|
|
33
34
|
const counts = countsByProject.get(task.projectId) || { total: 0, completed: 0 };
|
|
34
35
|
counts.total++;
|
|
35
|
-
if (task.status
|
|
36
|
+
if (isCompletedStatus(task.status)) {
|
|
36
37
|
counts.completed++;
|
|
37
38
|
}
|
|
38
39
|
countsByProject.set(task.projectId, counts);
|
|
@@ -43,22 +44,43 @@ export function calculateTaskCountsByProject(tasks: Task[]): Map<string, TaskCou
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
|
-
*
|
|
47
|
+
* Feature counts structure
|
|
48
|
+
*/
|
|
49
|
+
export interface FeatureCounts {
|
|
50
|
+
total: number;
|
|
51
|
+
completed: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Group features by project ID and calculate counts for each
|
|
56
|
+
*/
|
|
57
|
+
export function calculateFeatureCountsByProject(featureList: Feature[]): Map<string, FeatureCounts> {
|
|
58
|
+
const countsByProject = new Map<string, FeatureCounts>();
|
|
59
|
+
|
|
60
|
+
for (const feature of featureList) {
|
|
61
|
+
if (feature.projectId) {
|
|
62
|
+
const counts = countsByProject.get(feature.projectId) || { total: 0, completed: 0 };
|
|
63
|
+
counts.total++;
|
|
64
|
+
if (isCompletedStatus(feature.status)) {
|
|
65
|
+
counts.completed++;
|
|
66
|
+
}
|
|
67
|
+
countsByProject.set(feature.projectId, counts);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return countsByProject;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Project with task and feature count information for dashboard display
|
|
47
76
|
*/
|
|
48
77
|
export interface ProjectWithCounts extends Project {
|
|
49
78
|
taskCounts: TaskCounts;
|
|
79
|
+
featureCounts: FeatureCounts;
|
|
50
80
|
}
|
|
51
81
|
|
|
52
82
|
/**
|
|
53
83
|
* 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
84
|
*/
|
|
63
85
|
export function useProjects() {
|
|
64
86
|
const { adapter } = useAdapter();
|
|
@@ -70,10 +92,10 @@ export function useProjects() {
|
|
|
70
92
|
setLoading(true);
|
|
71
93
|
setError(null);
|
|
72
94
|
|
|
73
|
-
|
|
74
|
-
const [projectsResult, tasksResult] = await Promise.all([
|
|
95
|
+
const [projectsResult, tasksResult, featuresResult] = await Promise.all([
|
|
75
96
|
adapter.getProjects(),
|
|
76
|
-
adapter.getTasks({ limit: 1000 }),
|
|
97
|
+
adapter.getTasks({ limit: 1000 }),
|
|
98
|
+
adapter.getFeatures({ limit: 1000 }),
|
|
77
99
|
]);
|
|
78
100
|
|
|
79
101
|
if (!projectsResult.success) {
|
|
@@ -82,15 +104,18 @@ export function useProjects() {
|
|
|
82
104
|
return;
|
|
83
105
|
}
|
|
84
106
|
|
|
85
|
-
// Build task counts by project using shared utility
|
|
86
107
|
const taskCountsByProject = tasksResult.success
|
|
87
108
|
? calculateTaskCountsByProject(tasksResult.data)
|
|
88
109
|
: new Map<string, TaskCounts>();
|
|
89
110
|
|
|
90
|
-
|
|
111
|
+
const featureCountsByProject = featuresResult.success
|
|
112
|
+
? calculateFeatureCountsByProject(featuresResult.data)
|
|
113
|
+
: new Map<string, FeatureCounts>();
|
|
114
|
+
|
|
91
115
|
const projectsWithCounts: ProjectWithCounts[] = projectsResult.data.map(project => ({
|
|
92
116
|
...project,
|
|
93
117
|
taskCounts: taskCountsByProject.get(project.id) || { total: 0, completed: 0 },
|
|
118
|
+
featureCounts: featureCountsByProject.get(project.id) || { total: 0, completed: 0 },
|
|
94
119
|
}));
|
|
95
120
|
|
|
96
121
|
setProjects(projectsWithCounts);
|
|
@@ -111,14 +136,6 @@ export function useProjects() {
|
|
|
111
136
|
|
|
112
137
|
/**
|
|
113
138
|
* 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
139
|
*/
|
|
123
140
|
export function useProjectOverview(id: string) {
|
|
124
141
|
const { adapter } = useAdapter();
|
|
@@ -145,7 +162,6 @@ export function useProjectOverview(id: string) {
|
|
|
145
162
|
if (overviewResult.success) {
|
|
146
163
|
setOverview(overviewResult.data);
|
|
147
164
|
} else if (!error) {
|
|
148
|
-
// Only set error if project fetch didn't already fail
|
|
149
165
|
setError(overviewResult.error);
|
|
150
166
|
}
|
|
151
167
|
|
|
@@ -166,16 +182,16 @@ export function useProjectOverview(id: string) {
|
|
|
166
182
|
}
|
|
167
183
|
|
|
168
184
|
/**
|
|
169
|
-
*
|
|
185
|
+
* v2 pipeline status order for task columns
|
|
186
|
+
* Tasks: NEW → ACTIVE → TO_BE_TESTED → READY_TO_PROD → CLOSED (+ WILL_NOT_IMPLEMENT)
|
|
170
187
|
*/
|
|
171
|
-
const
|
|
172
|
-
'
|
|
173
|
-
'
|
|
174
|
-
'
|
|
175
|
-
'
|
|
176
|
-
'
|
|
177
|
-
'
|
|
178
|
-
'CANCELLED' as TaskStatus,
|
|
188
|
+
const TASK_STATUS_ORDER: string[] = [
|
|
189
|
+
'NEW',
|
|
190
|
+
'ACTIVE',
|
|
191
|
+
'TO_BE_TESTED',
|
|
192
|
+
'READY_TO_PROD',
|
|
193
|
+
'CLOSED',
|
|
194
|
+
'WILL_NOT_IMPLEMENT',
|
|
179
195
|
];
|
|
180
196
|
|
|
181
197
|
/**
|
|
@@ -187,65 +203,43 @@ const PRIORITY_ORDER: Record<Priority, number> = {
|
|
|
187
203
|
LOW: 1,
|
|
188
204
|
};
|
|
189
205
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
'
|
|
195
|
-
'
|
|
206
|
+
/**
|
|
207
|
+
* Board status order (subset for kanban view)
|
|
208
|
+
*/
|
|
209
|
+
const BOARD_STATUS_ORDER: string[] = [
|
|
210
|
+
'NEW',
|
|
211
|
+
'ACTIVE',
|
|
212
|
+
'TO_BE_TESTED',
|
|
213
|
+
'READY_TO_PROD',
|
|
214
|
+
'CLOSED',
|
|
196
215
|
];
|
|
197
216
|
|
|
198
217
|
/**
|
|
199
|
-
* Display names for
|
|
218
|
+
* Display names for v2 pipeline statuses
|
|
200
219
|
*/
|
|
201
220
|
const STATUS_DISPLAY_NAMES: Record<string, string> = {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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',
|
|
221
|
+
NEW: 'New',
|
|
222
|
+
ACTIVE: 'Active',
|
|
223
|
+
TO_BE_TESTED: 'To Be Tested',
|
|
224
|
+
READY_TO_PROD: 'Ready to Prod',
|
|
225
|
+
CLOSED: 'Closed',
|
|
226
|
+
WILL_NOT_IMPLEMENT: 'Will Not Implement',
|
|
222
227
|
};
|
|
223
228
|
|
|
224
229
|
/**
|
|
225
|
-
* Feature status order for grouping features
|
|
230
|
+
* Feature status order for grouping features
|
|
231
|
+
* Features: NEW → ACTIVE → READY_TO_PROD → CLOSED (+ WILL_NOT_IMPLEMENT)
|
|
226
232
|
*/
|
|
227
|
-
const FEATURE_STATUS_ORDER:
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
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,
|
|
233
|
+
const FEATURE_STATUS_ORDER: string[] = [
|
|
234
|
+
'NEW',
|
|
235
|
+
'ACTIVE',
|
|
236
|
+
'READY_TO_PROD',
|
|
237
|
+
'CLOSED',
|
|
238
|
+
'WILL_NOT_IMPLEMENT',
|
|
239
239
|
];
|
|
240
240
|
|
|
241
241
|
/**
|
|
242
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
243
|
*/
|
|
250
244
|
function buildStatusGroupedRows(
|
|
251
245
|
tasks: Task[],
|
|
@@ -260,39 +254,14 @@ function buildStatusGroupedRows(
|
|
|
260
254
|
}
|
|
261
255
|
|
|
262
256
|
// Group tasks by status
|
|
263
|
-
const tasksByStatus = new Map<
|
|
257
|
+
const tasksByStatus = new Map<string, Task[]>();
|
|
264
258
|
for (const task of tasks) {
|
|
265
|
-
const
|
|
266
|
-
const group = tasksByStatus.get(status) || [];
|
|
259
|
+
const group = tasksByStatus.get(task.status) || [];
|
|
267
260
|
group.push(task);
|
|
268
|
-
tasksByStatus.set(status, group);
|
|
261
|
+
tasksByStatus.set(task.status, group);
|
|
269
262
|
}
|
|
270
263
|
|
|
271
|
-
|
|
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.
|
|
264
|
+
// Track which features have tasks
|
|
296
265
|
const featureHasTasks = new Set<string>();
|
|
297
266
|
for (const task of tasks) {
|
|
298
267
|
if (task.featureId) {
|
|
@@ -300,20 +269,33 @@ function buildStatusGroupedRows(
|
|
|
300
269
|
}
|
|
301
270
|
}
|
|
302
271
|
|
|
303
|
-
//
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
272
|
+
// Map feature status to the closest task status for empty features
|
|
273
|
+
const featureStatusToTaskBucket = (featureStatus: string): string => {
|
|
274
|
+
switch (featureStatus) {
|
|
275
|
+
case 'CLOSED':
|
|
276
|
+
case 'WILL_NOT_IMPLEMENT':
|
|
277
|
+
return featureStatus;
|
|
278
|
+
case 'ACTIVE':
|
|
279
|
+
case 'READY_TO_PROD':
|
|
280
|
+
return featureStatus;
|
|
281
|
+
case 'NEW':
|
|
282
|
+
default:
|
|
283
|
+
return 'NEW';
|
|
308
284
|
}
|
|
309
|
-
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Group empty features by their mapped status bucket
|
|
288
|
+
const featuresByStatus = new Map<string, FeatureWithTasks[]>();
|
|
289
|
+
for (const feature of features) {
|
|
290
|
+
if (featureHasTasks.has(feature.id)) continue;
|
|
291
|
+
const mappedStatus = featureStatusToTaskBucket(feature.status);
|
|
310
292
|
const group = featuresByStatus.get(mappedStatus) || [];
|
|
311
293
|
group.push(feature);
|
|
312
294
|
featuresByStatus.set(mappedStatus, group);
|
|
313
295
|
}
|
|
314
296
|
|
|
315
297
|
// Build rows in status order
|
|
316
|
-
for (const status of
|
|
298
|
+
for (const status of TASK_STATUS_ORDER) {
|
|
317
299
|
const statusTasks = tasksByStatus.get(status) || [];
|
|
318
300
|
const statusFeatures = featuresByStatus.get(status) || [];
|
|
319
301
|
if (statusTasks.length === 0 && statusFeatures.length === 0) continue;
|
|
@@ -322,7 +304,6 @@ function buildStatusGroupedRows(
|
|
|
322
304
|
const statusExpanded = expandedGroups.has(statusGroupId);
|
|
323
305
|
const statusExpandable = statusTasks.length > 0 || statusFeatures.length > 0;
|
|
324
306
|
|
|
325
|
-
// Add status group row (depth 0)
|
|
326
307
|
rows.push({
|
|
327
308
|
type: 'group',
|
|
328
309
|
id: statusGroupId,
|
|
@@ -334,9 +315,8 @@ function buildStatusGroupedRows(
|
|
|
334
315
|
expandable: statusExpandable,
|
|
335
316
|
});
|
|
336
317
|
|
|
337
|
-
// If status group is expanded, group tasks by feature
|
|
338
318
|
if (statusExpanded) {
|
|
339
|
-
// Group tasks by featureId
|
|
319
|
+
// Group tasks by featureId
|
|
340
320
|
const tasksByFeature = new Map<string | null, Task[]>();
|
|
341
321
|
for (const task of statusTasks) {
|
|
342
322
|
const featureId = task.featureId || null;
|
|
@@ -345,7 +325,7 @@ function buildStatusGroupedRows(
|
|
|
345
325
|
tasksByFeature.set(featureId, group);
|
|
346
326
|
}
|
|
347
327
|
|
|
348
|
-
// Sort tasks within each feature by priority
|
|
328
|
+
// Sort tasks within each feature by priority then title
|
|
349
329
|
for (const [_, featureTasks] of tasksByFeature.entries()) {
|
|
350
330
|
featureTasks.sort((a, b) => {
|
|
351
331
|
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
|
@@ -355,7 +335,6 @@ function buildStatusGroupedRows(
|
|
|
355
335
|
}
|
|
356
336
|
|
|
357
337
|
// Build feature sub-groups
|
|
358
|
-
// First, collect features that have tasks in this status (sorted by creation date)
|
|
359
338
|
const tasksByFeatureId = new Map<string, Task[]>();
|
|
360
339
|
for (const [featureId, featureTasks] of tasksByFeature.entries()) {
|
|
361
340
|
if (featureId !== null) {
|
|
@@ -363,7 +342,7 @@ function buildStatusGroupedRows(
|
|
|
363
342
|
}
|
|
364
343
|
}
|
|
365
344
|
|
|
366
|
-
// Sort
|
|
345
|
+
// Sort features by creation date
|
|
367
346
|
const statusFeatureMap = new Map<string, FeatureWithTasks>();
|
|
368
347
|
for (const feature of statusFeatures) {
|
|
369
348
|
statusFeatureMap.set(feature.id, feature);
|
|
@@ -379,7 +358,6 @@ function buildStatusGroupedRows(
|
|
|
379
358
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
380
359
|
);
|
|
381
360
|
|
|
382
|
-
// Add feature sub-group rows
|
|
383
361
|
for (const feature of sortedStatusFeatures) {
|
|
384
362
|
const featureId = feature.id;
|
|
385
363
|
const featureTasks = tasksByFeatureId.get(featureId) || [];
|
|
@@ -387,7 +365,6 @@ function buildStatusGroupedRows(
|
|
|
387
365
|
const featureExpandable = featureTasks.length > 0;
|
|
388
366
|
const featureExpanded = featureExpandable && expandedGroups.has(compositeFeatureId);
|
|
389
367
|
|
|
390
|
-
// Add feature group row (depth 1)
|
|
391
368
|
rows.push({
|
|
392
369
|
type: 'group',
|
|
393
370
|
id: compositeFeatureId,
|
|
@@ -400,7 +377,6 @@ function buildStatusGroupedRows(
|
|
|
400
377
|
featureId,
|
|
401
378
|
});
|
|
402
379
|
|
|
403
|
-
// Add task rows if feature is expanded (depth 1)
|
|
404
380
|
if (featureExpanded) {
|
|
405
381
|
featureTasks.forEach((task, index) => {
|
|
406
382
|
const isLast = index === featureTasks.length - 1;
|
|
@@ -409,37 +385,33 @@ function buildStatusGroupedRows(
|
|
|
409
385
|
task,
|
|
410
386
|
isLast,
|
|
411
387
|
depth: 2,
|
|
412
|
-
// No featureName needed - tasks are nested under their feature
|
|
413
388
|
});
|
|
414
389
|
});
|
|
415
390
|
}
|
|
416
391
|
}
|
|
417
392
|
|
|
418
|
-
// Add unassigned tasks
|
|
393
|
+
// Add unassigned tasks
|
|
419
394
|
const unassignedTasks = tasksByFeature.get(null);
|
|
420
395
|
if (unassignedTasks && unassignedTasks.length > 0) {
|
|
421
396
|
const unassignedId = `${status}:unassigned`;
|
|
422
397
|
const unassignedExpanded = expandedGroups.has(unassignedId);
|
|
423
398
|
|
|
424
|
-
// Sort unassigned tasks by priority (descending) then title
|
|
425
399
|
unassignedTasks.sort((a, b) => {
|
|
426
400
|
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
|
427
401
|
if (priorityDiff !== 0) return priorityDiff;
|
|
428
402
|
return a.title.localeCompare(b.title);
|
|
429
403
|
});
|
|
430
404
|
|
|
431
|
-
// Add unassigned group row (depth 1)
|
|
432
405
|
rows.push({
|
|
433
406
|
type: 'group',
|
|
434
407
|
id: unassignedId,
|
|
435
408
|
label: 'Unassigned',
|
|
436
|
-
status: status,
|
|
409
|
+
status: status,
|
|
437
410
|
taskCount: unassignedTasks.length,
|
|
438
411
|
expanded: unassignedExpanded,
|
|
439
412
|
depth: 1,
|
|
440
413
|
});
|
|
441
414
|
|
|
442
|
-
// Add task rows if unassigned group is expanded (depth 1)
|
|
443
415
|
if (unassignedExpanded) {
|
|
444
416
|
unassignedTasks.forEach((task, index) => {
|
|
445
417
|
const isLast = index === unassignedTasks.length - 1;
|
|
@@ -460,11 +432,6 @@ function buildStatusGroupedRows(
|
|
|
460
432
|
|
|
461
433
|
/**
|
|
462
434
|
* 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
435
|
*/
|
|
469
436
|
function buildFeatureStatusGroupedRows(
|
|
470
437
|
features: FeatureWithTasks[],
|
|
@@ -472,16 +439,13 @@ function buildFeatureStatusGroupedRows(
|
|
|
472
439
|
): TreeRow[] {
|
|
473
440
|
const rows: TreeRow[] = [];
|
|
474
441
|
|
|
475
|
-
// Group features by their status
|
|
476
442
|
const featuresByStatus = new Map<string, FeatureWithTasks[]>();
|
|
477
443
|
for (const feature of features) {
|
|
478
|
-
const
|
|
479
|
-
const group = featuresByStatus.get(status) || [];
|
|
444
|
+
const group = featuresByStatus.get(feature.status) || [];
|
|
480
445
|
group.push(feature);
|
|
481
|
-
featuresByStatus.set(status, group);
|
|
446
|
+
featuresByStatus.set(feature.status, group);
|
|
482
447
|
}
|
|
483
448
|
|
|
484
|
-
// Build rows in feature status order
|
|
485
449
|
for (const status of FEATURE_STATUS_ORDER) {
|
|
486
450
|
const statusFeatures = featuresByStatus.get(status) || [];
|
|
487
451
|
if (statusFeatures.length === 0) continue;
|
|
@@ -489,10 +453,6 @@ function buildFeatureStatusGroupedRows(
|
|
|
489
453
|
const statusGroupId = `fs:${status}`;
|
|
490
454
|
const statusExpanded = expandedGroups.has(statusGroupId);
|
|
491
455
|
|
|
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
456
|
rows.push({
|
|
497
457
|
type: 'group',
|
|
498
458
|
id: statusGroupId,
|
|
@@ -505,7 +465,6 @@ function buildFeatureStatusGroupedRows(
|
|
|
505
465
|
});
|
|
506
466
|
|
|
507
467
|
if (statusExpanded) {
|
|
508
|
-
// Sort features by creation date (oldest first)
|
|
509
468
|
const sortedFeatures = [...statusFeatures].sort(
|
|
510
469
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
511
470
|
);
|
|
@@ -515,7 +474,6 @@ function buildFeatureStatusGroupedRows(
|
|
|
515
474
|
const featureExpandable = feature.tasks.length > 0;
|
|
516
475
|
const featureExpanded = featureExpandable && expandedGroups.has(compositeFeatureId);
|
|
517
476
|
|
|
518
|
-
// Add feature group row (depth 1)
|
|
519
477
|
rows.push({
|
|
520
478
|
type: 'group',
|
|
521
479
|
id: compositeFeatureId,
|
|
@@ -528,9 +486,7 @@ function buildFeatureStatusGroupedRows(
|
|
|
528
486
|
featureId: feature.id,
|
|
529
487
|
});
|
|
530
488
|
|
|
531
|
-
// Add task rows if feature is expanded (depth 2)
|
|
532
489
|
if (featureExpanded) {
|
|
533
|
-
// Sort tasks by priority (descending) then title
|
|
534
490
|
const sortedTasks = [...feature.tasks].sort((a, b) => {
|
|
535
491
|
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
|
536
492
|
if (priorityDiff !== 0) return priorityDiff;
|
|
@@ -555,15 +511,6 @@ function buildFeatureStatusGroupedRows(
|
|
|
555
511
|
|
|
556
512
|
/**
|
|
557
513
|
* 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
514
|
*/
|
|
568
515
|
export function useProjectTree(projectId: string, expandedGroups: Set<string> = new Set()) {
|
|
569
516
|
const { adapter } = useAdapter();
|
|
@@ -607,18 +554,15 @@ export function useProjectTree(projectId: string, expandedGroups: Set<string> =
|
|
|
607
554
|
const allFeatures = featuresResult.data;
|
|
608
555
|
const tasks = tasksResult.data;
|
|
609
556
|
|
|
610
|
-
// Sort features by creation date ascending (oldest first)
|
|
611
557
|
const sortedFeatures = [...allFeatures].sort(
|
|
612
558
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
613
559
|
);
|
|
614
560
|
|
|
615
|
-
// Build feature tree with nested tasks
|
|
616
561
|
const featuresWithTasks: FeatureWithTasks[] = sortedFeatures.map((feature) => ({
|
|
617
562
|
...feature,
|
|
618
563
|
tasks: tasks.filter((task) => task.featureId === feature.id),
|
|
619
564
|
}));
|
|
620
565
|
|
|
621
|
-
// Find unassigned tasks (no featureId)
|
|
622
566
|
const unassigned = tasks.filter((task) => !task.featureId);
|
|
623
567
|
|
|
624
568
|
setProject(projectData);
|
|
@@ -633,12 +577,10 @@ export function useProjectTree(projectId: string, expandedGroups: Set<string> =
|
|
|
633
577
|
loadProjectTree();
|
|
634
578
|
}, [loadProjectTree]);
|
|
635
579
|
|
|
636
|
-
// Build status-grouped rows using useMemo to recalculate when data changes
|
|
637
580
|
const statusGroupedRows = useMemo(() => {
|
|
638
581
|
return buildStatusGroupedRows(allTasks, features, expandedGroups);
|
|
639
582
|
}, [allTasks, features, expandedGroups]);
|
|
640
583
|
|
|
641
|
-
// Build feature-status-grouped rows
|
|
642
584
|
const featureStatusGroupedRows = useMemo(() => {
|
|
643
585
|
return buildFeatureStatusGroupedRows(features, expandedGroups);
|
|
644
586
|
}, [features, expandedGroups]);
|
|
@@ -658,7 +600,6 @@ export function useProjectTree(projectId: string, expandedGroups: Set<string> =
|
|
|
658
600
|
|
|
659
601
|
/**
|
|
660
602
|
* Hook for fetching board data (kanban columns) for a project.
|
|
661
|
-
* Returns tasks grouped by status with feature labels for each card.
|
|
662
603
|
*/
|
|
663
604
|
export function useBoardData(projectId: string) {
|
|
664
605
|
const { adapter } = useAdapter();
|
|
@@ -737,14 +678,6 @@ export function useBoardData(projectId: string) {
|
|
|
737
678
|
|
|
738
679
|
/**
|
|
739
680
|
* 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
681
|
*/
|
|
749
682
|
export function useTask(id: string) {
|
|
750
683
|
const { adapter } = useAdapter();
|
|
@@ -801,14 +734,6 @@ export function useTask(id: string) {
|
|
|
801
734
|
|
|
802
735
|
/**
|
|
803
736
|
* 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
737
|
*/
|
|
813
738
|
export function useFeature(id: string) {
|
|
814
739
|
const { adapter } = useAdapter();
|
|
@@ -865,17 +790,6 @@ export function useFeature(id: string) {
|
|
|
865
790
|
|
|
866
791
|
/**
|
|
867
792
|
* 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
793
|
*/
|
|
880
794
|
export function useSearch(query: string) {
|
|
881
795
|
const { adapter } = useAdapter();
|
|
@@ -884,7 +798,6 @@ export function useSearch(query: string) {
|
|
|
884
798
|
const [error, setError] = useState<string | null>(null);
|
|
885
799
|
|
|
886
800
|
useEffect(() => {
|
|
887
|
-
// Don't search for empty queries
|
|
888
801
|
if (!query.trim()) {
|
|
889
802
|
setResults(null);
|
|
890
803
|
setLoading(false);
|