@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.
- package/README.md +78 -0
- package/package.json +54 -0
- package/src/tui/app.tsx +308 -0
- package/src/tui/components/column-filter-bar.tsx +52 -0
- package/src/tui/components/confirm-dialog.tsx +45 -0
- package/src/tui/components/dependency-list.tsx +115 -0
- package/src/tui/components/empty-state.tsx +28 -0
- package/src/tui/components/entity-table.tsx +120 -0
- package/src/tui/components/error-message.tsx +41 -0
- package/src/tui/components/feature-kanban-card.tsx +216 -0
- package/src/tui/components/footer.tsx +34 -0
- package/src/tui/components/form-dialog.tsx +338 -0
- package/src/tui/components/header.tsx +54 -0
- package/src/tui/components/index.ts +16 -0
- package/src/tui/components/kanban-board.tsx +335 -0
- package/src/tui/components/kanban-card.tsx +70 -0
- package/src/tui/components/kanban-column.tsx +173 -0
- package/src/tui/components/priority-badge.tsx +16 -0
- package/src/tui/components/section-list.tsx +96 -0
- package/src/tui/components/status-actions.tsx +87 -0
- package/src/tui/components/status-badge.tsx +22 -0
- package/src/tui/components/tree-view.tsx +295 -0
- package/src/tui/components/view-mode-chips.tsx +23 -0
- package/src/tui/index.tsx +33 -0
- package/src/tui/screens/dashboard.tsx +248 -0
- package/src/tui/screens/feature-detail.tsx +312 -0
- package/src/tui/screens/index.ts +6 -0
- package/src/tui/screens/kanban-view.tsx +251 -0
- package/src/tui/screens/project-detail.tsx +305 -0
- package/src/tui/screens/project-view.tsx +498 -0
- package/src/tui/screens/search.tsx +257 -0
- package/src/tui/screens/task-detail.tsx +294 -0
- package/src/ui/adapters/direct.ts +429 -0
- package/src/ui/adapters/index.ts +14 -0
- package/src/ui/adapters/types.ts +269 -0
- package/src/ui/context/adapter-context.tsx +31 -0
- package/src/ui/context/theme-context.tsx +43 -0
- package/src/ui/hooks/index.ts +20 -0
- package/src/ui/hooks/use-data.ts +919 -0
- package/src/ui/hooks/use-debounce.ts +37 -0
- package/src/ui/hooks/use-feature-kanban.ts +151 -0
- package/src/ui/hooks/use-kanban.ts +96 -0
- package/src/ui/hooks/use-navigation.tsx +94 -0
- package/src/ui/index.ts +73 -0
- package/src/ui/lib/colors.ts +79 -0
- package/src/ui/lib/format.ts +114 -0
- package/src/ui/lib/types.ts +157 -0
- package/src/ui/themes/dark.ts +63 -0
- package/src/ui/themes/light.ts +63 -0
- package/src/ui/themes/types.ts +71 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Debounces a value by delaying updates until the value has stopped changing
|
|
5
|
+
* for the specified delay period.
|
|
6
|
+
*
|
|
7
|
+
* @param value - The value to debounce
|
|
8
|
+
* @param delay - The delay in milliseconds
|
|
9
|
+
* @returns The debounced value
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const searchQuery = useDebounce(inputValue, 500);
|
|
14
|
+
*
|
|
15
|
+
* useEffect(() => {
|
|
16
|
+
* // This will only run 500ms after user stops typing
|
|
17
|
+
* performSearch(searchQuery);
|
|
18
|
+
* }, [searchQuery]);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function useDebounce<T>(value: T, delay: number): T {
|
|
22
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
// Set up a timer to update the debounced value after the delay
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
setDebouncedValue(value);
|
|
28
|
+
}, delay);
|
|
29
|
+
|
|
30
|
+
// Clean up the timer if value changes before delay completes
|
|
31
|
+
return () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
};
|
|
34
|
+
}, [value, delay]);
|
|
35
|
+
|
|
36
|
+
return debouncedValue;
|
|
37
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import { useAdapter } from '../context/adapter-context';
|
|
3
|
+
import type { Feature, FeatureStatus } from 'task-orchestrator-bun/src/domain/types';
|
|
4
|
+
import type { BoardFeature, FeatureBoardColumn } from '../lib/types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Feature-status columns for the feature-based Kanban board
|
|
8
|
+
*/
|
|
9
|
+
export const FEATURE_KANBAN_STATUSES = [
|
|
10
|
+
{ id: 'draft', title: 'Draft', status: 'DRAFT' },
|
|
11
|
+
{ id: 'planning', title: 'Planning', status: 'PLANNING' },
|
|
12
|
+
{ id: 'in-development', title: 'In Development', status: 'IN_DEVELOPMENT' },
|
|
13
|
+
{ id: 'testing', title: 'Testing', status: 'TESTING' },
|
|
14
|
+
{ id: 'validating', title: 'Validating', status: 'VALIDATING' },
|
|
15
|
+
{ id: 'pending-review', title: 'Pending Review', status: 'PENDING_REVIEW' },
|
|
16
|
+
{ id: 'blocked', title: 'Blocked', status: 'BLOCKED' },
|
|
17
|
+
{ id: 'on-hold', title: 'On Hold', status: 'ON_HOLD' },
|
|
18
|
+
{ id: 'deployed', title: 'Deployed', status: 'DEPLOYED' },
|
|
19
|
+
{ id: 'completed', title: 'Completed', status: 'COMPLETED' },
|
|
20
|
+
{ id: 'archived', title: 'Archived', status: 'ARCHIVED' },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
interface UseFeatureKanbanReturn {
|
|
24
|
+
columns: FeatureBoardColumn[];
|
|
25
|
+
loading: boolean;
|
|
26
|
+
error: string | null;
|
|
27
|
+
refresh: () => void;
|
|
28
|
+
moveFeature: (featureId: string, newStatus: string) => Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hook for managing the feature-based Kanban board.
|
|
33
|
+
*
|
|
34
|
+
* Fetches features and tasks for a project, groups features by their status
|
|
35
|
+
* into columns, and nests tasks within each feature card.
|
|
36
|
+
*/
|
|
37
|
+
export function useFeatureKanban(projectId: string): UseFeatureKanbanReturn {
|
|
38
|
+
const { adapter } = useAdapter();
|
|
39
|
+
const [features, setFeatures] = useState<Feature[]>([]);
|
|
40
|
+
const [tasksByFeature, setTasksByFeature] = useState<Map<string, import('task-orchestrator-bun/src/domain/types').Task[]>>(new Map());
|
|
41
|
+
const [loading, setLoading] = useState(true);
|
|
42
|
+
const [error, setError] = useState<string | null>(null);
|
|
43
|
+
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
44
|
+
|
|
45
|
+
const refresh = useCallback(() => {
|
|
46
|
+
setRefreshTrigger((prev) => prev + 1);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const loadData = useCallback(async () => {
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError(null);
|
|
52
|
+
|
|
53
|
+
const [featuresResult, tasksResult] = await Promise.all([
|
|
54
|
+
adapter.getFeatures({ projectId }),
|
|
55
|
+
adapter.getTasks({ projectId }),
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
if (!featuresResult.success) {
|
|
59
|
+
setError(featuresResult.error);
|
|
60
|
+
setLoading(false);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!tasksResult.success) {
|
|
65
|
+
setError(tasksResult.error);
|
|
66
|
+
setLoading(false);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setFeatures(featuresResult.data);
|
|
71
|
+
|
|
72
|
+
// Group tasks by featureId
|
|
73
|
+
const grouped = new Map<string, import('task-orchestrator-bun/src/domain/types').Task[]>();
|
|
74
|
+
for (const task of tasksResult.data) {
|
|
75
|
+
if (task.featureId) {
|
|
76
|
+
const list = grouped.get(task.featureId) || [];
|
|
77
|
+
list.push(task);
|
|
78
|
+
grouped.set(task.featureId, list);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
setTasksByFeature(grouped);
|
|
82
|
+
setLoading(false);
|
|
83
|
+
}, [adapter, projectId]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
loadData();
|
|
87
|
+
}, [loadData, refreshTrigger]);
|
|
88
|
+
|
|
89
|
+
const columns = useMemo<FeatureBoardColumn[]>(() => {
|
|
90
|
+
return FEATURE_KANBAN_STATUSES.map((statusDef) => {
|
|
91
|
+
const columnFeatures = features
|
|
92
|
+
.filter((f) => f.status === statusDef.status)
|
|
93
|
+
.map((f): BoardFeature => {
|
|
94
|
+
const tasks = tasksByFeature.get(f.id) || [];
|
|
95
|
+
return {
|
|
96
|
+
...f,
|
|
97
|
+
tasks,
|
|
98
|
+
taskCounts: {
|
|
99
|
+
total: tasks.length,
|
|
100
|
+
completed: tasks.filter((t) => t.status === 'COMPLETED' || t.status === 'DEPLOYED').length,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: statusDef.id,
|
|
107
|
+
title: statusDef.title,
|
|
108
|
+
status: statusDef.status,
|
|
109
|
+
features: columnFeatures,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}, [features, tasksByFeature]);
|
|
113
|
+
|
|
114
|
+
const moveFeature = useCallback(
|
|
115
|
+
async (featureId: string, newStatus: string): Promise<boolean> => {
|
|
116
|
+
// Find feature in current data
|
|
117
|
+
const feature = features.find((f) => f.id === featureId);
|
|
118
|
+
if (!feature) {
|
|
119
|
+
refresh();
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const result = await adapter.setFeatureStatus(
|
|
125
|
+
featureId,
|
|
126
|
+
newStatus as FeatureStatus,
|
|
127
|
+
feature.version
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (result.success) {
|
|
131
|
+
refresh();
|
|
132
|
+
return true;
|
|
133
|
+
} else {
|
|
134
|
+
refresh();
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
} catch (_err) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[adapter, features, refresh]
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
columns,
|
|
146
|
+
loading,
|
|
147
|
+
error,
|
|
148
|
+
refresh,
|
|
149
|
+
moveFeature,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useMemo, useCallback } from 'react';
|
|
2
|
+
import { useAdapter } from '../context/adapter-context';
|
|
3
|
+
import type { Task } from 'task-orchestrator-bun/src/domain/types';
|
|
4
|
+
import type { BoardColumn } from '../lib/types';
|
|
5
|
+
import { useBoardData } from './use-data';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Standard Kanban column definitions
|
|
9
|
+
*/
|
|
10
|
+
const KANBAN_STATUSES = [
|
|
11
|
+
{ id: 'pending', title: 'Pending', status: 'PENDING' },
|
|
12
|
+
{ id: 'in-progress', title: 'In Progress', status: 'IN_PROGRESS' },
|
|
13
|
+
{ id: 'in-review', title: 'In Review', status: 'IN_REVIEW' },
|
|
14
|
+
{ id: 'blocked', title: 'Blocked', status: 'BLOCKED' },
|
|
15
|
+
{ id: 'completed', title: 'Completed', status: 'COMPLETED' },
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
interface UseKanbanReturn {
|
|
19
|
+
columns: BoardColumn[];
|
|
20
|
+
loading: boolean;
|
|
21
|
+
error: string | null;
|
|
22
|
+
refresh: () => void;
|
|
23
|
+
moveTask: (taskId: string, newStatus: string) => Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook for managing Kanban board state
|
|
28
|
+
*
|
|
29
|
+
* Fetches tasks for a project and organizes them into Kanban columns by status.
|
|
30
|
+
* Provides functionality to move tasks between columns with optimistic updates.
|
|
31
|
+
*
|
|
32
|
+
* @param projectId - The project ID to fetch tasks for
|
|
33
|
+
* @returns Kanban board state and operations
|
|
34
|
+
*/
|
|
35
|
+
export function useKanban(projectId: string): UseKanbanReturn {
|
|
36
|
+
const { adapter } = useAdapter();
|
|
37
|
+
const { columnsByStatus, loading, error, refresh } = useBoardData(projectId);
|
|
38
|
+
|
|
39
|
+
const columns = useMemo<BoardColumn[]>(() => (
|
|
40
|
+
KANBAN_STATUSES.map((statusDef) => ({
|
|
41
|
+
id: statusDef.id,
|
|
42
|
+
title: statusDef.title,
|
|
43
|
+
status: statusDef.status,
|
|
44
|
+
tasks: (columnsByStatus.get(statusDef.status) || []).map((card) => card.task),
|
|
45
|
+
}))
|
|
46
|
+
), [columnsByStatus]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Move a task to a new status
|
|
50
|
+
*
|
|
51
|
+
* @param taskId - ID of the task to move
|
|
52
|
+
* @param newStatus - New status to assign
|
|
53
|
+
* @returns true if successful, false otherwise
|
|
54
|
+
*/
|
|
55
|
+
const moveTask = useCallback(
|
|
56
|
+
async (taskId: string, newStatus: string): Promise<boolean> => {
|
|
57
|
+
// Find the task in current columns
|
|
58
|
+
let task: Task | undefined;
|
|
59
|
+
for (const column of columns) {
|
|
60
|
+
task = column.tasks.find((t) => t.id === taskId);
|
|
61
|
+
if (task) break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!task) {
|
|
65
|
+
// Board may be stale; ask the caller to refresh and retry.
|
|
66
|
+
refresh();
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Call adapter to update task status
|
|
72
|
+
const result = await adapter.setTaskStatus(taskId, newStatus as any, task.version);
|
|
73
|
+
|
|
74
|
+
if (result.success) {
|
|
75
|
+
// Refresh the board on success
|
|
76
|
+
refresh();
|
|
77
|
+
return true;
|
|
78
|
+
} else {
|
|
79
|
+
refresh();
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
} catch (_err) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[adapter, columns, refresh]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
columns,
|
|
91
|
+
loading,
|
|
92
|
+
error,
|
|
93
|
+
refresh,
|
|
94
|
+
moveTask,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Screen, type NavigationState } from '../lib/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Navigation context interface
|
|
6
|
+
*/
|
|
7
|
+
interface NavigationContextValue {
|
|
8
|
+
screen: Screen;
|
|
9
|
+
params: Record<string, unknown>;
|
|
10
|
+
push(screen: Screen, params?: Record<string, unknown>): void;
|
|
11
|
+
pop(): void;
|
|
12
|
+
replace(screen: Screen, params?: Record<string, unknown>): void;
|
|
13
|
+
reset(): void;
|
|
14
|
+
canGoBack: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Navigation context for TUI routing
|
|
19
|
+
*/
|
|
20
|
+
const NavigationContext = createContext<NavigationContextValue | undefined>(undefined);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Provider props
|
|
24
|
+
*/
|
|
25
|
+
interface NavigationProviderProps {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Navigation provider that manages the screen stack
|
|
31
|
+
*/
|
|
32
|
+
export function NavigationProvider({ children }: NavigationProviderProps) {
|
|
33
|
+
const [state, setState] = useState<NavigationState>({
|
|
34
|
+
stack: [{ screen: Screen.Dashboard, params: {} }],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const currentStackItem = state.stack[state.stack.length - 1]!;
|
|
38
|
+
|
|
39
|
+
const push = (screen: Screen, params: Record<string, unknown> = {}) => {
|
|
40
|
+
setState((prev: NavigationState) => ({
|
|
41
|
+
stack: [...prev.stack, { screen, params }],
|
|
42
|
+
}));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const pop = () => {
|
|
46
|
+
setState((prev: NavigationState) => {
|
|
47
|
+
if (prev.stack.length <= 1) {
|
|
48
|
+
return prev;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
stack: prev.stack.slice(0, -1),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const replace = (screen: Screen, params: Record<string, unknown> = {}) => {
|
|
57
|
+
setState((prev: NavigationState) => ({
|
|
58
|
+
stack: [...prev.stack.slice(0, -1), { screen, params }],
|
|
59
|
+
}));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const reset = () => {
|
|
63
|
+
setState({
|
|
64
|
+
stack: [{ screen: Screen.Dashboard, params: {} }],
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const value: NavigationContextValue = {
|
|
69
|
+
screen: currentStackItem.screen,
|
|
70
|
+
params: currentStackItem.params,
|
|
71
|
+
push,
|
|
72
|
+
pop,
|
|
73
|
+
replace,
|
|
74
|
+
reset,
|
|
75
|
+
canGoBack: state.stack.length > 1,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<NavigationContext.Provider value={value}>
|
|
80
|
+
{children}
|
|
81
|
+
</NavigationContext.Provider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Hook to access navigation state and actions
|
|
87
|
+
*/
|
|
88
|
+
export function useNavigation(): NavigationContextValue {
|
|
89
|
+
const context = useContext(NavigationContext);
|
|
90
|
+
if (!context) {
|
|
91
|
+
throw new Error('useNavigation must be used within a NavigationProvider');
|
|
92
|
+
}
|
|
93
|
+
return context;
|
|
94
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Orchestrator UI - Shared Layer
|
|
3
|
+
*
|
|
4
|
+
* Platform-agnostic foundation for TUI, web, and Electron renderers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Themes
|
|
8
|
+
export { darkTheme } from './themes/dark';
|
|
9
|
+
export { lightTheme } from './themes/light';
|
|
10
|
+
export type { Theme, TaskCounts, StatusKey } from './themes/types';
|
|
11
|
+
|
|
12
|
+
// Lib - Types
|
|
13
|
+
export {
|
|
14
|
+
Screen,
|
|
15
|
+
type ScreenParams,
|
|
16
|
+
type NavigationState,
|
|
17
|
+
type Shortcut,
|
|
18
|
+
type TreeNode,
|
|
19
|
+
type FeatureWithTasks,
|
|
20
|
+
type BoardColumn,
|
|
21
|
+
type SearchResults,
|
|
22
|
+
type DependencyInfo,
|
|
23
|
+
type ProjectOverview,
|
|
24
|
+
type FeatureOverview,
|
|
25
|
+
} from './lib/types';
|
|
26
|
+
|
|
27
|
+
// Lib - Format utilities
|
|
28
|
+
export {
|
|
29
|
+
timeAgo,
|
|
30
|
+
truncateId,
|
|
31
|
+
truncateText,
|
|
32
|
+
pluralize,
|
|
33
|
+
formatTaskCount,
|
|
34
|
+
formatCount,
|
|
35
|
+
formatStatus,
|
|
36
|
+
formatPriority,
|
|
37
|
+
} from './lib/format';
|
|
38
|
+
|
|
39
|
+
// Lib - Color utilities
|
|
40
|
+
export {
|
|
41
|
+
getStatusColor,
|
|
42
|
+
getPriorityColor,
|
|
43
|
+
getPriorityDots,
|
|
44
|
+
getSemanticColor,
|
|
45
|
+
isActiveStatus,
|
|
46
|
+
isBlockedStatus,
|
|
47
|
+
isCompletedStatus,
|
|
48
|
+
} from './lib/colors';
|
|
49
|
+
|
|
50
|
+
// Adapters
|
|
51
|
+
export type {
|
|
52
|
+
DataAdapter,
|
|
53
|
+
Result,
|
|
54
|
+
SearchParams,
|
|
55
|
+
FeatureSearchParams,
|
|
56
|
+
TaskSearchParams,
|
|
57
|
+
} from './adapters/types';
|
|
58
|
+
export { DirectAdapter } from './adapters/direct';
|
|
59
|
+
|
|
60
|
+
// Context
|
|
61
|
+
export { ThemeProvider, useTheme } from './context/theme-context';
|
|
62
|
+
export { AdapterProvider, useAdapter } from './context/adapter-context';
|
|
63
|
+
|
|
64
|
+
// Hooks
|
|
65
|
+
export { NavigationProvider, useNavigation } from './hooks/use-navigation';
|
|
66
|
+
export {
|
|
67
|
+
useProjects,
|
|
68
|
+
useProjectOverview,
|
|
69
|
+
useProjectTree,
|
|
70
|
+
useTask,
|
|
71
|
+
useSearch,
|
|
72
|
+
} from './hooks/use-data';
|
|
73
|
+
export { useDebounce } from './hooks/use-debounce';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Theme, StatusKey } from '../themes/types';
|
|
2
|
+
import type { Priority } from 'task-orchestrator-bun/src/domain/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get the color for a status value
|
|
6
|
+
* Falls back to muted color if status not found
|
|
7
|
+
*/
|
|
8
|
+
export function getStatusColor(status: string, theme: Theme): string {
|
|
9
|
+
const color = theme.colors.status[status as StatusKey];
|
|
10
|
+
return color ?? theme.colors.muted;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the color for a priority value
|
|
15
|
+
*/
|
|
16
|
+
export function getPriorityColor(priority: Priority, theme: Theme): string {
|
|
17
|
+
return theme.colors.priority[priority] ?? theme.colors.muted;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get priority dots visual indicator
|
|
22
|
+
* HIGH: ●●● (3 filled)
|
|
23
|
+
* MEDIUM: ●●○ (2 filled)
|
|
24
|
+
* LOW: ●○○ (1 filled)
|
|
25
|
+
*/
|
|
26
|
+
export function getPriorityDots(priority: Priority): string {
|
|
27
|
+
switch (priority) {
|
|
28
|
+
case 'HIGH':
|
|
29
|
+
return '●●●';
|
|
30
|
+
case 'MEDIUM':
|
|
31
|
+
return '●●○';
|
|
32
|
+
case 'LOW':
|
|
33
|
+
return '●○○';
|
|
34
|
+
default:
|
|
35
|
+
return '○○○';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get a semantic color from theme
|
|
41
|
+
*/
|
|
42
|
+
export function getSemanticColor(
|
|
43
|
+
type: 'success' | 'warning' | 'error' | 'info',
|
|
44
|
+
theme: Theme
|
|
45
|
+
): string {
|
|
46
|
+
return theme.colors[type];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Determine if a status represents an "active" state
|
|
51
|
+
*/
|
|
52
|
+
export function isActiveStatus(status: string): boolean {
|
|
53
|
+
const activeStatuses = [
|
|
54
|
+
'IN_PROGRESS',
|
|
55
|
+
'IN_DEVELOPMENT',
|
|
56
|
+
'IN_REVIEW',
|
|
57
|
+
'TESTING',
|
|
58
|
+
'VALIDATING',
|
|
59
|
+
'INVESTIGATING',
|
|
60
|
+
'READY_FOR_QA',
|
|
61
|
+
];
|
|
62
|
+
return activeStatuses.includes(status);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Determine if a status represents a "blocked" state
|
|
67
|
+
*/
|
|
68
|
+
export function isBlockedStatus(status: string): boolean {
|
|
69
|
+
const blockedStatuses = ['BLOCKED', 'ON_HOLD', 'CHANGES_REQUESTED'];
|
|
70
|
+
return blockedStatuses.includes(status);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Determine if a status represents a "completed" state
|
|
75
|
+
*/
|
|
76
|
+
export function isCompletedStatus(status: string): boolean {
|
|
77
|
+
const completedStatuses = ['COMPLETED', 'DEPLOYED', 'ARCHIVED', 'CANCELLED'];
|
|
78
|
+
return completedStatuses.includes(status);
|
|
79
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a date as a relative time string
|
|
3
|
+
* @example timeAgo(new Date(Date.now() - 3600000)) // "1h ago"
|
|
4
|
+
*/
|
|
5
|
+
export function timeAgo(date: Date): string {
|
|
6
|
+
const now = new Date();
|
|
7
|
+
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
8
|
+
|
|
9
|
+
if (seconds < 60) {
|
|
10
|
+
return 'just now';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
if (minutes < 60) {
|
|
15
|
+
return `${minutes}m ago`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const hours = Math.floor(minutes / 60);
|
|
19
|
+
if (hours < 24) {
|
|
20
|
+
return `${hours}h ago`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const days = Math.floor(hours / 24);
|
|
24
|
+
if (days < 7) {
|
|
25
|
+
return `${days}d ago`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const weeks = Math.floor(days / 7);
|
|
29
|
+
if (weeks < 4) {
|
|
30
|
+
return `${weeks}w ago`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const months = Math.floor(days / 30);
|
|
34
|
+
if (months < 12) {
|
|
35
|
+
return `${months}mo ago`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const years = Math.floor(days / 365);
|
|
39
|
+
return `${years}y ago`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Truncate a UUID or ID for display
|
|
44
|
+
* @example truncateId('550e8400-e29b-41d4-a716-446655440000') // "550e84..."
|
|
45
|
+
*/
|
|
46
|
+
export function truncateId(id: string, length: number = 6): string {
|
|
47
|
+
if (id.length <= length + 3) {
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
return `${id.slice(0, length)}...`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Truncate text with ellipsis
|
|
55
|
+
* @example truncateText('Long text here', 8) // "Long t..."
|
|
56
|
+
*/
|
|
57
|
+
export function truncateText(text: string, maxLength: number): string {
|
|
58
|
+
if (text.length <= maxLength) {
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
if (maxLength <= 3) {
|
|
62
|
+
return text.slice(0, maxLength);
|
|
63
|
+
}
|
|
64
|
+
return `${text.slice(0, maxLength - 3)}...`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pluralize a word based on count
|
|
69
|
+
* @example pluralize(1, 'task') // "task"
|
|
70
|
+
* @example pluralize(5, 'task') // "tasks"
|
|
71
|
+
*/
|
|
72
|
+
export function pluralize(count: number, singular: string, plural?: string): string {
|
|
73
|
+
if (count === 1) {
|
|
74
|
+
return singular;
|
|
75
|
+
}
|
|
76
|
+
return plural ?? `${singular}s`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format task counts for display
|
|
81
|
+
* @example formatTaskCount({ completed: 5, total: 12 }) // "5/12 tasks"
|
|
82
|
+
*/
|
|
83
|
+
export function formatTaskCount(counts: { total: number; byStatus: Record<string, number> }): string {
|
|
84
|
+
const completed = counts.byStatus['COMPLETED'] ?? 0;
|
|
85
|
+
return `${completed}/${counts.total} ${pluralize(counts.total, 'task')}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a count with label
|
|
90
|
+
* @example formatCount(5, 'task') // "5 tasks"
|
|
91
|
+
*/
|
|
92
|
+
export function formatCount(count: number, singular: string, plural?: string): string {
|
|
93
|
+
return `${count} ${pluralize(count, singular, plural)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Format a status string for display (convert to title case)
|
|
98
|
+
* @example formatStatus('IN_PROGRESS') // "In Progress"
|
|
99
|
+
*/
|
|
100
|
+
export function formatStatus(status: string): string {
|
|
101
|
+
return status
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.split('_')
|
|
104
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
105
|
+
.join(' ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format a priority for display
|
|
110
|
+
* @example formatPriority('HIGH') // "High"
|
|
111
|
+
*/
|
|
112
|
+
export function formatPriority(priority: string): string {
|
|
113
|
+
return priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase();
|
|
114
|
+
}
|