@agile-vibe-coding/avc 0.1.1 → 0.3.1
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/cli/agent-loader.js +21 -0
- package/cli/agents/agent-selector.md +152 -0
- package/cli/agents/architecture-recommender.md +418 -0
- package/cli/agents/code-implementer.md +117 -0
- package/cli/agents/code-validator.md +80 -0
- package/cli/agents/context-reviewer-epic.md +101 -0
- package/cli/agents/context-reviewer-story.md +92 -0
- package/cli/agents/context-writer-epic.md +145 -0
- package/cli/agents/context-writer-story.md +111 -0
- package/cli/agents/database-deep-dive.md +470 -0
- package/cli/agents/database-recommender.md +634 -0
- package/cli/agents/doc-distributor.md +176 -0
- package/cli/agents/doc-writer-epic.md +42 -0
- package/cli/agents/doc-writer-story.md +43 -0
- package/cli/agents/documentation-updater.md +203 -0
- package/cli/agents/duplicate-detector.md +110 -0
- package/cli/agents/epic-story-decomposer.md +559 -0
- package/cli/agents/feature-context-generator.md +91 -0
- package/cli/agents/gap-checker-epic.md +52 -0
- package/cli/agents/impact-checker-story.md +51 -0
- package/cli/agents/migration-guide-generator.md +305 -0
- package/cli/agents/mission-scope-generator.md +143 -0
- package/cli/agents/mission-scope-validator.md +146 -0
- package/cli/agents/project-context-extractor.md +122 -0
- package/cli/agents/project-documentation-creator.json +226 -0
- package/cli/agents/project-documentation-creator.md +595 -0
- package/cli/agents/question-prefiller.md +269 -0
- package/cli/agents/refiner-epic.md +39 -0
- package/cli/agents/refiner-story.md +42 -0
- package/cli/agents/scaffolding-generator.md +99 -0
- package/cli/agents/seed-validator.md +71 -0
- package/cli/agents/story-doc-enricher.md +133 -0
- package/cli/agents/story-scope-reviewer.md +147 -0
- package/cli/agents/story-splitter.md +83 -0
- package/cli/agents/suggestion-business-analyst.md +88 -0
- package/cli/agents/suggestion-deployment-architect.md +263 -0
- package/cli/agents/suggestion-product-manager.md +129 -0
- package/cli/agents/suggestion-security-specialist.md +156 -0
- package/cli/agents/suggestion-technical-architect.md +269 -0
- package/cli/agents/suggestion-ux-researcher.md +93 -0
- package/cli/agents/task-subtask-decomposer.md +188 -0
- package/cli/agents/validator-documentation.json +183 -0
- package/cli/agents/validator-documentation.md +455 -0
- package/cli/agents/validator-selector.md +211 -0
- package/cli/ansi-colors.js +21 -0
- package/cli/api-reference-tool.js +368 -0
- package/cli/build-docs.js +29 -8
- package/cli/ceremony-history.js +369 -0
- package/cli/checks/catalog.json +76 -0
- package/cli/checks/code/quality.json +26 -0
- package/cli/checks/code/testing.json +14 -0
- package/cli/checks/code/traceability.json +26 -0
- package/cli/checks/cross-refs/epic.json +171 -0
- package/cli/checks/cross-refs/story.json +149 -0
- package/cli/checks/epic/api.json +114 -0
- package/cli/checks/epic/backend.json +126 -0
- package/cli/checks/epic/cloud.json +126 -0
- package/cli/checks/epic/data.json +102 -0
- package/cli/checks/epic/database.json +114 -0
- package/cli/checks/epic/developer.json +182 -0
- package/cli/checks/epic/devops.json +174 -0
- package/cli/checks/epic/frontend.json +162 -0
- package/cli/checks/epic/mobile.json +102 -0
- package/cli/checks/epic/qa.json +90 -0
- package/cli/checks/epic/security.json +184 -0
- package/cli/checks/epic/solution-architect.json +192 -0
- package/cli/checks/epic/test-architect.json +90 -0
- package/cli/checks/epic/ui.json +102 -0
- package/cli/checks/epic/ux.json +90 -0
- package/cli/checks/fixes/epic-fix-template.md +10 -0
- package/cli/checks/fixes/story-fix-template.md +10 -0
- package/cli/checks/story/api.json +186 -0
- package/cli/checks/story/backend.json +102 -0
- package/cli/checks/story/cloud.json +102 -0
- package/cli/checks/story/data.json +210 -0
- package/cli/checks/story/database.json +102 -0
- package/cli/checks/story/developer.json +168 -0
- package/cli/checks/story/devops.json +102 -0
- package/cli/checks/story/frontend.json +174 -0
- package/cli/checks/story/mobile.json +102 -0
- package/cli/checks/story/qa.json +210 -0
- package/cli/checks/story/security.json +198 -0
- package/cli/checks/story/solution-architect.json +230 -0
- package/cli/checks/story/test-architect.json +210 -0
- package/cli/checks/story/ui.json +102 -0
- package/cli/checks/story/ux.json +102 -0
- package/cli/coding-order.js +401 -0
- package/cli/command-logger.js +49 -12
- package/cli/components/static-output.js +63 -0
- package/cli/console-output-manager.js +94 -0
- package/cli/dependency-checker.js +72 -0
- package/cli/docs-sync.js +306 -0
- package/cli/epic-story-validator.js +659 -0
- package/cli/evaluation-prompts.js +1008 -0
- package/cli/execution-context.js +195 -0
- package/cli/generate-summary-table.js +340 -0
- package/cli/init-model-config.js +704 -0
- package/cli/init.js +1737 -278
- package/cli/kanban-server-manager.js +227 -0
- package/cli/llm-claude.js +150 -1
- package/cli/llm-gemini.js +109 -0
- package/cli/llm-local.js +493 -0
- package/cli/llm-mock.js +233 -0
- package/cli/llm-openai.js +454 -0
- package/cli/llm-provider.js +379 -3
- package/cli/llm-token-limits.js +211 -0
- package/cli/llm-verifier.js +662 -0
- package/cli/llm-xiaomi.js +143 -0
- package/cli/message-constants.js +49 -0
- package/cli/message-manager.js +334 -0
- package/cli/message-types.js +96 -0
- package/cli/messaging-api.js +291 -0
- package/cli/micro-check-fixer.js +335 -0
- package/cli/micro-check-runner.js +449 -0
- package/cli/micro-check-scorer.js +148 -0
- package/cli/micro-check-validator.js +538 -0
- package/cli/model-pricing.js +192 -0
- package/cli/model-query-engine.js +468 -0
- package/cli/model-recommendation-analyzer.js +495 -0
- package/cli/model-selector.js +270 -0
- package/cli/output-buffer.js +107 -0
- package/cli/process-manager.js +73 -2
- package/cli/prompt-logger.js +57 -0
- package/cli/repl-ink.js +4625 -1094
- package/cli/repl-old.js +3 -4
- package/cli/seed-processor.js +962 -0
- package/cli/sprint-planning-processor.js +4162 -0
- package/cli/template-processor.js +2149 -105
- package/cli/templates/project.md +25 -8
- package/cli/templates/vitepress-config.mts.template +5 -4
- package/cli/token-tracker.js +547 -0
- package/cli/tools/generate-story-validators.js +317 -0
- package/cli/tools/generate-validators.js +669 -0
- package/cli/update-checker.js +19 -17
- package/cli/update-notifier.js +4 -4
- package/cli/validation-router.js +667 -0
- package/cli/verification-tracker.js +563 -0
- package/cli/worktree-runner.js +654 -0
- package/kanban/README.md +386 -0
- package/kanban/client/README.md +205 -0
- package/kanban/client/components.json +20 -0
- package/kanban/client/dist/assets/index-D_KC5EQT.css +1 -0
- package/kanban/client/dist/assets/index-DjY5zqW7.js +351 -0
- package/kanban/client/dist/index.html +16 -0
- package/kanban/client/dist/vite.svg +1 -0
- package/kanban/client/index.html +15 -0
- package/kanban/client/package-lock.json +9442 -0
- package/kanban/client/package.json +44 -0
- package/kanban/client/postcss.config.js +6 -0
- package/kanban/client/public/vite.svg +1 -0
- package/kanban/client/src/App.jsx +651 -0
- package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
- package/kanban/client/src/components/ceremony/AskArchPopup.jsx +420 -0
- package/kanban/client/src/components/ceremony/AskModelPopup.jsx +629 -0
- package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +1133 -0
- package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
- package/kanban/client/src/components/ceremony/ProviderSwitcherButton.jsx +290 -0
- package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +686 -0
- package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +838 -0
- package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
- package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +136 -0
- package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
- package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
- package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
- package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +329 -0
- package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +249 -0
- package/kanban/client/src/components/kanban/CardDetailModal.jsx +646 -0
- package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
- package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
- package/kanban/client/src/components/kanban/GroupingSelector.jsx +63 -0
- package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
- package/kanban/client/src/components/kanban/KanbanCard.jsx +147 -0
- package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
- package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +784 -0
- package/kanban/client/src/components/kanban/RunButton.jsx +162 -0
- package/kanban/client/src/components/kanban/SeedButton.jsx +176 -0
- package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
- package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
- package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
- package/kanban/client/src/components/settings/AgentsTab.jsx +381 -0
- package/kanban/client/src/components/settings/ApiKeysTab.jsx +142 -0
- package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +105 -0
- package/kanban/client/src/components/settings/CheckEditorPopup.jsx +507 -0
- package/kanban/client/src/components/settings/CostThresholdsTab.jsx +95 -0
- package/kanban/client/src/components/settings/ModelPricingTab.jsx +269 -0
- package/kanban/client/src/components/settings/OpenAIAuthSection.jsx +412 -0
- package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
- package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
- package/kanban/client/src/components/stats/CostModal.jsx +384 -0
- package/kanban/client/src/components/ui/badge.jsx +27 -0
- package/kanban/client/src/components/ui/dialog.jsx +121 -0
- package/kanban/client/src/components/ui/tabs.jsx +85 -0
- package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
- package/kanban/client/src/hooks/useGrouping.js +177 -0
- package/kanban/client/src/hooks/useWebSocket.js +120 -0
- package/kanban/client/src/lib/__tests__/api.test.js +196 -0
- package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
- package/kanban/client/src/lib/api.js +515 -0
- package/kanban/client/src/lib/status-grouping.js +154 -0
- package/kanban/client/src/lib/utils.js +11 -0
- package/kanban/client/src/main.jsx +10 -0
- package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
- package/kanban/client/src/store/ceremonyStore.js +172 -0
- package/kanban/client/src/store/filterStore.js +201 -0
- package/kanban/client/src/store/kanbanStore.js +123 -0
- package/kanban/client/src/store/processStore.js +65 -0
- package/kanban/client/src/store/sprintPlanningStore.js +33 -0
- package/kanban/client/src/styles/globals.css +59 -0
- package/kanban/client/tailwind.config.js +77 -0
- package/kanban/client/vite.config.js +28 -0
- package/kanban/client/vitest.config.js +28 -0
- package/kanban/dev-start.sh +47 -0
- package/kanban/package.json +12 -0
- package/kanban/server/index.js +537 -0
- package/kanban/server/routes/ceremony.js +454 -0
- package/kanban/server/routes/costs.js +163 -0
- package/kanban/server/routes/openai-oauth.js +366 -0
- package/kanban/server/routes/processes.js +50 -0
- package/kanban/server/routes/settings.js +736 -0
- package/kanban/server/routes/websocket.js +281 -0
- package/kanban/server/routes/work-items.js +487 -0
- package/kanban/server/services/CeremonyService.js +1441 -0
- package/kanban/server/services/FileSystemScanner.js +95 -0
- package/kanban/server/services/FileWatcher.js +144 -0
- package/kanban/server/services/HierarchyBuilder.js +196 -0
- package/kanban/server/services/ProcessRegistry.js +122 -0
- package/kanban/server/services/TaskRunnerService.js +261 -0
- package/kanban/server/services/WorkItemReader.js +123 -0
- package/kanban/server/services/WorkItemRefineService.js +510 -0
- package/kanban/server/start.js +49 -0
- package/kanban/server/utils/kanban-logger.js +132 -0
- package/kanban/server/utils/markdown.js +91 -0
- package/kanban/server/utils/status-grouping.js +107 -0
- package/kanban/server/workers/run-task-worker.js +121 -0
- package/kanban/server/workers/seed-worker.js +94 -0
- package/kanban/server/workers/sponsor-call-worker.js +92 -0
- package/kanban/server/workers/sprint-planning-worker.js +212 -0
- package/package.json +19 -7
- package/cli/agents/documentation.md +0 -302
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dialog Component (shadcn/ui style)
|
|
8
|
+
* Modal dialog with backdrop and animations
|
|
9
|
+
*/
|
|
10
|
+
export function Dialog({ open, onOpenChange, children }) {
|
|
11
|
+
// Close on Escape key
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const handleEscape = (e) => {
|
|
14
|
+
if (e.key === 'Escape' && open) {
|
|
15
|
+
onOpenChange(false);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
document.addEventListener('keydown', handleEscape);
|
|
20
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
21
|
+
}, [open, onOpenChange]);
|
|
22
|
+
|
|
23
|
+
// Prevent body scroll when dialog is open
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (open) {
|
|
26
|
+
document.body.style.overflow = 'hidden';
|
|
27
|
+
} else {
|
|
28
|
+
document.body.style.overflow = 'unset';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
document.body.style.overflow = 'unset';
|
|
33
|
+
};
|
|
34
|
+
}, [open]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<AnimatePresence>
|
|
38
|
+
{open && (
|
|
39
|
+
<>
|
|
40
|
+
{/* Backdrop */}
|
|
41
|
+
<motion.div
|
|
42
|
+
initial={{ opacity: 0 }}
|
|
43
|
+
animate={{ opacity: 1 }}
|
|
44
|
+
exit={{ opacity: 0 }}
|
|
45
|
+
onClick={() => onOpenChange(false)}
|
|
46
|
+
className="fixed inset-0 bg-black/50 z-50"
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
{/* Dialog Container */}
|
|
50
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
</>
|
|
54
|
+
)}
|
|
55
|
+
</AnimatePresence>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Dialog Content
|
|
61
|
+
*/
|
|
62
|
+
export function DialogContent({ className, children, onClose }) {
|
|
63
|
+
return (
|
|
64
|
+
<motion.div
|
|
65
|
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
66
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
67
|
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
68
|
+
transition={{ duration: 0.2 }}
|
|
69
|
+
onClick={(e) => e.stopPropagation()}
|
|
70
|
+
className={cn(
|
|
71
|
+
'relative bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] overflow-hidden',
|
|
72
|
+
'flex flex-col',
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{/* Close button */}
|
|
77
|
+
<button
|
|
78
|
+
onClick={onClose}
|
|
79
|
+
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity z-10"
|
|
80
|
+
>
|
|
81
|
+
<X className="h-5 w-5" />
|
|
82
|
+
<span className="sr-only">Close</span>
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
{children}
|
|
86
|
+
</motion.div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Dialog Header
|
|
92
|
+
*/
|
|
93
|
+
export function DialogHeader({ className, children }) {
|
|
94
|
+
return (
|
|
95
|
+
<div className={cn('flex flex-col space-y-1.5 px-6 pt-6 pb-4', className)}>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Dialog Title
|
|
103
|
+
*/
|
|
104
|
+
export function DialogTitle({ className, children }) {
|
|
105
|
+
return (
|
|
106
|
+
<h2 className={cn('text-2xl font-semibold leading-none tracking-tight', className)}>
|
|
107
|
+
{children}
|
|
108
|
+
</h2>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Dialog Description
|
|
114
|
+
*/
|
|
115
|
+
export function DialogDescription({ className, children }) {
|
|
116
|
+
return (
|
|
117
|
+
<p className={cn('text-sm text-slate-600', className)}>
|
|
118
|
+
{children}
|
|
119
|
+
</p>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { cn } from '../../lib/utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tabs Component (shadcn/ui style)
|
|
6
|
+
* Tabbed interface with keyboard navigation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TabsContext = createContext();
|
|
10
|
+
|
|
11
|
+
export function Tabs({ defaultValue, value, onValueChange, children, className }) {
|
|
12
|
+
const [selectedTab, setSelectedTab] = useState(value || defaultValue);
|
|
13
|
+
|
|
14
|
+
// Sync internal state when controlled value prop changes
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (value !== undefined) {
|
|
17
|
+
setSelectedTab(value);
|
|
18
|
+
}
|
|
19
|
+
}, [value]);
|
|
20
|
+
|
|
21
|
+
const handleTabChange = (newValue) => {
|
|
22
|
+
setSelectedTab(newValue);
|
|
23
|
+
onValueChange?.(newValue);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<TabsContext.Provider value={{ selectedTab, setSelectedTab: handleTabChange }}>
|
|
28
|
+
<div className={cn('w-full', className)}>{children}</div>
|
|
29
|
+
</TabsContext.Provider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function TabsList({ className, children }) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
'inline-flex h-10 items-center justify-start rounded-md bg-slate-100 p-1 text-slate-600',
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function TabsTrigger({ value, children, className }) {
|
|
47
|
+
const { selectedTab, setSelectedTab } = useContext(TabsContext);
|
|
48
|
+
const isSelected = selectedTab === value;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => setSelectedTab(value)}
|
|
53
|
+
className={cn(
|
|
54
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5',
|
|
55
|
+
'text-sm font-medium ring-offset-white transition-all',
|
|
56
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2',
|
|
57
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
58
|
+
isSelected
|
|
59
|
+
? 'bg-white text-slate-900 shadow-sm'
|
|
60
|
+
: 'text-slate-600 hover:bg-slate-200',
|
|
61
|
+
className
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TabsContent({ value, children, className }) {
|
|
70
|
+
const { selectedTab } = useContext(TabsContext);
|
|
71
|
+
|
|
72
|
+
if (selectedTab !== value) return null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
className={cn(
|
|
77
|
+
'mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2',
|
|
78
|
+
'focus-visible:ring-slate-950 focus-visible:ring-offset-2',
|
|
79
|
+
className
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import { useGrouping } from '../useGrouping';
|
|
4
|
+
|
|
5
|
+
describe('useGrouping', () => {
|
|
6
|
+
const mockWorkItems = [
|
|
7
|
+
{
|
|
8
|
+
id: 'EPIC-001',
|
|
9
|
+
type: 'epic',
|
|
10
|
+
name: 'Epic 1',
|
|
11
|
+
status: 'implementing',
|
|
12
|
+
epicId: 'EPIC-001',
|
|
13
|
+
epicName: 'Epic 1',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: 'STORY-001',
|
|
17
|
+
type: 'story',
|
|
18
|
+
name: 'Story 1',
|
|
19
|
+
status: 'ready',
|
|
20
|
+
epicId: 'EPIC-001',
|
|
21
|
+
epicName: 'Epic 1',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'EPIC-002',
|
|
25
|
+
type: 'epic',
|
|
26
|
+
name: 'Epic 2',
|
|
27
|
+
status: 'planned',
|
|
28
|
+
epicId: 'EPIC-002',
|
|
29
|
+
epicName: 'Epic 2',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'STORY-002',
|
|
33
|
+
type: 'story',
|
|
34
|
+
name: 'Story 2',
|
|
35
|
+
status: 'planned',
|
|
36
|
+
epicId: 'EPIC-002',
|
|
37
|
+
epicName: 'Epic 2',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'TASK-001',
|
|
41
|
+
type: 'task',
|
|
42
|
+
name: 'Task 1',
|
|
43
|
+
status: 'completed',
|
|
44
|
+
epicId: null,
|
|
45
|
+
epicName: null,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
describe('groupBy: status', () => {
|
|
50
|
+
it('should group by status columns', () => {
|
|
51
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'status'));
|
|
52
|
+
|
|
53
|
+
expect(result.current.mode).toBe('columns');
|
|
54
|
+
expect(result.current.groups).toHaveLength(5); // 5 columns
|
|
55
|
+
|
|
56
|
+
const columnNames = result.current.groups.map((g) => g.name);
|
|
57
|
+
expect(columnNames).toEqual(['Backlog', 'Ready', 'In Progress', 'Review', 'Done']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should distribute items to correct columns', () => {
|
|
61
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'status'));
|
|
62
|
+
|
|
63
|
+
const backlog = result.current.groups.find((g) => g.name === 'Backlog');
|
|
64
|
+
const ready = result.current.groups.find((g) => g.name === 'Ready');
|
|
65
|
+
const inProgress = result.current.groups.find((g) => g.name === 'In Progress');
|
|
66
|
+
const done = result.current.groups.find((g) => g.name === 'Done');
|
|
67
|
+
|
|
68
|
+
expect(backlog.items).toHaveLength(2); // EPIC-002, STORY-002 (planned)
|
|
69
|
+
expect(ready.items).toHaveLength(1); // STORY-001 (ready)
|
|
70
|
+
expect(inProgress.items).toHaveLength(1); // EPIC-001 (implementing)
|
|
71
|
+
expect(done.items).toHaveLength(1); // TASK-001 (completed)
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should have column structure with id and name', () => {
|
|
75
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'status'));
|
|
76
|
+
|
|
77
|
+
result.current.groups.forEach((group) => {
|
|
78
|
+
expect(group).toHaveProperty('id');
|
|
79
|
+
expect(group).toHaveProperty('name');
|
|
80
|
+
expect(group).toHaveProperty('items');
|
|
81
|
+
expect(Array.isArray(group.items)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('groupBy: epic', () => {
|
|
87
|
+
it('should group by epic with sections mode', () => {
|
|
88
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
|
|
89
|
+
|
|
90
|
+
expect(result.current.mode).toBe('sections');
|
|
91
|
+
expect(result.current.groups.length).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should create section for each epic', () => {
|
|
95
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
|
|
96
|
+
|
|
97
|
+
const epicNames = result.current.groups.map((g) => g.name);
|
|
98
|
+
expect(epicNames).toContain('Epic 1');
|
|
99
|
+
expect(epicNames).toContain('Epic 2');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should group items without epic into "No Epic" section', () => {
|
|
103
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
|
|
104
|
+
|
|
105
|
+
const noEpicGroup = result.current.groups.find((g) => g.type === 'ungrouped');
|
|
106
|
+
expect(noEpicGroup).toBeDefined();
|
|
107
|
+
expect(noEpicGroup.items).toHaveLength(1); // TASK-001
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should include epic object in group', () => {
|
|
111
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
|
|
112
|
+
|
|
113
|
+
const epic1Group = result.current.groups.find((g) => g.name === 'Epic 1');
|
|
114
|
+
expect(epic1Group.epic).toBeDefined();
|
|
115
|
+
expect(epic1Group.epic.id).toBe('EPIC-001');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should distribute items into columns within each epic', () => {
|
|
119
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
|
|
120
|
+
|
|
121
|
+
const epic1Group = result.current.groups.find((g) => g.name === 'Epic 1');
|
|
122
|
+
expect(epic1Group.columns).toBeDefined();
|
|
123
|
+
expect(epic1Group.columns.Ready).toHaveLength(1); // STORY-001
|
|
124
|
+
expect(epic1Group.columns['In Progress']).toHaveLength(1); // EPIC-001
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('groupBy: type', () => {
|
|
129
|
+
it('should group by type with sections mode', () => {
|
|
130
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
|
|
131
|
+
|
|
132
|
+
expect(result.current.mode).toBe('sections');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should create section for each type', () => {
|
|
136
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
|
|
137
|
+
|
|
138
|
+
const typeNames = result.current.groups.map((g) => g.name);
|
|
139
|
+
expect(typeNames).toContain('Epics');
|
|
140
|
+
expect(typeNames).toContain('Stories');
|
|
141
|
+
expect(typeNames).toContain('Tasks');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should distribute items into correct type sections', () => {
|
|
145
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
|
|
146
|
+
|
|
147
|
+
const epicsGroup = result.current.groups.find((g) => g.name === 'Epics');
|
|
148
|
+
const storiesGroup = result.current.groups.find((g) => g.name === 'Stories');
|
|
149
|
+
const tasksGroup = result.current.groups.find((g) => g.name === 'Tasks');
|
|
150
|
+
|
|
151
|
+
expect(epicsGroup.items).toHaveLength(2); // EPIC-001, EPIC-002
|
|
152
|
+
expect(storiesGroup.items).toHaveLength(2); // STORY-001, STORY-002
|
|
153
|
+
expect(tasksGroup.items).toHaveLength(1); // TASK-001
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should distribute items into columns within each type', () => {
|
|
157
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
|
|
158
|
+
|
|
159
|
+
const epicsGroup = result.current.groups.find((g) => g.name === 'Epics');
|
|
160
|
+
expect(epicsGroup.columns).toBeDefined();
|
|
161
|
+
expect(epicsGroup.columns.Backlog).toHaveLength(1); // EPIC-002 (planned)
|
|
162
|
+
expect(epicsGroup.columns['In Progress']).toHaveLength(1); // EPIC-001 (implementing)
|
|
163
|
+
|
|
164
|
+
const storiesGroup = result.current.groups.find((g) => g.name === 'Stories');
|
|
165
|
+
expect(storiesGroup.columns).toBeDefined();
|
|
166
|
+
expect(storiesGroup.columns.Backlog).toHaveLength(1); // STORY-002 (planned)
|
|
167
|
+
expect(storiesGroup.columns.Ready).toHaveLength(1); // STORY-001 (ready)
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('memoization', () => {
|
|
172
|
+
it('should return same reference when inputs unchanged', () => {
|
|
173
|
+
const { result, rerender } = renderHook(
|
|
174
|
+
({ items, groupBy }) => useGrouping(items, groupBy),
|
|
175
|
+
{ initialProps: { items: mockWorkItems, groupBy: 'status' } }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const firstResult = result.current;
|
|
179
|
+
rerender({ items: mockWorkItems, groupBy: 'status' });
|
|
180
|
+
const secondResult = result.current;
|
|
181
|
+
|
|
182
|
+
expect(firstResult).toBe(secondResult);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should recalculate when groupBy changes', () => {
|
|
186
|
+
const { result, rerender } = renderHook(
|
|
187
|
+
({ items, groupBy }) => useGrouping(items, groupBy),
|
|
188
|
+
{ initialProps: { items: mockWorkItems, groupBy: 'status' } }
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const statusResult = result.current;
|
|
192
|
+
rerender({ items: mockWorkItems, groupBy: 'epic' });
|
|
193
|
+
const epicResult = result.current;
|
|
194
|
+
|
|
195
|
+
expect(statusResult).not.toBe(epicResult);
|
|
196
|
+
expect(statusResult.mode).toBe('columns');
|
|
197
|
+
expect(epicResult.mode).toBe('sections');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should recalculate when items change', () => {
|
|
201
|
+
const { result, rerender } = renderHook(
|
|
202
|
+
({ items, groupBy }) => useGrouping(items, groupBy),
|
|
203
|
+
{ initialProps: { items: mockWorkItems, groupBy: 'status' } }
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const firstResult = result.current;
|
|
207
|
+
const newItems = [...mockWorkItems, { id: 'NEW-001', status: 'ready' }];
|
|
208
|
+
rerender({ items: newItems, groupBy: 'status' });
|
|
209
|
+
const secondResult = result.current;
|
|
210
|
+
|
|
211
|
+
expect(firstResult).not.toBe(secondResult);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('edge cases', () => {
|
|
216
|
+
it('should handle empty work items array', () => {
|
|
217
|
+
const { result } = renderHook(() => useGrouping([], 'status'));
|
|
218
|
+
|
|
219
|
+
expect(result.current.groups).toHaveLength(5);
|
|
220
|
+
result.current.groups.forEach((group) => {
|
|
221
|
+
expect(group.items).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should handle unknown groupBy value by defaulting to status', () => {
|
|
226
|
+
const { result } = renderHook(() => useGrouping(mockWorkItems, 'unknown'));
|
|
227
|
+
|
|
228
|
+
expect(result.current.mode).toBe('columns');
|
|
229
|
+
expect(result.current.groups).toHaveLength(5);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { groupItemsByColumn, COLUMN_ORDER } from '../lib/status-grouping';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Grouping Hook
|
|
6
|
+
* Handles different grouping strategies for work items
|
|
7
|
+
*/
|
|
8
|
+
export function useGrouping(workItems, groupBy) {
|
|
9
|
+
return useMemo(() => {
|
|
10
|
+
switch (groupBy) {
|
|
11
|
+
case 'status':
|
|
12
|
+
return groupByStatus(workItems);
|
|
13
|
+
case 'epic':
|
|
14
|
+
return groupByEpic(workItems);
|
|
15
|
+
case 'type':
|
|
16
|
+
return groupByType(workItems);
|
|
17
|
+
case 'phase':
|
|
18
|
+
return groupByPhase(workItems);
|
|
19
|
+
default:
|
|
20
|
+
return groupByStatus(workItems);
|
|
21
|
+
}
|
|
22
|
+
}, [workItems, groupBy]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Group by status (default kanban columns)
|
|
27
|
+
*/
|
|
28
|
+
function groupByStatus(workItems) {
|
|
29
|
+
const grouped = groupItemsByColumn(workItems);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
mode: 'columns',
|
|
33
|
+
groups: COLUMN_ORDER.map((columnName) => ({
|
|
34
|
+
id: columnName,
|
|
35
|
+
name: columnName,
|
|
36
|
+
items: grouped[columnName] || [],
|
|
37
|
+
type: 'column',
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Group by epic (hierarchical sections)
|
|
44
|
+
*/
|
|
45
|
+
function groupByEpic(workItems) {
|
|
46
|
+
// Get all epics
|
|
47
|
+
const epics = workItems.filter((item) => item.type === 'epic');
|
|
48
|
+
|
|
49
|
+
// Group items by epic
|
|
50
|
+
const groups = epics.map((epic) => {
|
|
51
|
+
// Get all descendants of this epic
|
|
52
|
+
const epicItems = workItems.filter(
|
|
53
|
+
(item) => item.epicId === epic.id
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Group epic's items by column
|
|
57
|
+
const columns = groupItemsByColumn(epicItems);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
id: epic.id,
|
|
61
|
+
name: epic.name,
|
|
62
|
+
epic: epic,
|
|
63
|
+
items: epicItems,
|
|
64
|
+
columns: columns,
|
|
65
|
+
type: 'epic',
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Add ungrouped items (items without an epic)
|
|
70
|
+
const ungroupedItems = workItems.filter(
|
|
71
|
+
(item) => !item.epicId && item.type !== 'epic'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (ungroupedItems.length > 0) {
|
|
75
|
+
const columns = groupItemsByColumn(ungroupedItems);
|
|
76
|
+
groups.push({
|
|
77
|
+
id: 'ungrouped',
|
|
78
|
+
name: 'No Epic',
|
|
79
|
+
items: ungroupedItems,
|
|
80
|
+
columns: columns,
|
|
81
|
+
type: 'ungrouped',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
mode: 'sections',
|
|
87
|
+
groups,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Group by type (separate boards for each type)
|
|
93
|
+
*/
|
|
94
|
+
function groupByType(workItems) {
|
|
95
|
+
const types = [
|
|
96
|
+
{ id: 'epic', name: 'Epics' },
|
|
97
|
+
{ id: 'story', name: 'Stories' },
|
|
98
|
+
{ id: 'task', name: 'Tasks' },
|
|
99
|
+
{ id: 'subtask', name: 'Subtasks' },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const groups = types.map((type) => {
|
|
103
|
+
const typeItems = workItems.filter((item) => item.type === type.id);
|
|
104
|
+
const columns = groupItemsByColumn(typeItems);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: type.id,
|
|
108
|
+
name: type.name,
|
|
109
|
+
items: typeItems,
|
|
110
|
+
columns: columns,
|
|
111
|
+
type: 'type',
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Filter out empty groups
|
|
116
|
+
return {
|
|
117
|
+
mode: 'sections',
|
|
118
|
+
groups: groups.filter((group) => group.items.length > 0),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Group by implementation phase (coding order)
|
|
124
|
+
*/
|
|
125
|
+
function groupByPhase(workItems) {
|
|
126
|
+
// Collect all phases from metadata
|
|
127
|
+
const phaseMap = new Map(); // phase number → items
|
|
128
|
+
const unphased = [];
|
|
129
|
+
|
|
130
|
+
for (const item of workItems) {
|
|
131
|
+
const phase = item.metadata?.codingPhase;
|
|
132
|
+
if (phase != null) {
|
|
133
|
+
if (!phaseMap.has(phase)) phaseMap.set(phase, []);
|
|
134
|
+
phaseMap.get(phase).push(item);
|
|
135
|
+
} else {
|
|
136
|
+
unphased.push(item);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Sort phases numerically
|
|
141
|
+
const sortedPhases = [...phaseMap.entries()].sort((a, b) => a[0] - b[0]);
|
|
142
|
+
|
|
143
|
+
const groups = sortedPhases.map(([phase, items]) => {
|
|
144
|
+
// Sort items within phase by codingOrder
|
|
145
|
+
items.sort((a, b) => (a.metadata?.codingOrder ?? 0) - (b.metadata?.codingOrder ?? 0));
|
|
146
|
+
const columns = groupItemsByColumn(items);
|
|
147
|
+
|
|
148
|
+
// Find epic names in this phase for the label
|
|
149
|
+
const epicNames = [...new Set(items.filter(i => i.type === 'epic').map(i => i.name))];
|
|
150
|
+
const label = epicNames.length > 0
|
|
151
|
+
? `Phase ${phase}: ${epicNames.join(', ')}`
|
|
152
|
+
: `Phase ${phase}`;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
id: `phase-${phase}`,
|
|
156
|
+
name: label,
|
|
157
|
+
items,
|
|
158
|
+
columns,
|
|
159
|
+
type: 'phase',
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (unphased.length > 0) {
|
|
164
|
+
groups.push({
|
|
165
|
+
id: 'unphased',
|
|
166
|
+
name: 'No Phase',
|
|
167
|
+
items: unphased,
|
|
168
|
+
columns: groupItemsByColumn(unphased),
|
|
169
|
+
type: 'ungrouped',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
mode: 'sections',
|
|
175
|
+
groups,
|
|
176
|
+
};
|
|
177
|
+
}
|