@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,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
+ }
@@ -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
+ }