github-issue-tower-defence-management 1.185.0 → 1.186.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 (150) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +2 -0
  3. package/bin/adapter/entry-points/cli/index.js +2 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleOperationApi.js +43 -1
  6. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  7. package/bin/adapter/entry-points/console/consoleProjectResolver.js +7 -1
  8. package/bin/adapter/entry-points/console/consoleProjectResolver.js.map +1 -1
  9. package/bin/adapter/entry-points/console/ui-dist/assets/index-wj6o8qvo.js +86 -0
  10. package/bin/adapter/entry-points/console/ui-dist/assets/index-yZy1TUOo.css +2 -0
  11. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  12. package/bin/adapter/entry-points/console/webServer.js +3 -0
  13. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +5 -1
  15. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  16. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  17. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  18. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +40 -3
  19. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -1
  20. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +36 -0
  21. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  22. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +12 -0
  23. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  24. package/bin/domain/entities/Project.js +11 -1
  25. package/bin/domain/entities/Project.js.map +1 -1
  26. package/bin/domain/usecases/HandleScheduledEventUseCase.js +12 -1
  27. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  28. package/bin/domain/usecases/IssueNoStatusUpdateUseCase.js +36 -0
  29. package/bin/domain/usecases/IssueNoStatusUpdateUseCase.js.map +1 -0
  30. package/bin/domain/usecases/IssueRejectionEvaluator.js +19 -0
  31. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  32. package/bin/domain/usecases/QualityCheckAdvanceUseCase.js +1 -0
  33. package/bin/domain/usecases/QualityCheckAdvanceUseCase.js.map +1 -1
  34. package/bin/domain/usecases/ReopenedDoneIssueRevertUseCase.js +36 -0
  35. package/bin/domain/usecases/ReopenedDoneIssueRevertUseCase.js.map +1 -0
  36. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +2 -5
  37. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  38. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +9 -1
  39. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/adapter/entry-points/cli/index.ts +14 -10
  42. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +199 -0
  43. package/src/adapter/entry-points/console/consoleOperationApi.ts +65 -0
  44. package/src/adapter/entry-points/console/consoleProjectResolver.test.ts +26 -0
  45. package/src/adapter/entry-points/console/consoleProjectResolver.ts +8 -1
  46. package/src/adapter/entry-points/console/dashboardEmitterComposerKey.test.ts +1 -0
  47. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +40 -0
  48. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +18 -0
  49. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +4 -0
  50. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +114 -141
  51. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +84 -3
  52. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +23 -0
  53. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +73 -0
  54. package/src/adapter/entry-points/console/ui/src/index.css +35 -0
  55. package/src/adapter/entry-points/console/ui-dist/assets/index-wj6o8qvo.js +86 -0
  56. package/src/adapter/entry-points/console/ui-dist/assets/index-yZy1TUOo.css +2 -0
  57. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  58. package/src/adapter/entry-points/console/webServer.ts +7 -0
  59. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
  60. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.test.ts +1 -0
  61. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +1 -0
  62. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +1 -0
  63. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  64. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  65. package/src/adapter/entry-points/handlers/ownerCallFileCleaner.test.ts +1 -0
  66. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +1 -0
  67. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +1 -0
  68. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +1 -0
  69. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  70. package/src/adapter/repositories/GitHubIssueCommentRepository.test.ts +1 -0
  71. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +48 -0
  72. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +55 -2
  73. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +90 -0
  74. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +48 -2
  75. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +107 -0
  76. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +20 -0
  77. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +1 -0
  78. package/src/domain/entities/ClaudeHandoverSession.ts +1 -0
  79. package/src/domain/entities/Issue.ts +1 -0
  80. package/src/domain/entities/Project.ts +11 -2
  81. package/src/domain/usecases/AgentDesignationLabelAdoptUseCase.test.ts +1 -0
  82. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +1 -0
  83. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  84. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +144 -0
  85. package/src/domain/usecases/HandleScheduledEventUseCase.ts +16 -0
  86. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +1 -0
  87. package/src/domain/usecases/IssueNoStatusUpdateUseCase.test.ts +165 -0
  88. package/src/domain/usecases/IssueNoStatusUpdateUseCase.ts +44 -0
  89. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +120 -0
  90. package/src/domain/usecases/IssueRejectionEvaluator.ts +21 -0
  91. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +1 -0
  92. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +1 -0
  93. package/src/domain/usecases/QualityCheckAdvanceUseCase.test.ts +34 -0
  94. package/src/domain/usecases/QualityCheckAdvanceUseCase.ts +1 -0
  95. package/src/domain/usecases/ReopenedDoneIssueRevertUseCase.test.ts +188 -0
  96. package/src/domain/usecases/ReopenedDoneIssueRevertUseCase.ts +57 -0
  97. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +44 -0
  98. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +4 -12
  99. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  100. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +1 -0
  101. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +1 -0
  102. package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -0
  103. package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +59 -1
  104. package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +14 -1
  105. package/src/domain/usecases/TriagerApprovalDispatchUseCase.test.ts +1 -0
  106. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +5 -0
  107. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +1 -0
  108. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +1 -0
  109. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +1 -0
  110. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  111. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
  112. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  113. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +2 -0
  114. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  115. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts +1 -0
  116. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts.map +1 -1
  117. package/types/adapter/entry-points/console/webServer.d.ts +2 -0
  118. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  119. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  120. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  121. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +1 -0
  122. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -1
  123. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
  124. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  125. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +1 -0
  126. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  127. package/types/domain/entities/ClaudeHandoverSession.d.ts +1 -0
  128. package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -1
  129. package/types/domain/entities/Issue.d.ts +1 -0
  130. package/types/domain/entities/Issue.d.ts.map +1 -1
  131. package/types/domain/entities/Project.d.ts +2 -1
  132. package/types/domain/entities/Project.d.ts.map +1 -1
  133. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +6 -1
  134. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  135. package/types/domain/usecases/IssueNoStatusUpdateUseCase.d.ts +12 -0
  136. package/types/domain/usecases/IssueNoStatusUpdateUseCase.d.ts.map +1 -0
  137. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +1 -0
  138. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  139. package/types/domain/usecases/QualityCheckAdvanceUseCase.d.ts.map +1 -1
  140. package/types/domain/usecases/ReopenedDoneIssueRevertUseCase.d.ts +12 -0
  141. package/types/domain/usecases/ReopenedDoneIssueRevertUseCase.d.ts.map +1 -0
  142. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
  143. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +1 -0
  144. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -1
  145. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -0
  146. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  147. package/bin/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +0 -2
  148. package/bin/adapter/entry-points/console/ui-dist/assets/index-DQT4HL0G.js +0 -86
  149. package/src/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +0 -2
  150. package/src/adapter/entry-points/console/ui-dist/assets/index-DQT4HL0G.js +0 -86
@@ -1,5 +1,5 @@
1
1
  import { fireEvent, render, waitFor } from '@testing-library/react';
2
- import type { ConsoleStoryEntry } from '../../logic/types';
2
+ import type { ConsoleColor, ConsoleStoryEntry } from '../../logic/types';
3
3
  import { ConsoleStoryList } from './ConsoleStoryList';
4
4
 
5
5
  const storyEntries: ConsoleStoryEntry[] = [
@@ -20,20 +20,20 @@ const storyEntries: ConsoleStoryEntry[] = [
20
20
  ];
21
21
 
22
22
  const defaultProps = {
23
+ stories: storyEntries,
24
+ isLoading: false,
25
+ error: null,
23
26
  onCreateIssue: () => Promise.resolve(),
24
27
  onAddStory: () => Promise.resolve(),
28
+ onSelectColor: () => undefined,
29
+ optimisticColors: {} as Record<string, ConsoleColor>,
30
+ colorChangeInFlight: null as string | null,
31
+ colorErrors: {} as Record<string, string>,
25
32
  };
26
33
 
27
34
  describe('ConsoleStoryList', () => {
28
35
  it('renders each story name and its open item count', () => {
29
- const { getByText } = render(
30
- <ConsoleStoryList
31
- stories={storyEntries}
32
- isLoading={false}
33
- error={null}
34
- {...defaultProps}
35
- />,
36
- );
36
+ const { getByText } = render(<ConsoleStoryList {...defaultProps} />);
37
37
  expect(getByText('TDPM Console port')).toBeInTheDocument();
38
38
  expect(getByText('12')).toBeInTheDocument();
39
39
  expect(getByText('Move to Okinawa')).toBeInTheDocument();
@@ -41,26 +41,14 @@ describe('ConsoleStoryList', () => {
41
41
  });
42
42
 
43
43
  it('renders an Add task button for each story row', () => {
44
- const { getAllByRole } = render(
45
- <ConsoleStoryList
46
- stories={storyEntries}
47
- isLoading={false}
48
- error={null}
49
- {...defaultProps}
50
- />,
51
- );
44
+ const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
52
45
  const buttons = getAllByRole('button', { name: 'Add task' });
53
46
  expect(buttons).toHaveLength(storyEntries.length);
54
47
  });
55
48
 
56
49
  it('shows the create form when Add task is clicked', () => {
57
50
  const { getAllByRole, getByPlaceholderText } = render(
58
- <ConsoleStoryList
59
- stories={storyEntries}
60
- isLoading={false}
61
- error={null}
62
- {...defaultProps}
63
- />,
51
+ <ConsoleStoryList {...defaultProps} />,
64
52
  );
65
53
  const [firstButton] = getAllByRole('button', { name: 'Add task' });
66
54
  fireEvent.click(firstButton);
@@ -69,12 +57,7 @@ describe('ConsoleStoryList', () => {
69
57
 
70
58
  it('hides the form when Add task is clicked again on the same row', () => {
71
59
  const { getAllByRole, queryByPlaceholderText } = render(
72
- <ConsoleStoryList
73
- stories={storyEntries}
74
- isLoading={false}
75
- error={null}
76
- {...defaultProps}
77
- />,
60
+ <ConsoleStoryList {...defaultProps} />,
78
61
  );
79
62
  const [firstButton] = getAllByRole('button', { name: 'Add task' });
80
63
  fireEvent.click(firstButton);
@@ -85,13 +68,7 @@ describe('ConsoleStoryList', () => {
85
68
  it('calls onCreateIssue with the correct storyOptionId and title', async () => {
86
69
  const onCreateIssue = jest.fn().mockResolvedValue(undefined);
87
70
  const { getAllByRole, getByPlaceholderText, getByRole } = render(
88
- <ConsoleStoryList
89
- stories={storyEntries}
90
- isLoading={false}
91
- error={null}
92
- onCreateIssue={onCreateIssue}
93
- onAddStory={() => Promise.resolve()}
94
- />,
71
+ <ConsoleStoryList {...defaultProps} onCreateIssue={onCreateIssue} />,
95
72
  );
96
73
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
97
74
  fireEvent.change(getByPlaceholderText('Issue title'), {
@@ -108,12 +85,7 @@ describe('ConsoleStoryList', () => {
108
85
 
109
86
  it('shows a validation error when Create is clicked with an empty title', () => {
110
87
  const { getAllByRole, getByRole, getByText } = render(
111
- <ConsoleStoryList
112
- stories={storyEntries}
113
- isLoading={false}
114
- error={null}
115
- {...defaultProps}
116
- />,
88
+ <ConsoleStoryList {...defaultProps} />,
117
89
  );
118
90
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
119
91
  fireEvent.click(getByRole('button', { name: 'Create' }));
@@ -128,13 +100,7 @@ describe('ConsoleStoryList', () => {
128
100
  getByRole,
129
101
  queryByPlaceholderText,
130
102
  } = render(
131
- <ConsoleStoryList
132
- stories={storyEntries}
133
- isLoading={false}
134
- error={null}
135
- onCreateIssue={onCreateIssue}
136
- onAddStory={() => Promise.resolve()}
137
- />,
103
+ <ConsoleStoryList {...defaultProps} onCreateIssue={onCreateIssue} />,
138
104
  );
139
105
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
140
106
  fireEvent.change(getByPlaceholderText('Issue title'), {
@@ -151,13 +117,7 @@ describe('ConsoleStoryList', () => {
151
117
  .fn()
152
118
  .mockRejectedValue(new Error('Network error'));
153
119
  const { getAllByRole, getByPlaceholderText, getByRole, getByText } = render(
154
- <ConsoleStoryList
155
- stories={storyEntries}
156
- isLoading={false}
157
- error={null}
158
- onCreateIssue={onCreateIssue}
159
- onAddStory={() => Promise.resolve()}
160
- />,
120
+ <ConsoleStoryList {...defaultProps} onCreateIssue={onCreateIssue} />,
161
121
  );
162
122
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
163
123
  fireEvent.change(getByPlaceholderText('Issue title'), {
@@ -169,36 +129,21 @@ describe('ConsoleStoryList', () => {
169
129
 
170
130
  it('shows a loading message when isLoading is true', () => {
171
131
  const { getByText } = render(
172
- <ConsoleStoryList
173
- stories={[]}
174
- isLoading={true}
175
- error={null}
176
- {...defaultProps}
177
- />,
132
+ <ConsoleStoryList {...defaultProps} stories={[]} isLoading={true} />,
178
133
  );
179
134
  expect(getByText('Loading stories...')).toBeInTheDocument();
180
135
  });
181
136
 
182
137
  it('shows an empty message when there are no stories', () => {
183
138
  const { getByText } = render(
184
- <ConsoleStoryList
185
- stories={[]}
186
- isLoading={false}
187
- error={null}
188
- {...defaultProps}
189
- />,
139
+ <ConsoleStoryList {...defaultProps} stories={[]} />,
190
140
  );
191
141
  expect(getByText('No active stories')).toBeInTheDocument();
192
142
  });
193
143
 
194
144
  it('shows an error message when error is set', () => {
195
145
  const { getByRole, getByText } = render(
196
- <ConsoleStoryList
197
- stories={[]}
198
- isLoading={false}
199
- error="HTTP 503"
200
- {...defaultProps}
201
- />,
146
+ <ConsoleStoryList {...defaultProps} stories={[]} error="HTTP 503" />,
202
147
  );
203
148
  expect(getByRole('alert')).toBeInTheDocument();
204
149
  expect(getByText(/HTTP 503/)).toBeInTheDocument();
@@ -212,28 +157,14 @@ describe('ConsoleStoryList', () => {
212
157
  openItemCount: 4,
213
158
  } as unknown as ConsoleStoryEntry;
214
159
  const { getByText } = render(
215
- <ConsoleStoryList
216
- stories={[oldFormatEntry]}
217
- isLoading={false}
218
- error={null}
219
- onCreateIssue={() => Promise.resolve()}
220
- onAddStory={() => Promise.resolve()}
221
- />,
160
+ <ConsoleStoryList {...defaultProps} stories={[oldFormatEntry]} />,
222
161
  );
223
162
  const nameEl = getByText('TDPM Console port');
224
163
  expect(nameEl.tagName).toBe('SPAN');
225
164
  });
226
165
 
227
166
  it('renders story name as a span when storyViewUrl is null', () => {
228
- const { getByText } = render(
229
- <ConsoleStoryList
230
- stories={storyEntries}
231
- isLoading={false}
232
- error={null}
233
- onCreateIssue={() => Promise.resolve()}
234
- onAddStory={() => Promise.resolve()}
235
- />,
236
- );
167
+ const { getByText } = render(<ConsoleStoryList {...defaultProps} />);
237
168
  const nameEl = getByText('TDPM Console port');
238
169
  expect(nameEl.tagName).toBe('SPAN');
239
170
  });
@@ -247,13 +178,7 @@ describe('ConsoleStoryList', () => {
247
178
  },
248
179
  ];
249
180
  const { getByText } = render(
250
- <ConsoleStoryList
251
- stories={entriesWithUrl}
252
- isLoading={false}
253
- error={null}
254
- onCreateIssue={() => Promise.resolve()}
255
- onAddStory={() => Promise.resolve()}
256
- />,
181
+ <ConsoleStoryList {...defaultProps} stories={entriesWithUrl} />,
257
182
  );
258
183
  const nameEl = getByText('TDPM Console port');
259
184
  expect(nameEl.tagName).toBe('A');
@@ -266,25 +191,13 @@ describe('ConsoleStoryList', () => {
266
191
  });
267
192
 
268
193
  it('renders an Add story button', () => {
269
- const { getByRole } = render(
270
- <ConsoleStoryList
271
- stories={storyEntries}
272
- isLoading={false}
273
- error={null}
274
- {...defaultProps}
275
- />,
276
- );
194
+ const { getByRole } = render(<ConsoleStoryList {...defaultProps} />);
277
195
  expect(getByRole('button', { name: 'Add story' })).toBeInTheDocument();
278
196
  });
279
197
 
280
198
  it('shows the Add story form when Add story button is clicked', () => {
281
199
  const { getByRole, getByPlaceholderText } = render(
282
- <ConsoleStoryList
283
- stories={storyEntries}
284
- isLoading={false}
285
- error={null}
286
- {...defaultProps}
287
- />,
200
+ <ConsoleStoryList {...defaultProps} />,
288
201
  );
289
202
  fireEvent.click(getByRole('button', { name: 'Add story' }));
290
203
  expect(getByPlaceholderText('Story name')).toBeInTheDocument();
@@ -292,12 +205,7 @@ describe('ConsoleStoryList', () => {
292
205
 
293
206
  it('hides the Add story form when Add story button is clicked again', () => {
294
207
  const { getByRole, queryByPlaceholderText } = render(
295
- <ConsoleStoryList
296
- stories={storyEntries}
297
- isLoading={false}
298
- error={null}
299
- {...defaultProps}
300
- />,
208
+ <ConsoleStoryList {...defaultProps} />,
301
209
  );
302
210
  fireEvent.click(getByRole('button', { name: 'Add story' }));
303
211
  fireEvent.click(getByRole('button', { name: 'Add story' }));
@@ -307,13 +215,7 @@ describe('ConsoleStoryList', () => {
307
215
  it('calls onAddStory with the story name and closes the form on success', async () => {
308
216
  const onAddStory = jest.fn().mockResolvedValue(undefined);
309
217
  const { getByRole, getByPlaceholderText, queryByPlaceholderText } = render(
310
- <ConsoleStoryList
311
- stories={storyEntries}
312
- isLoading={false}
313
- error={null}
314
- onCreateIssue={() => Promise.resolve()}
315
- onAddStory={onAddStory}
316
- />,
218
+ <ConsoleStoryList {...defaultProps} onAddStory={onAddStory} />,
317
219
  );
318
220
  fireEvent.click(getByRole('button', { name: 'Add story' }));
319
221
  fireEvent.change(getByPlaceholderText('Story name'), {
@@ -330,12 +232,7 @@ describe('ConsoleStoryList', () => {
330
232
 
331
233
  it('shows a validation error when Add story Create is clicked with an empty name', () => {
332
234
  const { getByRole, getByText } = render(
333
- <ConsoleStoryList
334
- stories={storyEntries}
335
- isLoading={false}
336
- error={null}
337
- {...defaultProps}
338
- />,
235
+ <ConsoleStoryList {...defaultProps} />,
339
236
  );
340
237
  fireEvent.click(getByRole('button', { name: 'Add story' }));
341
238
  fireEvent.click(getByRole('button', { name: 'Create' }));
@@ -345,13 +242,7 @@ describe('ConsoleStoryList', () => {
345
242
  it('shows an API error when onAddStory rejects', async () => {
346
243
  const onAddStory = jest.fn().mockRejectedValue(new Error('API failure'));
347
244
  const { getByRole, getByPlaceholderText, getByText } = render(
348
- <ConsoleStoryList
349
- stories={storyEntries}
350
- isLoading={false}
351
- error={null}
352
- onCreateIssue={() => Promise.resolve()}
353
- onAddStory={onAddStory}
354
- />,
245
+ <ConsoleStoryList {...defaultProps} onAddStory={onAddStory} />,
355
246
  );
356
247
  fireEvent.click(getByRole('button', { name: 'Add story' }));
357
248
  fireEvent.change(getByPlaceholderText('Story name'), {
@@ -363,13 +254,95 @@ describe('ConsoleStoryList', () => {
363
254
 
364
255
  it('renders Add story button even when there are no stories', () => {
365
256
  const { getByRole } = render(
257
+ <ConsoleStoryList {...defaultProps} stories={[]} />,
258
+ );
259
+ expect(getByRole('button', { name: 'Add story' })).toBeInTheDocument();
260
+ });
261
+
262
+ it('renders a Change color button for each story row', () => {
263
+ const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
264
+ const buttons = getAllByRole('button', { name: 'Change color' });
265
+ expect(buttons).toHaveLength(storyEntries.length);
266
+ });
267
+
268
+ it('shows the color palette when Change color is clicked', () => {
269
+ const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
270
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
271
+ fireEvent.click(firstColorButton);
272
+ expect(getAllByRole('button', { name: /GRAY \(disable\)/ })).toHaveLength(
273
+ 1,
274
+ );
275
+ });
276
+
277
+ it('hides the palette when Change color is clicked again on the same row', () => {
278
+ const { getAllByRole, queryByRole } = render(
279
+ <ConsoleStoryList {...defaultProps} />,
280
+ );
281
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
282
+ fireEvent.click(firstColorButton);
283
+ fireEvent.click(firstColorButton);
284
+ expect(queryByRole('button', { name: /GRAY \(disable\)/ })).toBeNull();
285
+ });
286
+
287
+ it('calls onSelectColor with the correct storyOptionId and color when a swatch is clicked', () => {
288
+ const onSelectColor = jest.fn();
289
+ const { getAllByRole } = render(
290
+ <ConsoleStoryList {...defaultProps} onSelectColor={onSelectColor} />,
291
+ );
292
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
293
+ fireEvent.click(firstColorButton);
294
+ const greenSwatch = getAllByRole('button', { name: 'GREEN' })[0];
295
+ fireEvent.click(greenSwatch);
296
+ expect(onSelectColor).toHaveBeenCalledWith('1491051e', 'GREEN');
297
+ });
298
+
299
+ it('closes the palette after a swatch is clicked', () => {
300
+ const { getAllByRole, queryByRole } = render(
301
+ <ConsoleStoryList {...defaultProps} />,
302
+ );
303
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
304
+ fireEvent.click(firstColorButton);
305
+ const greenSwatch = getAllByRole('button', { name: 'GREEN' })[0];
306
+ fireEvent.click(greenSwatch);
307
+ expect(queryByRole('button', { name: /GRAY \(disable\)/ })).toBeNull();
308
+ });
309
+
310
+ it('GRAY swatch carries an accessible disable label', () => {
311
+ const { getAllByRole, getByRole } = render(
312
+ <ConsoleStoryList {...defaultProps} />,
313
+ );
314
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
315
+ fireEvent.click(firstColorButton);
316
+ expect(getByRole('button', { name: 'GRAY (disable)' })).toBeInTheDocument();
317
+ });
318
+
319
+ it('disables Change color button when colorChangeInFlight matches that storyOptionId', () => {
320
+ const { getAllByRole } = render(
321
+ <ConsoleStoryList {...defaultProps} colorChangeInFlight="1491051e" />,
322
+ );
323
+ const colorButtons = getAllByRole('button', { name: 'Change color' });
324
+ expect(colorButtons[0]).toBeDisabled();
325
+ expect(colorButtons[1]).not.toBeDisabled();
326
+ });
327
+
328
+ it('displays an optimistic color from optimisticColors prop', () => {
329
+ const { getAllByRole } = render(
366
330
  <ConsoleStoryList
367
- stories={[]}
368
- isLoading={false}
369
- error={null}
370
331
  {...defaultProps}
332
+ optimisticColors={{ '1491051e': 'RED' }}
371
333
  />,
372
334
  );
373
- expect(getByRole('button', { name: 'Add story' })).toBeInTheDocument();
335
+ const colorButtons = getAllByRole('button', { name: 'Change color' });
336
+ expect(colorButtons[0]).toBeInTheDocument();
337
+ });
338
+
339
+ it('shows a per-row error message from colorErrors prop', () => {
340
+ const { getByText } = render(
341
+ <ConsoleStoryList
342
+ {...defaultProps}
343
+ colorErrors={{ '1491051e': 'Color update failed' }}
344
+ />,
345
+ );
346
+ expect(getByText('Color update failed')).toBeInTheDocument();
374
347
  });
375
348
  });
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import { colorFromEnum } from '../../logic/colors';
3
- import type { ConsoleStoryEntry } from '../../logic/types';
2
+ import { CONSOLE_COLOR_PALETTE, colorFromEnum } from '../../logic/colors';
3
+ import type { ConsoleColor, ConsoleStoryEntry } from '../../logic/types';
4
+
5
+ const ALL_COLORS = Object.keys(CONSOLE_COLOR_PALETTE) as ConsoleColor[];
4
6
 
5
7
  type InlineInputFormProps = {
6
8
  placeholder: string;
@@ -97,12 +99,44 @@ const StoryCreateForm = ({
97
99
  />
98
100
  );
99
101
 
102
+ type ColorPaletteProps = {
103
+ onSelectColor: (color: ConsoleColor) => void;
104
+ disabled: boolean;
105
+ };
106
+
107
+ const ColorPalette = ({ onSelectColor, disabled }: ColorPaletteProps) => (
108
+ <div className="console-story-color-palette">
109
+ {ALL_COLORS.map((color) => {
110
+ const palette = CONSOLE_COLOR_PALETTE[color];
111
+ return (
112
+ <button
113
+ key={color}
114
+ type="button"
115
+ className="console-story-color-swatch"
116
+ aria-label={color === 'GRAY' ? `${color} (disable)` : color}
117
+ style={{ backgroundColor: palette.dot }}
118
+ onClick={() => onSelectColor(color)}
119
+ disabled={disabled}
120
+ >
121
+ {color === 'GRAY' && (
122
+ <span className="console-story-color-swatch-label">disable</span>
123
+ )}
124
+ </button>
125
+ );
126
+ })}
127
+ </div>
128
+ );
129
+
100
130
  export type ConsoleStoryListProps = {
101
131
  stories: ConsoleStoryEntry[];
102
132
  isLoading: boolean;
103
133
  error: string | null;
104
134
  onCreateIssue: (storyOptionId: string, title: string) => Promise<void>;
105
135
  onAddStory: (storyName: string) => Promise<void>;
136
+ onSelectColor: (storyOptionId: string, newColor: ConsoleColor) => void;
137
+ optimisticColors: Record<string, ConsoleColor>;
138
+ colorChangeInFlight: string | null;
139
+ colorErrors: Record<string, string>;
106
140
  };
107
141
 
108
142
  export const ConsoleStoryList = ({
@@ -111,9 +145,16 @@ export const ConsoleStoryList = ({
111
145
  error,
112
146
  onCreateIssue,
113
147
  onAddStory,
148
+ onSelectColor,
149
+ optimisticColors,
150
+ colorChangeInFlight,
151
+ colorErrors,
114
152
  }: ConsoleStoryListProps) => {
115
153
  const [expandedOptionId, setExpandedOptionId] = useState<string | null>(null);
116
154
  const [addStoryExpanded, setAddStoryExpanded] = useState(false);
155
+ const [colorPickerOptionId, setColorPickerOptionId] = useState<string | null>(
156
+ null,
157
+ );
117
158
 
118
159
  if (error !== null) {
119
160
  return (
@@ -141,6 +182,20 @@ export const ConsoleStoryList = ({
141
182
  setExpandedOptionId(null);
142
183
  };
143
184
 
185
+ const handleColorButtonClick = (storyOptionId: string): void => {
186
+ setColorPickerOptionId(
187
+ colorPickerOptionId === storyOptionId ? null : storyOptionId,
188
+ );
189
+ };
190
+
191
+ const handleSwatchClick = (
192
+ storyOptionId: string,
193
+ newColor: ConsoleColor,
194
+ ): void => {
195
+ setColorPickerOptionId(null);
196
+ onSelectColor(storyOptionId, newColor);
197
+ };
198
+
144
199
  return (
145
200
  <div className="console-story-list-container">
146
201
  {stories.length === 0 ? (
@@ -148,8 +203,13 @@ export const ConsoleStoryList = ({
148
203
  ) : (
149
204
  <ul className="console-story-list">
150
205
  {stories.map((entry) => {
151
- const palette = colorFromEnum(entry.color);
206
+ const displayColor: ConsoleColor =
207
+ optimisticColors[entry.storyOptionId] ?? entry.color;
208
+ const palette = colorFromEnum(displayColor);
152
209
  const isExpanded = expandedOptionId === entry.storyOptionId;
210
+ const isPickerOpen = colorPickerOptionId === entry.storyOptionId;
211
+ const isInFlight = colorChangeInFlight === entry.storyOptionId;
212
+ const colorError = colorErrors[entry.storyOptionId] ?? null;
153
213
  return (
154
214
  <li key={entry.storyOptionId} className="console-story-list-row">
155
215
  <div className="console-story-list-row-main">
@@ -189,7 +249,28 @@ export const ConsoleStoryList = ({
189
249
  >
190
250
  Add task
191
251
  </button>
252
+ <button
253
+ type="button"
254
+ className="console-op-button"
255
+ onClick={() => handleColorButtonClick(entry.storyOptionId)}
256
+ disabled={isInFlight}
257
+ >
258
+ Change color
259
+ </button>
192
260
  </div>
261
+ {isPickerOpen && (
262
+ <ColorPalette
263
+ onSelectColor={(newColor) =>
264
+ handleSwatchClick(entry.storyOptionId, newColor)
265
+ }
266
+ disabled={isInFlight}
267
+ />
268
+ )}
269
+ {colorError !== null && (
270
+ <p role="alert" className="console-list-error">
271
+ {colorError}
272
+ </p>
273
+ )}
193
274
  {isExpanded && (
194
275
  <StoryCreateForm
195
276
  storyOptionId={entry.storyOptionId}
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ConsoleChangedFile,
3
+ ConsoleColor,
3
4
  ConsoleComment,
4
5
  ConsoleCommit,
5
6
  ConsoleIssueState,
@@ -418,6 +419,28 @@ export type ConsoleCreateIssueRequest = {
418
419
  nameWithOwner: string;
419
420
  };
420
421
 
422
+ export const STORY_COLOR_OPERATION_PATH = '/api/storycolor';
423
+
424
+ export type ConsoleStoryColorRequest = {
425
+ pjcode: string;
426
+ storyOptionId: string;
427
+ newColor: ConsoleColor;
428
+ nameWithOwner: string;
429
+ };
430
+
431
+ export const postConsoleStoryColor = async (
432
+ request: ConsoleStoryColorRequest,
433
+ ): Promise<void> => {
434
+ const response = await fetch(STORY_COLOR_OPERATION_PATH, {
435
+ method: 'POST',
436
+ headers: { 'Content-Type': 'application/json' },
437
+ body: JSON.stringify(request),
438
+ });
439
+ if (!response.ok) {
440
+ throw new Error(await readOperationErrorReason(response));
441
+ }
442
+ };
443
+
421
444
  export const postConsoleCreateIssue = async (
422
445
  request: ConsoleCreateIssueRequest,
423
446
  ): Promise<string> => {
@@ -24,6 +24,7 @@ import {
24
24
  postConsoleAddStory,
25
25
  postConsoleCreateIssue,
26
26
  postConsoleReorderStory,
27
+ postConsoleStoryColor,
27
28
  } from '../lib/consoleApi';
28
29
  import { navigateAssign, navigateReplace } from '../lib/navigation';
29
30
  import {
@@ -48,6 +49,7 @@ import type { ConsoleSwipeDirection } from '../logic/swipe';
48
49
  import { findNextNonEmptyTabToRight } from '../logic/tabAdvance';
49
50
  import { findNextPjcodeWithMinutes } from '../logic/timerSettings';
50
51
  import type {
52
+ ConsoleColor,
51
53
  ConsoleListItem,
52
54
  ConsoleOverlayStatus,
53
55
  ConsoleTabName,
@@ -386,6 +388,73 @@ export const ConsolePage = () => {
386
388
  [pjcode],
387
389
  );
388
390
 
391
+ const [storyOptimisticColors, setStoryOptimisticColors] = useState<
392
+ Record<string, ConsoleColor>
393
+ >({});
394
+ const [storyColorChangeInFlight, setStoryColorChangeInFlight] = useState<
395
+ string | null
396
+ >(null);
397
+ const [storyColorErrors, setStoryColorErrors] = useState<
398
+ Record<string, string>
399
+ >({});
400
+
401
+ const handleSelectColor = useCallback(
402
+ (storyOptionId: string, newColor: ConsoleColor): void => {
403
+ const originalEntry = storyEntries.find(
404
+ (e) => e.storyOptionId === storyOptionId,
405
+ );
406
+ const originalColor = originalEntry?.color;
407
+ setStoryColorChangeInFlight(storyOptionId);
408
+ setStoryOptimisticColors((prev) => ({
409
+ ...prev,
410
+ [storyOptionId]: newColor,
411
+ }));
412
+ setStoryColorErrors((prev) => {
413
+ const next = { ...prev };
414
+ delete next[storyOptionId];
415
+ return next;
416
+ });
417
+ if (pjcode === null || defaultNameWithOwner === null) {
418
+ setStoryOptimisticColors((prev) =>
419
+ originalColor !== undefined
420
+ ? { ...prev, [storyOptionId]: originalColor }
421
+ : prev,
422
+ );
423
+ setStoryColorErrors((prev) => ({
424
+ ...prev,
425
+ [storyOptionId]: 'No project or repository configured.',
426
+ }));
427
+ setStoryColorChangeInFlight((current) =>
428
+ current === storyOptionId ? null : current,
429
+ );
430
+ return;
431
+ }
432
+ postConsoleStoryColor({
433
+ pjcode,
434
+ storyOptionId,
435
+ newColor,
436
+ nameWithOwner: defaultNameWithOwner,
437
+ })
438
+ .catch((err: unknown) => {
439
+ setStoryOptimisticColors((prev) =>
440
+ originalColor !== undefined
441
+ ? { ...prev, [storyOptionId]: originalColor }
442
+ : prev,
443
+ );
444
+ setStoryColorErrors((prev) => ({
445
+ ...prev,
446
+ [storyOptionId]: err instanceof Error ? err.message : String(err),
447
+ }));
448
+ })
449
+ .finally(() => {
450
+ setStoryColorChangeInFlight((current) =>
451
+ current === storyOptionId ? null : current,
452
+ );
453
+ });
454
+ },
455
+ [pjcode, defaultNameWithOwner, storyEntries],
456
+ );
457
+
389
458
  return (
390
459
  <main className="console-app">
391
460
  {actionQueue.pending !== null && (
@@ -433,6 +502,10 @@ export const ConsolePage = () => {
433
502
  error={error}
434
503
  onCreateIssue={handleCreateIssue}
435
504
  onAddStory={handleStoryAdd}
505
+ onSelectColor={handleSelectColor}
506
+ optimisticColors={storyOptimisticColors}
507
+ colorChangeInFlight={storyColorChangeInFlight}
508
+ colorErrors={storyColorErrors}
436
509
  />
437
510
  ) : selectedItem === null ? (
438
511
  <>