@agile-vibe-coding/avc 0.1.1 → 0.2.3

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 (289) hide show
  1. package/cli/agent-loader.js +21 -0
  2. package/cli/agents/agent-selector.md +129 -0
  3. package/cli/agents/architecture-recommender.md +418 -0
  4. package/cli/agents/database-deep-dive.md +470 -0
  5. package/cli/agents/database-recommender.md +634 -0
  6. package/cli/agents/doc-distributor.md +176 -0
  7. package/cli/agents/documentation-updater.md +203 -0
  8. package/cli/agents/epic-story-decomposer.md +280 -0
  9. package/cli/agents/feature-context-generator.md +91 -0
  10. package/cli/agents/gap-checker-epic.md +52 -0
  11. package/cli/agents/impact-checker-story.md +51 -0
  12. package/cli/agents/migration-guide-generator.md +305 -0
  13. package/cli/agents/mission-scope-generator.md +79 -0
  14. package/cli/agents/mission-scope-validator.md +112 -0
  15. package/cli/agents/project-context-extractor.md +107 -0
  16. package/cli/agents/project-documentation-creator.json +226 -0
  17. package/cli/agents/project-documentation-creator.md +595 -0
  18. package/cli/agents/question-prefiller.md +269 -0
  19. package/cli/agents/refiner-epic.md +39 -0
  20. package/cli/agents/refiner-story.md +42 -0
  21. package/cli/agents/solver-epic-api.json +15 -0
  22. package/cli/agents/solver-epic-api.md +39 -0
  23. package/cli/agents/solver-epic-backend.json +15 -0
  24. package/cli/agents/solver-epic-backend.md +39 -0
  25. package/cli/agents/solver-epic-cloud.json +15 -0
  26. package/cli/agents/solver-epic-cloud.md +39 -0
  27. package/cli/agents/solver-epic-data.json +15 -0
  28. package/cli/agents/solver-epic-data.md +39 -0
  29. package/cli/agents/solver-epic-database.json +15 -0
  30. package/cli/agents/solver-epic-database.md +39 -0
  31. package/cli/agents/solver-epic-developer.json +15 -0
  32. package/cli/agents/solver-epic-developer.md +39 -0
  33. package/cli/agents/solver-epic-devops.json +15 -0
  34. package/cli/agents/solver-epic-devops.md +39 -0
  35. package/cli/agents/solver-epic-frontend.json +15 -0
  36. package/cli/agents/solver-epic-frontend.md +39 -0
  37. package/cli/agents/solver-epic-mobile.json +15 -0
  38. package/cli/agents/solver-epic-mobile.md +39 -0
  39. package/cli/agents/solver-epic-qa.json +15 -0
  40. package/cli/agents/solver-epic-qa.md +39 -0
  41. package/cli/agents/solver-epic-security.json +15 -0
  42. package/cli/agents/solver-epic-security.md +39 -0
  43. package/cli/agents/solver-epic-solution-architect.json +15 -0
  44. package/cli/agents/solver-epic-solution-architect.md +39 -0
  45. package/cli/agents/solver-epic-test-architect.json +15 -0
  46. package/cli/agents/solver-epic-test-architect.md +39 -0
  47. package/cli/agents/solver-epic-ui.json +15 -0
  48. package/cli/agents/solver-epic-ui.md +39 -0
  49. package/cli/agents/solver-epic-ux.json +15 -0
  50. package/cli/agents/solver-epic-ux.md +39 -0
  51. package/cli/agents/solver-story-api.json +15 -0
  52. package/cli/agents/solver-story-api.md +39 -0
  53. package/cli/agents/solver-story-backend.json +15 -0
  54. package/cli/agents/solver-story-backend.md +39 -0
  55. package/cli/agents/solver-story-cloud.json +15 -0
  56. package/cli/agents/solver-story-cloud.md +39 -0
  57. package/cli/agents/solver-story-data.json +15 -0
  58. package/cli/agents/solver-story-data.md +39 -0
  59. package/cli/agents/solver-story-database.json +15 -0
  60. package/cli/agents/solver-story-database.md +39 -0
  61. package/cli/agents/solver-story-developer.json +15 -0
  62. package/cli/agents/solver-story-developer.md +39 -0
  63. package/cli/agents/solver-story-devops.json +15 -0
  64. package/cli/agents/solver-story-devops.md +39 -0
  65. package/cli/agents/solver-story-frontend.json +15 -0
  66. package/cli/agents/solver-story-frontend.md +39 -0
  67. package/cli/agents/solver-story-mobile.json +15 -0
  68. package/cli/agents/solver-story-mobile.md +39 -0
  69. package/cli/agents/solver-story-qa.json +15 -0
  70. package/cli/agents/solver-story-qa.md +39 -0
  71. package/cli/agents/solver-story-security.json +15 -0
  72. package/cli/agents/solver-story-security.md +39 -0
  73. package/cli/agents/solver-story-solution-architect.json +15 -0
  74. package/cli/agents/solver-story-solution-architect.md +39 -0
  75. package/cli/agents/solver-story-test-architect.json +15 -0
  76. package/cli/agents/solver-story-test-architect.md +39 -0
  77. package/cli/agents/solver-story-ui.json +15 -0
  78. package/cli/agents/solver-story-ui.md +39 -0
  79. package/cli/agents/solver-story-ux.json +15 -0
  80. package/cli/agents/solver-story-ux.md +39 -0
  81. package/cli/agents/story-doc-enricher.md +133 -0
  82. package/cli/agents/suggestion-business-analyst.md +88 -0
  83. package/cli/agents/suggestion-deployment-architect.md +263 -0
  84. package/cli/agents/suggestion-product-manager.md +129 -0
  85. package/cli/agents/suggestion-security-specialist.md +156 -0
  86. package/cli/agents/suggestion-technical-architect.md +269 -0
  87. package/cli/agents/suggestion-ux-researcher.md +93 -0
  88. package/cli/agents/task-subtask-decomposer.md +188 -0
  89. package/cli/agents/validator-documentation.json +152 -0
  90. package/cli/agents/validator-documentation.md +453 -0
  91. package/cli/agents/validator-epic-api.json +93 -0
  92. package/cli/agents/validator-epic-api.md +137 -0
  93. package/cli/agents/validator-epic-backend.json +93 -0
  94. package/cli/agents/validator-epic-backend.md +130 -0
  95. package/cli/agents/validator-epic-cloud.json +93 -0
  96. package/cli/agents/validator-epic-cloud.md +137 -0
  97. package/cli/agents/validator-epic-data.json +93 -0
  98. package/cli/agents/validator-epic-data.md +130 -0
  99. package/cli/agents/validator-epic-database.json +93 -0
  100. package/cli/agents/validator-epic-database.md +137 -0
  101. package/cli/agents/validator-epic-developer.json +74 -0
  102. package/cli/agents/validator-epic-developer.md +153 -0
  103. package/cli/agents/validator-epic-devops.json +74 -0
  104. package/cli/agents/validator-epic-devops.md +153 -0
  105. package/cli/agents/validator-epic-frontend.json +74 -0
  106. package/cli/agents/validator-epic-frontend.md +153 -0
  107. package/cli/agents/validator-epic-mobile.json +93 -0
  108. package/cli/agents/validator-epic-mobile.md +130 -0
  109. package/cli/agents/validator-epic-qa.json +93 -0
  110. package/cli/agents/validator-epic-qa.md +130 -0
  111. package/cli/agents/validator-epic-security.json +74 -0
  112. package/cli/agents/validator-epic-security.md +154 -0
  113. package/cli/agents/validator-epic-solution-architect.json +74 -0
  114. package/cli/agents/validator-epic-solution-architect.md +156 -0
  115. package/cli/agents/validator-epic-test-architect.json +93 -0
  116. package/cli/agents/validator-epic-test-architect.md +130 -0
  117. package/cli/agents/validator-epic-ui.json +93 -0
  118. package/cli/agents/validator-epic-ui.md +130 -0
  119. package/cli/agents/validator-epic-ux.json +93 -0
  120. package/cli/agents/validator-epic-ux.md +130 -0
  121. package/cli/agents/validator-selector.md +211 -0
  122. package/cli/agents/validator-story-api.json +104 -0
  123. package/cli/agents/validator-story-api.md +152 -0
  124. package/cli/agents/validator-story-backend.json +104 -0
  125. package/cli/agents/validator-story-backend.md +152 -0
  126. package/cli/agents/validator-story-cloud.json +104 -0
  127. package/cli/agents/validator-story-cloud.md +152 -0
  128. package/cli/agents/validator-story-data.json +104 -0
  129. package/cli/agents/validator-story-data.md +152 -0
  130. package/cli/agents/validator-story-database.json +104 -0
  131. package/cli/agents/validator-story-database.md +152 -0
  132. package/cli/agents/validator-story-developer.json +104 -0
  133. package/cli/agents/validator-story-developer.md +152 -0
  134. package/cli/agents/validator-story-devops.json +104 -0
  135. package/cli/agents/validator-story-devops.md +152 -0
  136. package/cli/agents/validator-story-frontend.json +104 -0
  137. package/cli/agents/validator-story-frontend.md +152 -0
  138. package/cli/agents/validator-story-mobile.json +104 -0
  139. package/cli/agents/validator-story-mobile.md +152 -0
  140. package/cli/agents/validator-story-qa.json +104 -0
  141. package/cli/agents/validator-story-qa.md +152 -0
  142. package/cli/agents/validator-story-security.json +104 -0
  143. package/cli/agents/validator-story-security.md +152 -0
  144. package/cli/agents/validator-story-solution-architect.json +104 -0
  145. package/cli/agents/validator-story-solution-architect.md +152 -0
  146. package/cli/agents/validator-story-test-architect.json +104 -0
  147. package/cli/agents/validator-story-test-architect.md +152 -0
  148. package/cli/agents/validator-story-ui.json +104 -0
  149. package/cli/agents/validator-story-ui.md +152 -0
  150. package/cli/agents/validator-story-ux.json +104 -0
  151. package/cli/agents/validator-story-ux.md +152 -0
  152. package/cli/ansi-colors.js +21 -0
  153. package/cli/build-docs.js +29 -8
  154. package/cli/ceremony-history.js +369 -0
  155. package/cli/command-logger.js +49 -12
  156. package/cli/components/static-output.js +63 -0
  157. package/cli/console-output-manager.js +94 -0
  158. package/cli/docs-sync.js +306 -0
  159. package/cli/epic-story-validator.js +1174 -0
  160. package/cli/evaluation-prompts.js +1008 -0
  161. package/cli/execution-context.js +195 -0
  162. package/cli/generate-summary-table.js +340 -0
  163. package/cli/index.js +0 -0
  164. package/cli/init-model-config.js +697 -0
  165. package/cli/init.js +1311 -274
  166. package/cli/kanban-server-manager.js +228 -0
  167. package/cli/llm-claude.js +83 -1
  168. package/cli/llm-gemini.js +85 -0
  169. package/cli/llm-mock.js +233 -0
  170. package/cli/llm-openai.js +233 -0
  171. package/cli/llm-provider.js +240 -3
  172. package/cli/llm-token-limits.js +102 -0
  173. package/cli/llm-verifier.js +454 -0
  174. package/cli/message-constants.js +58 -0
  175. package/cli/message-manager.js +334 -0
  176. package/cli/message-types.js +96 -0
  177. package/cli/messaging-api.js +297 -0
  178. package/cli/model-pricing.js +169 -0
  179. package/cli/model-query-engine.js +468 -0
  180. package/cli/model-recommendation-analyzer.js +495 -0
  181. package/cli/model-selector.js +269 -0
  182. package/cli/output-buffer.js +107 -0
  183. package/cli/process-manager.js +73 -2
  184. package/cli/repl-ink.js +4988 -1217
  185. package/cli/repl-old.js +4 -4
  186. package/cli/seed-processor.js +792 -0
  187. package/cli/sprint-planning-processor.js +1813 -0
  188. package/cli/template-processor.js +2102 -105
  189. package/cli/templates/project.md +25 -8
  190. package/cli/templates/vitepress-config.mts.template +5 -4
  191. package/cli/token-tracker.js +520 -0
  192. package/cli/tools/generate-story-validators.js +317 -0
  193. package/cli/tools/generate-validators.js +669 -0
  194. package/cli/update-checker.js +19 -17
  195. package/cli/update-notifier.js +4 -4
  196. package/cli/validation-router.js +605 -0
  197. package/cli/verification-tracker.js +563 -0
  198. package/kanban/README.md +386 -0
  199. package/kanban/client/README.md +205 -0
  200. package/kanban/client/components.json +20 -0
  201. package/kanban/client/dist/assets/index-CiD8PS2e.js +306 -0
  202. package/kanban/client/dist/assets/index-nLh0m82Q.css +1 -0
  203. package/kanban/client/dist/index.html +16 -0
  204. package/kanban/client/dist/vite.svg +1 -0
  205. package/kanban/client/index.html +15 -0
  206. package/kanban/client/package-lock.json +9442 -0
  207. package/kanban/client/package.json +44 -0
  208. package/kanban/client/postcss.config.js +6 -0
  209. package/kanban/client/public/vite.svg +1 -0
  210. package/kanban/client/src/App.jsx +622 -0
  211. package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
  212. package/kanban/client/src/components/ceremony/AskArchPopup.jsx +416 -0
  213. package/kanban/client/src/components/ceremony/AskModelPopup.jsx +616 -0
  214. package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +946 -0
  215. package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
  216. package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +619 -0
  217. package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +704 -0
  218. package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
  219. package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +154 -0
  220. package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
  221. package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
  222. package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
  223. package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +125 -0
  224. package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +228 -0
  225. package/kanban/client/src/components/kanban/CardDetailModal.jsx +559 -0
  226. package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
  227. package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
  228. package/kanban/client/src/components/kanban/GroupingSelector.jsx +57 -0
  229. package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
  230. package/kanban/client/src/components/kanban/KanbanCard.jsx +138 -0
  231. package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
  232. package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +789 -0
  233. package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
  234. package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
  235. package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
  236. package/kanban/client/src/components/settings/AgentsTab.jsx +353 -0
  237. package/kanban/client/src/components/settings/ApiKeysTab.jsx +113 -0
  238. package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +98 -0
  239. package/kanban/client/src/components/settings/CostThresholdsTab.jsx +94 -0
  240. package/kanban/client/src/components/settings/ModelPricingTab.jsx +204 -0
  241. package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
  242. package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
  243. package/kanban/client/src/components/stats/CostModal.jsx +353 -0
  244. package/kanban/client/src/components/ui/badge.jsx +27 -0
  245. package/kanban/client/src/components/ui/dialog.jsx +121 -0
  246. package/kanban/client/src/components/ui/tabs.jsx +85 -0
  247. package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
  248. package/kanban/client/src/hooks/useGrouping.js +118 -0
  249. package/kanban/client/src/hooks/useWebSocket.js +120 -0
  250. package/kanban/client/src/lib/__tests__/api.test.js +196 -0
  251. package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
  252. package/kanban/client/src/lib/api.js +401 -0
  253. package/kanban/client/src/lib/status-grouping.js +144 -0
  254. package/kanban/client/src/lib/utils.js +11 -0
  255. package/kanban/client/src/main.jsx +10 -0
  256. package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
  257. package/kanban/client/src/store/ceremonyStore.js +172 -0
  258. package/kanban/client/src/store/filterStore.js +201 -0
  259. package/kanban/client/src/store/kanbanStore.js +115 -0
  260. package/kanban/client/src/store/processStore.js +65 -0
  261. package/kanban/client/src/store/sprintPlanningStore.js +33 -0
  262. package/kanban/client/src/styles/globals.css +59 -0
  263. package/kanban/client/tailwind.config.js +77 -0
  264. package/kanban/client/vite.config.js +28 -0
  265. package/kanban/client/vitest.config.js +28 -0
  266. package/kanban/dev-start.sh +47 -0
  267. package/kanban/package.json +12 -0
  268. package/kanban/server/index.js +516 -0
  269. package/kanban/server/routes/ceremony.js +305 -0
  270. package/kanban/server/routes/costs.js +157 -0
  271. package/kanban/server/routes/processes.js +50 -0
  272. package/kanban/server/routes/settings.js +303 -0
  273. package/kanban/server/routes/websocket.js +276 -0
  274. package/kanban/server/routes/work-items.js +347 -0
  275. package/kanban/server/services/CeremonyService.js +1190 -0
  276. package/kanban/server/services/FileSystemScanner.js +95 -0
  277. package/kanban/server/services/FileWatcher.js +144 -0
  278. package/kanban/server/services/HierarchyBuilder.js +196 -0
  279. package/kanban/server/services/ProcessRegistry.js +122 -0
  280. package/kanban/server/services/WorkItemReader.js +123 -0
  281. package/kanban/server/services/WorkItemRefineService.js +510 -0
  282. package/kanban/server/start.js +49 -0
  283. package/kanban/server/utils/kanban-logger.js +132 -0
  284. package/kanban/server/utils/markdown.js +91 -0
  285. package/kanban/server/utils/status-grouping.js +107 -0
  286. package/kanban/server/workers/sponsor-call-worker.js +84 -0
  287. package/kanban/server/workers/sprint-planning-worker.js +130 -0
  288. package/package.json +18 -5
  289. package/cli/agents/documentation.md +0 -302
@@ -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,118 @@
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
+ default:
18
+ return groupByStatus(workItems);
19
+ }
20
+ }, [workItems, groupBy]);
21
+ }
22
+
23
+ /**
24
+ * Group by status (default kanban columns)
25
+ */
26
+ function groupByStatus(workItems) {
27
+ const grouped = groupItemsByColumn(workItems);
28
+
29
+ return {
30
+ mode: 'columns',
31
+ groups: COLUMN_ORDER.map((columnName) => ({
32
+ id: columnName,
33
+ name: columnName,
34
+ items: grouped[columnName] || [],
35
+ type: 'column',
36
+ })),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Group by epic (hierarchical sections)
42
+ */
43
+ function groupByEpic(workItems) {
44
+ // Get all epics
45
+ const epics = workItems.filter((item) => item.type === 'epic');
46
+
47
+ // Group items by epic
48
+ const groups = epics.map((epic) => {
49
+ // Get all descendants of this epic
50
+ const epicItems = workItems.filter(
51
+ (item) => item.epicId === epic.id
52
+ );
53
+
54
+ // Group epic's items by column
55
+ const columns = groupItemsByColumn(epicItems);
56
+
57
+ return {
58
+ id: epic.id,
59
+ name: epic.name,
60
+ epic: epic,
61
+ items: epicItems,
62
+ columns: columns,
63
+ type: 'epic',
64
+ };
65
+ });
66
+
67
+ // Add ungrouped items (items without an epic)
68
+ const ungroupedItems = workItems.filter(
69
+ (item) => !item.epicId && item.type !== 'epic'
70
+ );
71
+
72
+ if (ungroupedItems.length > 0) {
73
+ const columns = groupItemsByColumn(ungroupedItems);
74
+ groups.push({
75
+ id: 'ungrouped',
76
+ name: 'No Epic',
77
+ items: ungroupedItems,
78
+ columns: columns,
79
+ type: 'ungrouped',
80
+ });
81
+ }
82
+
83
+ return {
84
+ mode: 'sections',
85
+ groups,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Group by type (separate boards for each type)
91
+ */
92
+ function groupByType(workItems) {
93
+ const types = [
94
+ { id: 'epic', name: 'Epics' },
95
+ { id: 'story', name: 'Stories' },
96
+ { id: 'task', name: 'Tasks' },
97
+ { id: 'subtask', name: 'Subtasks' },
98
+ ];
99
+
100
+ const groups = types.map((type) => {
101
+ const typeItems = workItems.filter((item) => item.type === type.id);
102
+ const columns = groupItemsByColumn(typeItems);
103
+
104
+ return {
105
+ id: type.id,
106
+ name: type.name,
107
+ items: typeItems,
108
+ columns: columns,
109
+ type: 'type',
110
+ };
111
+ });
112
+
113
+ // Filter out empty groups
114
+ return {
115
+ mode: 'sections',
116
+ groups: groups.filter((group) => group.items.length > 0),
117
+ };
118
+ }
@@ -0,0 +1,120 @@
1
+ import { useEffect, useRef, useCallback, useState } from 'react';
2
+
3
+ // Module-level constant — window.location.host never changes during a session
4
+ const WS_URL = import.meta.env.VITE_WS_URL || `ws://${window.location.host}/ws`;
5
+
6
+ const MAX_RECONNECT_ATTEMPTS = 5;
7
+ const RECONNECT_DELAY = 2000;
8
+
9
+ /**
10
+ * WebSocket hook for real-time updates.
11
+ *
12
+ * Returns wsStatus: 'connecting' | 'connected' | 'disconnected'
13
+ * Callbacks are stored in refs so connect() is stable and the effect
14
+ * only runs once (no infinite reconnect loop from inline callbacks).
15
+ */
16
+ export function useWebSocket(options = {}) {
17
+ const { onMessage, onConnected, onDisconnected, onError } = options;
18
+
19
+ const [wsStatus, setWsStatus] = useState('connecting');
20
+
21
+ const wsRef = useRef(null);
22
+ const reconnectTimeoutRef = useRef(null);
23
+ const reconnectAttempts = useRef(0);
24
+
25
+ // Stable refs — updated every render without triggering reconnects
26
+ const onMessageRef = useRef(onMessage);
27
+ const onConnectedRef = useRef(onConnected);
28
+ const onDisconnectedRef = useRef(onDisconnected);
29
+ const onErrorRef = useRef(onError);
30
+ onMessageRef.current = onMessage;
31
+ onConnectedRef.current = onConnected;
32
+ onDisconnectedRef.current = onDisconnected;
33
+ onErrorRef.current = onError;
34
+
35
+ const connect = useCallback(() => {
36
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
37
+ return; // Already connected
38
+ }
39
+
40
+ try {
41
+ const ws = new WebSocket(WS_URL);
42
+
43
+ ws.onopen = () => {
44
+ console.log('WebSocket connected');
45
+ reconnectAttempts.current = 0;
46
+ setWsStatus('connected');
47
+ onConnectedRef.current?.();
48
+ };
49
+
50
+ ws.onmessage = (event) => {
51
+ try {
52
+ const data = JSON.parse(event.data);
53
+ onMessageRef.current?.(data);
54
+ } catch (error) {
55
+ console.error('WebSocket message parse error:', error);
56
+ }
57
+ };
58
+
59
+ ws.onerror = (error) => {
60
+ console.error('WebSocket error:', error);
61
+ onErrorRef.current?.(error);
62
+ };
63
+
64
+ ws.onclose = () => {
65
+ console.log('WebSocket disconnected');
66
+ wsRef.current = null;
67
+ onDisconnectedRef.current?.();
68
+
69
+ if (reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
70
+ reconnectAttempts.current++;
71
+ console.log(`Reconnecting... (attempt ${reconnectAttempts.current})`);
72
+ setWsStatus('connecting');
73
+ reconnectTimeoutRef.current = setTimeout(connect, RECONNECT_DELAY);
74
+ } else {
75
+ console.error('Max reconnection attempts reached');
76
+ setWsStatus('disconnected');
77
+ }
78
+ };
79
+
80
+ wsRef.current = ws;
81
+ } catch (error) {
82
+ console.error('WebSocket connection error:', error);
83
+ onErrorRef.current?.(error);
84
+ }
85
+ }, []); // No callback deps — refs keep them current without recreating connect
86
+
87
+ const disconnect = useCallback(() => {
88
+ if (reconnectTimeoutRef.current) {
89
+ clearTimeout(reconnectTimeoutRef.current);
90
+ reconnectTimeoutRef.current = null;
91
+ }
92
+
93
+ if (wsRef.current) {
94
+ wsRef.current.close();
95
+ wsRef.current = null;
96
+ }
97
+ }, []);
98
+
99
+ const send = useCallback((data) => {
100
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
101
+ wsRef.current.send(JSON.stringify(data));
102
+ } else {
103
+ console.warn('WebSocket not connected, cannot send message');
104
+ }
105
+ }, []);
106
+
107
+ useEffect(() => {
108
+ connect();
109
+ return () => {
110
+ disconnect();
111
+ };
112
+ }, [connect, disconnect]); // both stable — effect runs exactly once
113
+
114
+ return {
115
+ wsStatus,
116
+ send,
117
+ disconnect,
118
+ reconnect: connect,
119
+ };
120
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import {
3
+ getHealth,
4
+ getStats,
5
+ getWorkItems,
6
+ getWorkItemsGrouped,
7
+ getWorkItem,
8
+ getWorkItemDoc,
9
+ } from '../api';
10
+
11
+ // Mock fetch globally
12
+ global.fetch = vi.fn();
13
+
14
+ describe('api', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ describe('getHealth', () => {
20
+ it('should fetch health status successfully', async () => {
21
+ const mockResponse = { status: 'ok', uptime: 12345 };
22
+ global.fetch.mockResolvedValueOnce({
23
+ ok: true,
24
+ json: async () => mockResponse,
25
+ });
26
+
27
+ const result = await getHealth();
28
+
29
+ expect(fetch).toHaveBeenCalledWith('/api/health', {
30
+ headers: { 'Content-Type': 'application/json' },
31
+ });
32
+ expect(result).toEqual(mockResponse);
33
+ });
34
+
35
+ it('should throw error when fetch fails', async () => {
36
+ global.fetch.mockResolvedValueOnce({
37
+ ok: false,
38
+ status: 500,
39
+ statusText: 'Internal Server Error',
40
+ json: async () => ({ error: 'Server error' }),
41
+ });
42
+
43
+ await expect(getHealth()).rejects.toThrow();
44
+ });
45
+ });
46
+
47
+ describe('getStats', () => {
48
+ it('should fetch statistics successfully', async () => {
49
+ const mockStats = {
50
+ totalItems: 42,
51
+ byStatus: { planned: 10, implementing: 15 },
52
+ byType: { epic: 5, story: 20 },
53
+ };
54
+ global.fetch.mockResolvedValueOnce({
55
+ ok: true,
56
+ json: async () => mockStats,
57
+ });
58
+
59
+ const result = await getStats();
60
+
61
+ expect(fetch).toHaveBeenCalledWith('/api/stats', {
62
+ headers: { 'Content-Type': 'application/json' },
63
+ });
64
+ expect(result).toEqual(mockStats);
65
+ });
66
+ });
67
+
68
+ describe('getWorkItems', () => {
69
+ it('should fetch work items without filters', async () => {
70
+ const mockItems = [
71
+ { id: 'EPIC-001', type: 'epic', status: 'implementing' },
72
+ { id: 'STORY-001', type: 'story', status: 'ready' },
73
+ ];
74
+ global.fetch.mockResolvedValueOnce({
75
+ ok: true,
76
+ json: async () => mockItems,
77
+ });
78
+
79
+ const result = await getWorkItems();
80
+
81
+ expect(fetch).toHaveBeenCalledWith('/api/work-items', {
82
+ headers: { 'Content-Type': 'application/json' },
83
+ });
84
+ expect(result).toEqual(mockItems);
85
+ });
86
+
87
+ it('should fetch work items with type filter', async () => {
88
+ const mockItems = [{ id: 'EPIC-001', type: 'epic', status: 'implementing' }];
89
+ global.fetch.mockResolvedValueOnce({
90
+ ok: true,
91
+ json: async () => mockItems,
92
+ });
93
+
94
+ const result = await getWorkItems({ type: 'epic' });
95
+
96
+ expect(fetch).toHaveBeenCalledWith('/api/work-items?type=epic', {
97
+ headers: { 'Content-Type': 'application/json' },
98
+ });
99
+ expect(result).toEqual(mockItems);
100
+ });
101
+
102
+ it('should fetch work items with multiple filters', async () => {
103
+ const mockItems = [{ id: 'STORY-001', type: 'story', status: 'ready' }];
104
+ global.fetch.mockResolvedValueOnce({
105
+ ok: true,
106
+ json: async () => mockItems,
107
+ });
108
+
109
+ const result = await getWorkItems({ type: 'story', status: 'ready' });
110
+
111
+ expect(fetch).toHaveBeenCalledWith('/api/work-items?type=story&status=ready', {
112
+ headers: { 'Content-Type': 'application/json' },
113
+ });
114
+ expect(result).toEqual(mockItems);
115
+ });
116
+ });
117
+
118
+ describe('getWorkItemsGrouped', () => {
119
+ it('should fetch grouped work items', async () => {
120
+ const mockGrouped = {
121
+ Backlog: [{ id: 'STORY-001', status: 'planned' }],
122
+ Ready: [{ id: 'STORY-002', status: 'ready' }],
123
+ };
124
+ global.fetch.mockResolvedValueOnce({
125
+ ok: true,
126
+ json: async () => mockGrouped,
127
+ });
128
+
129
+ const result = await getWorkItemsGrouped();
130
+
131
+ expect(fetch).toHaveBeenCalledWith('/api/work-items/grouped', {
132
+ headers: { 'Content-Type': 'application/json' },
133
+ });
134
+ expect(result).toEqual(mockGrouped);
135
+ });
136
+ });
137
+
138
+ describe('getWorkItem', () => {
139
+ it('should fetch single work item successfully', async () => {
140
+ const mockItem = {
141
+ id: 'EPIC-001',
142
+ type: 'epic',
143
+ name: 'Test Epic',
144
+ status: 'implementing',
145
+ };
146
+ global.fetch.mockResolvedValueOnce({
147
+ ok: true,
148
+ json: async () => mockItem,
149
+ });
150
+
151
+ const result = await getWorkItem('EPIC-001');
152
+
153
+ expect(fetch).toHaveBeenCalledWith('/api/work-items/EPIC-001', {
154
+ headers: { 'Content-Type': 'application/json' },
155
+ });
156
+ expect(result).toEqual(mockItem);
157
+ });
158
+
159
+ it('should throw error for non-existent work item', async () => {
160
+ global.fetch.mockResolvedValueOnce({
161
+ ok: false,
162
+ status: 404,
163
+ statusText: 'Not Found',
164
+ json: async () => ({ error: 'Not found' }),
165
+ });
166
+
167
+ await expect(getWorkItem('NONEXISTENT')).rejects.toThrow();
168
+ });
169
+ });
170
+
171
+ describe('getWorkItemDoc', () => {
172
+ it('should fetch work item documentation', async () => {
173
+ const mockDoc = '# Epic Documentation\n\nDetails here...';
174
+ global.fetch.mockResolvedValueOnce({
175
+ ok: true,
176
+ text: async () => mockDoc,
177
+ });
178
+
179
+ const result = await getWorkItemDoc('EPIC-001');
180
+
181
+ expect(result).toBe(mockDoc);
182
+ });
183
+
184
+ it('should return empty string when doc not found', async () => {
185
+ global.fetch.mockResolvedValueOnce({
186
+ ok: false,
187
+ status: 404,
188
+ });
189
+
190
+ const result = await getWorkItemDoc('EPIC-001');
191
+
192
+ expect(result).toBe('');
193
+ });
194
+ });
195
+
196
+ });