github-issue-tower-defence-management 1.91.1 → 1.91.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [1.91.3](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.91.2...v1.91.3) (2026-06-21)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **console:** anchor read API paths at server root so per-project routes load detail content ([#879](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/879)) ([789e4a1](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/commit/789e4a1da0ec087e061f051de08af7d30e3735ef))
7
+
8
+ ## [1.91.2](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.91.1...v1.91.2) (2026-06-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **console:** apply processed-item overlay to all tab badges so a zeroed tab stays zero ([#871](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/871)) ([4eda1ab](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/commit/4eda1ab29cce54969c5fcc2b8a5ff838013978f7)), closes [#868](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/868)
14
+
1
15
  ## [1.91.1](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.91.0...v1.91.1) (2026-06-20)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.91.1",
3
+ "version": "1.91.3",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -20,10 +20,38 @@ describe('createConsoleApiClient', () => {
20
20
  const body = await client.fetchItemBody('https://github.com/o/r/issues/1');
21
21
  expect(body).toBe('# Title');
22
22
  const requested = fetchMock.mock.calls[0][0] as string;
23
- expect(requested).toContain('./api/itembody?url=');
23
+ expect(requested).toContain('/api/itembody?url=');
24
24
  expect(requested).toContain('&k=token');
25
25
  });
26
26
 
27
+ it('anchors every read endpoint at the server root regardless of route', async () => {
28
+ const readers: ((url: string) => Promise<unknown>)[] = [];
29
+ const client = createConsoleApiClient(appendToken);
30
+ readers.push((url) => client.fetchItemBody(url));
31
+ readers.push((url) => client.fetchComments(url));
32
+ readers.push((url) => client.fetchPrFiles(url));
33
+ readers.push((url) => client.fetchPrCommits(url));
34
+ readers.push((url) => client.fetchRelatedPrs(url));
35
+ readers.push((url) => client.fetchIssueState(url));
36
+ const expectedPaths = [
37
+ '/api/itembody',
38
+ '/api/comments',
39
+ '/api/prfiles',
40
+ '/api/prcommits',
41
+ '/api/relatedprs',
42
+ '/api/issuetitle',
43
+ ];
44
+ for (let index = 0; index < readers.length; index += 1) {
45
+ const fetchMock = mockFetchOnce({});
46
+ await readers[index]('https://github.com/o/r/issues/1');
47
+ const requested = fetchMock.mock.calls[0][0] as string;
48
+ expect(requested.startsWith(expectedPaths[index])).toBe(true);
49
+ expect(requested.startsWith('/api/')).toBe(true);
50
+ expect(requested.startsWith('./')).toBe(false);
51
+ expect(requested.startsWith('/projects')).toBe(false);
52
+ }
53
+ });
54
+
27
55
  it('parses comments', async () => {
28
56
  mockFetchOnce({
29
57
  comments: [
@@ -156,19 +156,19 @@ export const createConsoleApiClient = (
156
156
  appendToken: AppendToken,
157
157
  ): ConsoleApiClient => ({
158
158
  fetchItemBody: async (url) => {
159
- const payload = await requestJson(appendToken, './api/itembody', url);
159
+ const payload = await requestJson(appendToken, '/api/itembody', url);
160
160
  return isRecord(payload) ? getString(payload.body) : '';
161
161
  },
162
162
  fetchComments: async (url) =>
163
- parseComments(await requestJson(appendToken, './api/comments', url)),
163
+ parseComments(await requestJson(appendToken, '/api/comments', url)),
164
164
  fetchPrFiles: async (url) =>
165
- parseFiles(await requestJson(appendToken, './api/prfiles', url)),
165
+ parseFiles(await requestJson(appendToken, '/api/prfiles', url)),
166
166
  fetchPrCommits: async (url) =>
167
- parseCommits(await requestJson(appendToken, './api/prcommits', url)),
167
+ parseCommits(await requestJson(appendToken, '/api/prcommits', url)),
168
168
  fetchRelatedPrs: async (url) =>
169
- parseRelatedPrs(await requestJson(appendToken, './api/relatedprs', url)),
169
+ parseRelatedPrs(await requestJson(appendToken, '/api/relatedprs', url)),
170
170
  fetchIssueState: async (url) =>
171
- parseState(await requestJson(appendToken, './api/issuetitle', url)),
171
+ parseState(await requestJson(appendToken, '/api/issuetitle', url)),
172
172
  });
173
173
 
174
174
  export const postConsoleOperation = async (
@@ -1,12 +1,9 @@
1
1
  import {
2
2
  countPendingItems,
3
3
  filterPendingItems,
4
- getOverlayEntry,
5
- isOverlayEntryActedForMode,
6
- isOverlayEntryExpiredForMode,
4
+ isOverlayEntryActed,
7
5
  overlayKeyForItem,
8
6
  overlayStorageKey,
9
- parseGeneratedAtMs,
10
7
  writeOverlayEntry,
11
8
  } from './overlay';
12
9
  import type { ConsoleListItem, ConsoleOverlay } from './types';
@@ -34,68 +31,71 @@ describe('overlay helpers', () => {
34
31
  expect(overlayKeyForItem(item(5))).toBe('PVTI_5');
35
32
  });
36
33
 
37
- it('parses a generatedAt timestamp', () => {
38
- expect(parseGeneratedAtMs('2026-06-10T00:00:00.000Z')).toBe(
39
- Date.parse('2026-06-10T00:00:00.000Z'),
40
- );
41
- expect(parseGeneratedAtMs('not-a-date')).toBe(0);
34
+ it('falls back to the itemId when the projectItemId is empty', () => {
35
+ expect(overlayKeyForItem({ ...item(5), projectItemId: '' })).toBe('PVTI_5');
42
36
  });
43
37
  });
44
38
 
45
- describe('mode-aware expiry', () => {
46
- it('expires a same-mode entry written before the snapshot', () => {
47
- expect(
48
- isOverlayEntryExpiredForMode(
49
- { ts: 100, mode: 'prs', done: true },
50
- 200,
51
- 'prs',
52
- ),
53
- ).toBe(true);
39
+ describe('isOverlayEntryActed', () => {
40
+ it('treats a done entry as acted', () => {
41
+ expect(isOverlayEntryActed({ ts: 100, mode: 'prs', done: true })).toBe(
42
+ true,
43
+ );
54
44
  });
55
45
 
56
- it('does not expire an entry written in a different mode', () => {
57
- expect(
58
- isOverlayEntryExpiredForMode(
59
- { ts: 100, mode: 'triage', done: true },
60
- 200,
61
- 'prs',
62
- ),
63
- ).toBe(false);
46
+ it('treats a missing entry as not acted', () => {
47
+ expect(isOverlayEntryActed(undefined)).toBe(false);
64
48
  });
65
49
 
66
- it('does not expire an entry written after the snapshot', () => {
50
+ it('treats an entry without done as not acted', () => {
67
51
  expect(
68
- isOverlayEntryExpiredForMode(
69
- { ts: 300, mode: 'prs', done: true },
70
- 200,
71
- 'prs',
72
- ),
52
+ isOverlayEntryActed({
53
+ ts: 100,
54
+ mode: 'prs',
55
+ story: { name: 'Story', color: 'BLUE' },
56
+ }),
73
57
  ).toBe(false);
74
58
  });
59
+
60
+ it('treats a done entry as acted regardless of the mode it was written in', () => {
61
+ expect(isOverlayEntryActed({ ts: 100, mode: 'triage', done: true })).toBe(
62
+ true,
63
+ );
64
+ });
75
65
  });
76
66
 
77
67
  describe('counts driven to zero do not revive on tab switch', () => {
78
- it('keeps a done item subtracted in its own mode', () => {
68
+ it('keeps a done item subtracted in the tab it was processed in', () => {
79
69
  const overlay: ConsoleOverlay = {
80
70
  PVTI_1: { ts: 500, mode: 'prs', done: true },
81
71
  };
82
- const generatedAtMs = 400;
83
- expect(countPendingItems([item(1)], overlay, generatedAtMs, 'prs')).toBe(0);
72
+ expect(countPendingItems([item(1)], overlay)).toBe(0);
84
73
  });
85
74
 
86
- it('treats a done entry from another mode as still acted', () => {
75
+ it('keeps a done item subtracted from every tab regardless of its mode', () => {
87
76
  const overlay: ConsoleOverlay = {
88
77
  PVTI_1: { ts: 100, mode: 'triage', done: true },
89
78
  };
90
- expect(isOverlayEntryActedForMode(overlay.PVTI_1, 999, 'prs')).toBe(true);
91
- expect(countPendingItems([item(1)], overlay, 999, 'prs')).toBe(0);
79
+ expect(countPendingItems([item(1)], overlay)).toBe(0);
80
+ });
81
+
82
+ it('does not revive a done item even when an entry was processed before the snapshot it still appears in', () => {
83
+ const overlay: ConsoleOverlay = {
84
+ PVTI_1: { ts: 100, mode: 'prs', done: true },
85
+ };
86
+ expect(countPendingItems([item(1)], overlay)).toBe(0);
92
87
  });
93
88
 
94
- it('revives the count only when a newer same-mode snapshot supersedes the entry', () => {
89
+ it('does not revive a done item when it appears in a tab other than the one it was processed in', () => {
95
90
  const overlay: ConsoleOverlay = {
96
91
  PVTI_1: { ts: 100, mode: 'prs', done: true },
97
92
  };
98
- expect(countPendingItems([item(1)], overlay, 200, 'prs')).toBe(1);
93
+ expect(countPendingItems([item(1)], overlay)).toBe(0);
94
+ expect(filterPendingItems([item(1)], overlay)).toEqual([]);
95
+ });
96
+
97
+ it('counts an item that has no done entry', () => {
98
+ expect(countPendingItems([item(1)], {})).toBe(1);
99
99
  });
100
100
  });
101
101
 
@@ -104,21 +104,43 @@ describe('filterPendingItems', () => {
104
104
  const overlay: ConsoleOverlay = {
105
105
  PVTI_1: { ts: 500, mode: 'prs', done: true },
106
106
  };
107
- const result = filterPendingItems([item(1), item(2)], overlay, 400, 'prs');
107
+ const result = filterPendingItems([item(1), item(2)], overlay);
108
108
  expect(result.map((entry) => entry.number)).toEqual([2]);
109
109
  });
110
- });
111
110
 
112
- describe('getOverlayEntry and writeOverlayEntry', () => {
113
- it('returns null for an expired entry', () => {
111
+ it('keeps the badge count and the filtered list consistent', () => {
114
112
  const overlay: ConsoleOverlay = {
115
- PVTI_1: { ts: 100, mode: 'prs', done: true },
113
+ PVTI_1: { ts: 500, mode: 'prs', done: true },
116
114
  };
117
- expect(getOverlayEntry(overlay, item(1), 200, 'prs')).toBeNull();
115
+ const items = [item(1), item(2)];
116
+ expect(countPendingItems(items, overlay)).toBe(
117
+ filterPendingItems(items, overlay).length,
118
+ );
118
119
  });
120
+ });
119
121
 
122
+ describe('writeOverlayEntry', () => {
120
123
  it('stamps the timestamp and mode on write', () => {
121
124
  const next = writeOverlayEntry({}, 'PVTI_1', { done: true }, 'prs', 1234);
122
125
  expect(next.PVTI_1).toEqual({ done: true, ts: 1234, mode: 'prs' });
123
126
  });
127
+
128
+ it('merges a patch into an existing entry while refreshing ts and mode', () => {
129
+ const overlay: ConsoleOverlay = {
130
+ PVTI_1: { ts: 100, mode: 'prs', done: true },
131
+ };
132
+ const next = writeOverlayEntry(
133
+ overlay,
134
+ 'PVTI_1',
135
+ { story: { name: 'New Story', color: 'GREEN' } },
136
+ 'triage',
137
+ 999,
138
+ );
139
+ expect(next.PVTI_1).toEqual({
140
+ done: true,
141
+ story: { name: 'New Story', color: 'GREEN' },
142
+ ts: 999,
143
+ mode: 'triage',
144
+ });
145
+ });
124
146
  });
@@ -11,76 +11,23 @@ export const overlayStorageKey = (pjcode: string): string =>
11
11
  export const overlayKeyForItem = (item: ConsoleListItem): string =>
12
12
  item.projectItemId !== '' ? item.projectItemId : item.itemId;
13
13
 
14
- export const parseGeneratedAtMs = (generatedAt: string): number => {
15
- const parsed = Date.parse(generatedAt);
16
- return Number.isNaN(parsed) ? 0 : parsed;
17
- };
18
-
19
- export const isOverlayEntryExpiredForMode = (
20
- entry: ConsoleOverlayEntry,
21
- generatedAtMs: number,
22
- mode: ConsoleTabName,
23
- ): boolean =>
24
- entry.ts > 0 &&
25
- generatedAtMs > 0 &&
26
- entry.mode === mode &&
27
- entry.ts < generatedAtMs;
28
-
29
- export const getOverlayEntry = (
30
- overlay: ConsoleOverlay,
31
- item: ConsoleListItem,
32
- generatedAtMs: number,
33
- mode: ConsoleTabName,
34
- ): ConsoleOverlayEntry | null => {
35
- const entry = overlay[overlayKeyForItem(item)];
36
- if (entry === undefined) {
37
- return null;
38
- }
39
- if (isOverlayEntryExpiredForMode(entry, generatedAtMs, mode)) {
40
- return null;
41
- }
42
- return entry;
43
- };
44
-
45
- export const isOverlayEntryActedForMode = (
14
+ export const isOverlayEntryActed = (
46
15
  entry: ConsoleOverlayEntry | undefined,
47
- generatedAtMs: number,
48
- mode: ConsoleTabName,
49
- ): boolean => {
50
- if (entry === undefined || entry.done !== true) {
51
- return false;
52
- }
53
- return !isOverlayEntryExpiredForMode(entry, generatedAtMs, mode);
54
- };
16
+ ): boolean => entry !== undefined && entry.done === true;
55
17
 
56
18
  export const countPendingItems = (
57
19
  items: ConsoleListItem[],
58
20
  overlay: ConsoleOverlay,
59
- generatedAtMs: number,
60
- mode: ConsoleTabName,
61
21
  ): number =>
62
- items.filter(
63
- (item) =>
64
- !isOverlayEntryActedForMode(
65
- overlay[overlayKeyForItem(item)],
66
- generatedAtMs,
67
- mode,
68
- ),
69
- ).length;
22
+ items.filter((item) => !isOverlayEntryActed(overlay[overlayKeyForItem(item)]))
23
+ .length;
70
24
 
71
25
  export const filterPendingItems = (
72
26
  items: ConsoleListItem[],
73
27
  overlay: ConsoleOverlay,
74
- generatedAtMs: number,
75
- mode: ConsoleTabName,
76
28
  ): ConsoleListItem[] =>
77
29
  items.filter(
78
- (item) =>
79
- !isOverlayEntryActedForMode(
80
- overlay[overlayKeyForItem(item)],
81
- generatedAtMs,
82
- mode,
83
- ),
30
+ (item) => !isOverlayEntryActed(overlay[overlayKeyForItem(item)]),
84
31
  );
85
32
 
86
33
  export const writeOverlayEntry = (
@@ -28,7 +28,23 @@ const listPayload = (tab: string) => ({
28
28
  createdAt: '2026-06-17T00:00:00.000Z',
29
29
  },
30
30
  ]
31
- : [],
31
+ : tab === 'unread'
32
+ ? [
33
+ {
34
+ number: 866,
35
+ title: 'Notify finished issue preparation',
36
+ url: 'https://github.com/o/r/issues/866',
37
+ repo: 'o/r',
38
+ nameWithOwner: 'o/r',
39
+ projectItemId: 'PVTI_2',
40
+ itemId: 'PVTI_2',
41
+ isPr: false,
42
+ story: 'TDPM Console port',
43
+ labels: [],
44
+ createdAt: '2026-06-18T00:00:00.000Z',
45
+ },
46
+ ]
47
+ : [],
32
48
  });
33
49
 
34
50
  const installFetch = (): void => {
@@ -71,4 +87,42 @@ describe('ConsolePage', () => {
71
87
  expect(await findByText('← Back to list')).toBeInTheDocument();
72
88
  expect(getByText('Approve')).toBeInTheDocument();
73
89
  });
90
+
91
+ it('keeps a tab driven to zero at zero and does not revive its badge after switching tabs', async () => {
92
+ const { getByText, queryByText, findByText } = render(<ConsolePage />);
93
+ await waitFor(() => {
94
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
95
+ });
96
+ expect(
97
+ getByText('Awaiting Quality Check')
98
+ .closest('button')
99
+ ?.querySelector('.console-tab-badge')?.textContent,
100
+ ).toBe('1');
101
+
102
+ fireEvent.click(getByText('Add serveConsole subcommand'));
103
+ expect(await findByText('← Back to list')).toBeInTheDocument();
104
+ fireEvent.click(getByText('Approve'));
105
+
106
+ await waitFor(() => {
107
+ expect(
108
+ getByText('Awaiting Quality Check')
109
+ .closest('button')
110
+ ?.querySelector('.console-tab-badge')?.textContent,
111
+ ).toBe('0');
112
+ });
113
+
114
+ fireEvent.click(getByText('Unread'));
115
+ await waitFor(() => {
116
+ expect(
117
+ getByText('Notify finished issue preparation'),
118
+ ).toBeInTheDocument();
119
+ });
120
+
121
+ const prsTabButton = queryByText('Awaiting Quality Check')?.closest(
122
+ 'button',
123
+ );
124
+ const prsBadge =
125
+ prsTabButton?.querySelector('.console-tab-badge')?.textContent ?? '0';
126
+ expect(prsBadge).toBe('0');
127
+ });
74
128
  });
@@ -12,7 +12,6 @@ import {
12
12
  countPendingItems,
13
13
  filterPendingItems,
14
14
  overlayKeyForItem,
15
- parseGeneratedAtMs,
16
15
  } from '../logic/overlay';
17
16
  import type {
18
17
  ConsoleListItem,
@@ -57,8 +56,6 @@ export const ConsolePage = () => {
57
56
  result[tab.name] = countPendingItems(
58
57
  snapshot.items,
59
58
  overlayState.overlay,
60
- parseGeneratedAtMs(snapshot.generatedAt),
61
- tab.name,
62
59
  );
63
60
  }
64
61
  return result;
@@ -69,13 +66,8 @@ export const ConsolePage = () => {
69
66
  if (activeSnapshot === null) {
70
67
  return [];
71
68
  }
72
- return filterPendingItems(
73
- activeSnapshot.items,
74
- overlayState.overlay,
75
- parseGeneratedAtMs(activeSnapshot.generatedAt),
76
- activeTab,
77
- );
78
- }, [activeSnapshot, overlayState.overlay, activeTab]);
69
+ return filterPendingItems(activeSnapshot.items, overlayState.overlay);
70
+ }, [activeSnapshot, overlayState.overlay]);
79
71
 
80
72
  const rows = useMemo(
81
73
  () => buildConsoleListRows(pendingItems, overlayState.overlay),