github-issue-tower-defence-management 1.166.6 → 1.168.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 (51) hide show
  1. package/.github/workflows/console-ui.yml +1 -0
  2. package/CHANGELOG.md +20 -0
  3. package/bin/adapter/entry-points/cli/index.js +1 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/assets/index-DkAuY63u.js +86 -0
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/sw.js +60 -0
  8. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  9. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  10. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -26
  11. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  12. package/bin/domain/usecases/StartPreparationUseCase.js +19 -0
  13. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  14. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +7 -1
  15. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  16. package/bin/domain/usecases/ensureAgentOptionAndGetId.js +31 -0
  17. package/bin/domain/usecases/ensureAgentOptionAndGetId.js.map +1 -0
  18. package/package.json +1 -1
  19. package/src/adapter/ci/consoleUiWorkflowTimeout.test.ts +61 -0
  20. package/src/adapter/entry-points/cli/index.test.ts +33 -0
  21. package/src/adapter/entry-points/cli/index.ts +1 -0
  22. package/src/adapter/entry-points/console/ui/public/sw.js +60 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +9 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +31 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +8 -1
  26. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +111 -15
  27. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +100 -15
  28. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +70 -1
  29. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +42 -6
  30. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +2 -0
  31. package/src/adapter/entry-points/console/ui/src/main.tsx +6 -0
  32. package/src/adapter/entry-points/console/ui-dist/assets/index-DkAuY63u.js +86 -0
  33. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  34. package/src/adapter/entry-points/console/ui-dist/sw.js +60 -0
  35. package/src/domain/usecases/HandleScheduledEventUseCase.ts +1 -0
  36. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -38
  37. package/src/domain/usecases/StartPreparationUseCase.test.ts +199 -4
  38. package/src/domain/usecases/StartPreparationUseCase.ts +50 -1
  39. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +13 -0
  40. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +6 -1
  41. package/src/domain/usecases/ensureAgentOptionAndGetId.ts +47 -0
  42. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  43. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  44. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  45. package/types/domain/usecases/StartPreparationUseCase.d.ts +3 -1
  46. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  47. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  48. package/types/domain/usecases/ensureAgentOptionAndGetId.d.ts +4 -0
  49. package/types/domain/usecases/ensureAgentOptionAndGetId.d.ts.map +1 -0
  50. package/bin/adapter/entry-points/console/ui-dist/assets/index-CrSqMAVR.js +0 -86
  51. package/src/adapter/entry-points/console/ui-dist/assets/index-CrSqMAVR.js +0 -86
@@ -472,6 +472,7 @@ program
472
472
  manager,
473
473
  codexHomeCandidates,
474
474
  labelsAsLlmAgentName: config.labelsAsLlmAgentName ?? null,
475
+ agents: config.agents ?? null,
475
476
  });
476
477
  if (preparationResult.rotationOrder !== null) {
477
478
  writeRotationOrderFile(preparationResult.rotationOrder);
@@ -0,0 +1,60 @@
1
+ const SHELL_CACHE = 'console-shell-v1';
2
+
3
+ self.addEventListener('install', (event) => {
4
+ event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.add('/')));
5
+ self.skipWaiting();
6
+ });
7
+
8
+ self.addEventListener('activate', (event) => {
9
+ event.waitUntil(
10
+ caches
11
+ .keys()
12
+ .then((names) =>
13
+ Promise.all(
14
+ names
15
+ .filter((name) => name !== SHELL_CACHE)
16
+ .map((name) => caches.delete(name)),
17
+ ),
18
+ ),
19
+ );
20
+ self.clients.claim();
21
+ });
22
+
23
+ self.addEventListener('fetch', (event) => {
24
+ if (event.request.method !== 'GET') {
25
+ return;
26
+ }
27
+ const url = new URL(event.request.url);
28
+ if (url.origin !== self.location.origin) {
29
+ return;
30
+ }
31
+ if (
32
+ url.pathname.startsWith('/projects/') ||
33
+ url.pathname.startsWith('/api/')
34
+ ) {
35
+ return;
36
+ }
37
+ event.respondWith(
38
+ fetch(event.request)
39
+ .then((response) => {
40
+ if (response.ok) {
41
+ caches
42
+ .open(SHELL_CACHE)
43
+ .then((cache) => cache.put(event.request, response.clone()))
44
+ .catch((e) => console.warn('Shell cache put failed:', e));
45
+ }
46
+ return response;
47
+ })
48
+ .catch(async () => {
49
+ const cached = await caches.match(event.request);
50
+ if (cached !== undefined) {
51
+ return cached;
52
+ }
53
+ const root = await caches.match('/');
54
+ if (root !== undefined) {
55
+ return root;
56
+ }
57
+ return Response.error();
58
+ }),
59
+ );
60
+ });
@@ -9,6 +9,7 @@ const meta: Meta<typeof ConsoleTabList> = {
9
9
  args: {
10
10
  pjcode: 'acme',
11
11
  generatedAt: '2026-06-19T08:42:11.000Z',
12
+ fromCache: false,
12
13
  tabHref: (tab: ConsoleTabName) => `/projects/acme/${tab}`,
13
14
  onSelectTab: () => {},
14
15
  },
@@ -85,6 +86,14 @@ export const AfterAutoAdvanceToNextTab: Story = {
85
86
  },
86
87
  };
87
88
 
89
+ export const CachedSnapshot: Story = {
90
+ args: {
91
+ activeTab: 'prs',
92
+ counts,
93
+ fromCache: true,
94
+ },
95
+ };
96
+
88
97
  export const Interactive: Story = {
89
98
  render: (args) => {
90
99
  const [activeTab, setActiveTab] = useState<ConsoleTabName>('prs');
@@ -16,6 +16,7 @@ const counts: Record<ConsoleTabName, number> = {
16
16
  const baseProps = {
17
17
  pjcode: 'acme',
18
18
  generatedAt: '2026-06-19T08:42:11.000Z',
19
+ fromCache: false,
19
20
  tabHref: (tab: ConsoleTabName) => `/projects/acme/${tab}`,
20
21
  onSelectTab: () => {},
21
22
  };
@@ -116,4 +117,34 @@ describe('ConsoleTabList', () => {
116
117
  fireEvent.click(getByText('Unread'));
117
118
  expect(onSelectTab).toHaveBeenCalledWith('unread');
118
119
  });
120
+
121
+ it('prefixes the snapshot info with "(cached)" and sets data-from-cache when data is from cache', () => {
122
+ const { getByText } = render(
123
+ <ConsoleTabList
124
+ {...baseProps}
125
+ activeTab="prs"
126
+ counts={counts}
127
+ fromCache={true}
128
+ />,
129
+ );
130
+ const genInfo = getByText('(cached) snapshot: 2026-06-19T08:42:11.000Z');
131
+ expect(genInfo).toBeInTheDocument();
132
+ expect(genInfo).toHaveAttribute('data-from-cache', 'true');
133
+ });
134
+
135
+ it('omits the cache prefix and data-from-cache attribute when data is from the network', () => {
136
+ const { getByText, queryByText } = render(
137
+ <ConsoleTabList
138
+ {...baseProps}
139
+ activeTab="prs"
140
+ counts={counts}
141
+ fromCache={false}
142
+ />,
143
+ );
144
+ expect(getByText('snapshot: 2026-06-19T08:42:11.000Z')).toBeInTheDocument();
145
+ expect(
146
+ queryByText('(cached) snapshot: 2026-06-19T08:42:11.000Z'),
147
+ ).toBeNull();
148
+ expect(document.querySelector('[data-from-cache]')).toBeNull();
149
+ });
119
150
  });
@@ -5,6 +5,7 @@ export type ConsoleTabBarProps = {
5
5
  counts: Record<ConsoleTabName, number>;
6
6
  pjcode: string | null;
7
7
  generatedAt: string | null;
8
+ fromCache: boolean;
8
9
  tabHref: (tab: ConsoleTabName) => string;
9
10
  onSelectTab: (tab: ConsoleTabName) => void;
10
11
  };
@@ -14,6 +15,7 @@ export const ConsoleTabList = ({
14
15
  counts,
15
16
  pjcode,
16
17
  generatedAt,
18
+ fromCache,
17
19
  tabHref,
18
20
  onSelectTab,
19
21
  }: ConsoleTabBarProps) => {
@@ -59,7 +61,12 @@ export const ConsoleTabList = ({
59
61
  })}
60
62
  {pjcode !== null && <span className="console-tab-pjname">{pjcode}</span>}
61
63
  {generatedAt !== null && (
62
- <span className="console-tab-geninfo">snapshot: {generatedAt}</span>
64
+ <span
65
+ className="console-tab-geninfo"
66
+ data-from-cache={fromCache ? 'true' : undefined}
67
+ >
68
+ {fromCache ? '(cached) ' : ''}snapshot: {generatedAt}
69
+ </span>
63
70
  )}
64
71
  </nav>
65
72
  );
@@ -5,27 +5,72 @@ import {
5
5
  useConsoleTabData,
6
6
  } from './useConsoleTabData';
7
7
 
8
+ const makeTabPayload = (overrides: Record<string, unknown> = {}) => ({
9
+ pjcode: 'acme',
10
+ generatedAt: '2026-06-19T00:00:00.000Z',
11
+ statusOptions: [{ id: 's1', name: 'Unread', color: 'ORANGE' }],
12
+ storyColors: {},
13
+ items: [],
14
+ ...overrides,
15
+ });
16
+
17
+ const installNetworkFetch = (
18
+ perTabItems: (url: string) => unknown[] = () => [],
19
+ ): jest.Mock => {
20
+ const fetchMock = jest.fn(async (url: string) => ({
21
+ ok: true,
22
+ status: 200,
23
+ json: async () =>
24
+ makeTabPayload({
25
+ items: url.includes('/prs/') ? perTabItems(url) : [],
26
+ }),
27
+ }));
28
+ global.fetch = fetchMock as unknown as typeof fetch;
29
+ return fetchMock;
30
+ };
31
+
32
+ type MockCacheEntry = { json: jest.Mock };
33
+ type MockCache = {
34
+ match: jest.Mock<Promise<MockCacheEntry | undefined>>;
35
+ put: jest.Mock<Promise<void>>;
36
+ };
37
+
38
+ const installMockCaches = (
39
+ matchResult: MockCacheEntry | undefined,
40
+ ): MockCache => {
41
+ const mockCache: MockCache = {
42
+ match: jest.fn(async () => matchResult),
43
+ put: jest.fn(async () => undefined),
44
+ };
45
+ Object.defineProperty(global, 'caches', {
46
+ value: { open: jest.fn(async () => mockCache) },
47
+ writable: true,
48
+ configurable: true,
49
+ });
50
+ return mockCache;
51
+ };
52
+
53
+ const removeMockCaches = (): void => {
54
+ Reflect.deleteProperty(global as Record<string, unknown>, 'caches');
55
+ };
56
+
8
57
  describe('useConsoleTabData', () => {
9
58
  beforeEach(() => {
10
59
  localStorage.clear();
11
60
  window.history.replaceState({}, '', '/?k=token');
61
+ removeMockCaches();
62
+ });
63
+
64
+ afterEach(() => {
65
+ removeMockCaches();
12
66
  });
13
67
 
14
68
  it('fetches every tab once at startup and parses snapshots', async () => {
15
- const fetchMock = jest.fn(async (url: string) => ({
16
- ok: true,
17
- status: 200,
18
- json: async () => ({
19
- pjcode: 'acme',
20
- generatedAt: '2026-06-19T00:00:00.000Z',
21
- statusOptions: [{ id: 's1', name: 'Unread', color: 'ORANGE' }],
22
- storyColors: {},
23
- items: url.includes('/prs/')
24
- ? [{ number: 1, itemId: 'PVTI_1', projectItemId: 'PVTI_1' }]
25
- : [],
26
- }),
27
- }));
28
- global.fetch = fetchMock as unknown as typeof fetch;
69
+ const fetchMock = installNetworkFetch((url) =>
70
+ url.includes('/prs/')
71
+ ? [{ number: 1, itemId: 'PVTI_1', projectItemId: 'PVTI_1' }]
72
+ : [],
73
+ );
29
74
 
30
75
  const { result } = renderHook(() => useConsoleTabData('acme'));
31
76
  await waitFor(() => {
@@ -41,6 +86,15 @@ describe('useConsoleTabData', () => {
41
86
  );
42
87
  });
43
88
 
89
+ it('sets fromCache false when data is loaded from the network', async () => {
90
+ installNetworkFetch();
91
+ const { result } = renderHook(() => useConsoleTabData('acme'));
92
+ await waitFor(() => {
93
+ expect(result.current.isLoading).toBe(false);
94
+ });
95
+ expect(result.current.snapshots.prs?.fromCache).toBe(false);
96
+ });
97
+
44
98
  it('normalizes relatedOpenPullRequestUrls for a snapshot written before the field existed', async () => {
45
99
  const fetchMock = jest.fn(async (url: string) => ({
46
100
  ok: true,
@@ -116,7 +170,7 @@ describe('useConsoleTabData', () => {
116
170
  jest.useRealTimers();
117
171
  });
118
172
 
119
- it('surfaces an error when a tab fetch fails', async () => {
173
+ it('surfaces an error when a tab fetch fails and no cache is available', async () => {
120
174
  const fetchMock = jest.fn(async () => ({
121
175
  ok: false,
122
176
  status: 500,
@@ -129,6 +183,48 @@ describe('useConsoleTabData', () => {
129
183
  });
130
184
  });
131
185
 
186
+ it('serves board data from cache and sets fromCache true when every network fetch rejects', async () => {
187
+ const cachedPayload = makeTabPayload({
188
+ generatedAt: '2026-06-10T00:00:00.000Z',
189
+ items: [{ number: 1, itemId: 'PVTI_1', projectItemId: 'PVTI_1' }],
190
+ });
191
+ const cacheEntry = { json: jest.fn(async () => cachedPayload) };
192
+ installMockCaches(cacheEntry);
193
+
194
+ global.fetch = jest
195
+ .fn()
196
+ .mockRejectedValue(new Error('Network error')) as unknown as typeof fetch;
197
+
198
+ const { result } = renderHook(() => useConsoleTabData('acme'));
199
+ await waitFor(() => {
200
+ expect(result.current.isLoading).toBe(false);
201
+ });
202
+
203
+ expect(result.current.error).toBeNull();
204
+ expect(result.current.snapshots.prs?.fromCache).toBe(true);
205
+ expect(result.current.snapshots.prs?.generatedAt).toBe(
206
+ '2026-06-10T00:00:00.000Z',
207
+ );
208
+ expect(result.current.snapshots.prs?.items.length).toBe(1);
209
+ });
210
+
211
+ it('surfaces an error when network rejects and no cache entry exists', async () => {
212
+ installMockCaches(undefined);
213
+ global.fetch = jest
214
+ .fn()
215
+ .mockRejectedValue(new Error('offline')) as unknown as typeof fetch;
216
+
217
+ const { result } = renderHook(() => useConsoleTabData('acme'));
218
+ await waitFor(() => {
219
+ expect(result.current.isLoading).toBe(false);
220
+ });
221
+
222
+ expect(result.current.error).toBe('offline');
223
+ expect(
224
+ Object.values(result.current.snapshots).every((s) => s === null),
225
+ ).toBe(true);
226
+ });
227
+
132
228
  it('reports an error and fetches nothing when no pjcode is in the URL', async () => {
133
229
  const fetchMock = jest.fn();
134
230
  global.fetch = fetchMock as unknown as typeof fetch;
@@ -16,6 +16,7 @@ export type ConsoleTabSnapshot = {
16
16
  storyColors: ConsoleStoryColorSource;
17
17
  stories: ConsoleStoryEntry[];
18
18
  defaultNameWithOwner: string | null;
19
+ fromCache: boolean;
19
20
  };
20
21
 
21
22
  export type ConsoleTabDataState = {
@@ -61,7 +62,9 @@ const parseStoryEntries = (value: unknown): ConsoleStoryEntry[] => {
61
62
  return value.filter(isRecord) as unknown as ConsoleStoryEntry[];
62
63
  };
63
64
 
64
- const parseSnapshot = (payload: unknown): ConsoleTabSnapshot => ({
65
+ const parseSnapshotData = (
66
+ payload: unknown,
67
+ ): Omit<ConsoleTabSnapshot, 'fromCache'> => ({
65
68
  items: parseItems(payload),
66
69
  generatedAt:
67
70
  isRecord(payload) && typeof payload.generatedAt === 'string'
@@ -96,24 +99,106 @@ const buildListUrl = (pjcode: string, tab: ConsoleTabName): string =>
96
99
 
97
100
  export const CONSOLE_TAB_REFRESH_INTERVAL_MS = 60000;
98
101
 
102
+ const CONSOLE_LIST_CACHE_NAME = 'console-list-v1';
103
+
104
+ const persistListSnapshot = (url: string, payload: unknown): void => {
105
+ try {
106
+ if (!('caches' in globalThis)) {
107
+ return;
108
+ }
109
+ globalThis.caches
110
+ .open(CONSOLE_LIST_CACHE_NAME)
111
+ .then((cache) =>
112
+ cache.put(
113
+ url,
114
+ new Response(JSON.stringify(payload), {
115
+ headers: { 'Content-Type': 'application/json' },
116
+ }),
117
+ ),
118
+ )
119
+ .catch((e: unknown) => {
120
+ console.warn('Failed to persist tab snapshot to cache:', e);
121
+ });
122
+ } catch (e: unknown) {
123
+ console.warn('Failed to access cache storage:', e);
124
+ }
125
+ };
126
+
127
+ const loadListSnapshotFromCache = async (
128
+ url: string,
129
+ ): Promise<ConsoleTabSnapshot | null> => {
130
+ if (!('caches' in globalThis)) {
131
+ return null;
132
+ }
133
+ try {
134
+ const cache = await globalThis.caches.open(CONSOLE_LIST_CACHE_NAME);
135
+ const cached = await cache.match(url);
136
+ if (cached === undefined) {
137
+ return null;
138
+ }
139
+ const payload: unknown = await cached.json();
140
+ return { ...parseSnapshotData(payload), fromCache: true };
141
+ } catch {
142
+ return null;
143
+ }
144
+ };
145
+
146
+ const fetchSingleSnapshot = async (
147
+ pjcode: string,
148
+ tabName: ConsoleTabName,
149
+ ): Promise<{ snapshot: ConsoleTabSnapshot | null; error: Error | null }> => {
150
+ const url = buildListUrl(pjcode, tabName);
151
+ try {
152
+ const response = await fetch(url);
153
+ if (!response.ok) {
154
+ throw new Error(`HTTP ${response.status}`);
155
+ }
156
+ const payload: unknown = await response.json();
157
+ persistListSnapshot(url, payload);
158
+ return {
159
+ snapshot: { ...parseSnapshotData(payload), fromCache: false },
160
+ error: null,
161
+ };
162
+ } catch (e: unknown) {
163
+ const cached = await loadListSnapshotFromCache(url);
164
+ if (cached !== null) {
165
+ return { snapshot: cached, error: null };
166
+ }
167
+ return {
168
+ snapshot: null,
169
+ error: e instanceof Error ? e : new Error(String(e)),
170
+ };
171
+ }
172
+ };
173
+
99
174
  const fetchSnapshots = async (
100
175
  pjcode: string,
101
- ): Promise<Record<ConsoleTabName, ConsoleTabSnapshot | null>> => {
102
- const entries = await Promise.all(
176
+ ): Promise<{
177
+ snapshots: Record<ConsoleTabName, ConsoleTabSnapshot | null>;
178
+ firstError: Error | null;
179
+ }> => {
180
+ const results = await Promise.all(
103
181
  CONSOLE_TABS.map(async (tab) => {
104
- const response = await fetch(buildListUrl(pjcode, tab.name));
105
- if (!response.ok) {
106
- throw new Error(`HTTP ${response.status}`);
107
- }
108
- const payload: unknown = await response.json();
109
- return [tab.name, parseSnapshot(payload)] as const;
182
+ const { snapshot, error } = await fetchSingleSnapshot(pjcode, tab.name);
183
+ return { tabName: tab.name, snapshot, error };
110
184
  }),
111
185
  );
112
- const next = emptySnapshots();
113
- for (const [name, snapshot] of entries) {
114
- next[name] = snapshot;
186
+
187
+ const snapshots = emptySnapshots();
188
+ let firstError: Error | null = null;
189
+
190
+ for (const { tabName, snapshot, error } of results) {
191
+ snapshots[tabName] = snapshot;
192
+ if (error !== null && firstError === null) {
193
+ firstError = error;
194
+ }
115
195
  }
116
- return next;
196
+
197
+ const allNull = Object.values(snapshots).every((s) => s === null);
198
+ return {
199
+ snapshots,
200
+ firstError: allNull ? firstError : null,
201
+ };
117
202
  };
118
203
 
119
204
  export const useConsoleTabData = (
@@ -140,12 +225,12 @@ export const useConsoleTabData = (
140
225
 
141
226
  const load = (): void => {
142
227
  fetchSnapshots(pjcode)
143
- .then((next) => {
228
+ .then(({ snapshots: next, firstError }) => {
144
229
  if (cancelled) {
145
230
  return;
146
231
  }
147
232
  setSnapshots(next);
148
- setError(null);
233
+ setError(firstError !== null ? firstError.message : null);
149
234
  setIsLoading(false);
150
235
  })
151
236
  .catch((cause: unknown) => {
@@ -25,7 +25,40 @@ const mockFetchFailureOnce = (status: number, rawBody: string): jest.Mock => {
25
25
  return fetchMock;
26
26
  };
27
27
 
28
+ type MockCacheEntry = { json: jest.Mock };
29
+ type MockCache = {
30
+ match: jest.Mock<Promise<MockCacheEntry | undefined>>;
31
+ put: jest.Mock<Promise<void>>;
32
+ };
33
+
34
+ const installMockCaches = (
35
+ matchResult: MockCacheEntry | undefined,
36
+ ): MockCache => {
37
+ const mockCache: MockCache = {
38
+ match: jest.fn(async () => matchResult),
39
+ put: jest.fn(async () => undefined),
40
+ };
41
+ Object.defineProperty(global, 'caches', {
42
+ value: { open: jest.fn(async () => mockCache) },
43
+ writable: true,
44
+ configurable: true,
45
+ });
46
+ return mockCache;
47
+ };
48
+
49
+ const removeMockCaches = (): void => {
50
+ Reflect.deleteProperty(global as Record<string, unknown>, 'caches');
51
+ };
52
+
28
53
  describe('createConsoleApiClient', () => {
54
+ beforeEach(() => {
55
+ removeMockCaches();
56
+ });
57
+
58
+ afterEach(() => {
59
+ removeMockCaches();
60
+ });
61
+
29
62
  it('reads the item body from the api root without a token query', async () => {
30
63
  const fetchMock = mockFetchOnce({ body: '# Title' });
31
64
  const client = createConsoleApiClient();
@@ -243,13 +276,49 @@ describe('createConsoleApiClient', () => {
243
276
  });
244
277
  });
245
278
 
246
- it('throws on a non-ok response', async () => {
279
+ it('throws on a non-ok response when no cache is available', async () => {
247
280
  mockFetchOnce({}, false);
248
281
  const client = createConsoleApiClient();
249
282
  await expect(
250
283
  client.fetchComments('https://github.com/o/r/issues/1'),
251
284
  ).rejects.toThrow('HTTP 500');
252
285
  });
286
+
287
+ it('returns cached comments when the network fetch rejects', async () => {
288
+ const cachedBody = {
289
+ comments: [
290
+ {
291
+ author: 'alice',
292
+ body: 'hello',
293
+ createdAt: '2026-06-19T00:00:00.000Z',
294
+ },
295
+ ],
296
+ };
297
+ installMockCaches({ json: jest.fn(async () => cachedBody) });
298
+ global.fetch = jest
299
+ .fn()
300
+ .mockRejectedValue(new Error('offline')) as unknown as typeof fetch;
301
+
302
+ const client = createConsoleApiClient();
303
+ const comments = await client.fetchComments(
304
+ 'https://github.com/o/r/issues/1',
305
+ );
306
+ expect(comments).toEqual([
307
+ { author: 'alice', body: 'hello', createdAt: '2026-06-19T00:00:00.000Z' },
308
+ ]);
309
+ });
310
+
311
+ it('throws when the network fails and no cache entry exists', async () => {
312
+ installMockCaches(undefined);
313
+ global.fetch = jest
314
+ .fn()
315
+ .mockRejectedValue(new Error('offline')) as unknown as typeof fetch;
316
+
317
+ const client = createConsoleApiClient();
318
+ await expect(
319
+ client.fetchComments('https://github.com/o/r/issues/1'),
320
+ ).rejects.toThrow('offline');
321
+ });
253
322
  });
254
323
 
255
324
  describe('postConsoleOperation', () => {
@@ -70,17 +70,53 @@ const getNumber = (value: unknown): number =>
70
70
 
71
71
  const getBoolean = (value: unknown): boolean => value === true;
72
72
 
73
+ const CONSOLE_API_CACHE_NAME = 'console-api-v1';
74
+
73
75
  const requestJson = async (
74
76
  apiPath: string,
75
77
  resourceUrl: string,
76
78
  ): Promise<unknown> => {
77
- const response = await fetch(
78
- `${apiPath}?url=${encodeURIComponent(resourceUrl)}`,
79
- );
80
- if (!response.ok) {
81
- throw new Error(`HTTP ${response.status}`);
79
+ const url = `${apiPath}?url=${encodeURIComponent(resourceUrl)}`;
80
+ try {
81
+ const response = await fetch(url);
82
+ if (!response.ok) {
83
+ throw new Error(`HTTP ${response.status}`);
84
+ }
85
+ const payload: unknown = await response.json();
86
+ try {
87
+ if ('caches' in globalThis) {
88
+ globalThis.caches
89
+ .open(CONSOLE_API_CACHE_NAME)
90
+ .then((cache) =>
91
+ cache.put(
92
+ url,
93
+ new Response(JSON.stringify(payload), {
94
+ headers: { 'Content-Type': 'application/json' },
95
+ }),
96
+ ),
97
+ )
98
+ .catch((e: unknown) => {
99
+ console.warn('Failed to persist API response to cache:', e);
100
+ });
101
+ }
102
+ } catch (e: unknown) {
103
+ console.warn('Failed to access cache storage:', e);
104
+ }
105
+ return payload;
106
+ } catch (e: unknown) {
107
+ try {
108
+ if ('caches' in globalThis) {
109
+ const cache = await globalThis.caches.open(CONSOLE_API_CACHE_NAME);
110
+ const cached = await cache.match(url);
111
+ if (cached !== undefined) {
112
+ return cached.json();
113
+ }
114
+ }
115
+ } catch {
116
+ // cache read failure, fall through to original error
117
+ }
118
+ throw e;
82
119
  }
83
- return response.json();
84
120
  };
85
121
 
86
122
  const parseComments = (payload: unknown): ConsoleComment[] => {
@@ -126,6 +126,7 @@ export const ConsolePage = () => {
126
126
  const statusOptions = activeSnapshot?.statusOptions ?? [];
127
127
  const storyOptions = activeSnapshot?.storyOptions ?? [];
128
128
  const generatedAt = activeSnapshot?.generatedAt ?? null;
129
+ const fromCache = activeSnapshot?.fromCache ?? false;
129
130
 
130
131
  const selectedItem = useMemo<ConsoleListItem | null>(() => {
131
132
  if (selectedItemKey === null || activeSnapshot === null) {
@@ -274,6 +275,7 @@ export const ConsolePage = () => {
274
275
  counts={counts}
275
276
  pjcode={pjcode}
276
277
  generatedAt={generatedAt}
278
+ fromCache={fromCache}
277
279
  tabHref={navigation.tabHref}
278
280
  onSelectTab={navigation.selectTab}
279
281
  />
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client';
3
3
  import { ConsolePage } from '@/features/console/pages/ConsolePage';
4
4
  import './index.css';
5
5
 
6
+ if ('serviceWorker' in navigator) {
7
+ navigator.serviceWorker.register('/sw.js').catch((e: unknown) => {
8
+ console.warn('Service worker registration failed:', e);
9
+ });
10
+ }
11
+
6
12
  const container = document.getElementById('root');
7
13
  if (container === null) {
8
14
  throw new Error('Root container #root not found');