issue-map 0.1.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.
@@ -0,0 +1,825 @@
1
+ /**
2
+ * 開發地圖的畫面。`issue-map.ts` 用 `Bun.build` 把這支打包成一段 script 塞進樣板,所以它
3
+ * **只能碰瀏覽器的東西**,不能 import Bun 的 API。
4
+ *
5
+ * 形狀與純推導在 `issue-map-model.ts`:狀態的五個值、票的形狀、分群、關鍵路徑、線路圖的排版
6
+ * 都在那邊,兩側共用同一份定義。這裡剩下的是 DOM、事件與收合狀態。
7
+ */
8
+
9
+ import {
10
+ layoutOf,
11
+ MAP,
12
+ type Edge,
13
+ type Group,
14
+ type Layout,
15
+ type MapIssue,
16
+ type Point,
17
+ type Snapshot,
18
+ STATUS_LABEL,
19
+ STATUS_ORDER,
20
+ type Status,
21
+ } from './issue-map-model.ts'
22
+
23
+ // ---- 資料 ----
24
+
25
+ const SVG_NS = 'http://www.w3.org/2000/svg'
26
+
27
+ /** 樣板保證這些節點存在。找不到就是樣板被改壞了,早點喊比畫出半張圖好。 */
28
+ function pick(id: string): HTMLElement {
29
+ const node = document.getElementById(id)
30
+ if (!node) throw new Error(`樣板缺少 #${id}`)
31
+ return node
32
+ }
33
+
34
+ const raw = JSON.parse(pick('issue-map-data').textContent || '{}') as Partial<Snapshot>
35
+ const issues: readonly MapIssue[] = raw.issues ?? []
36
+ const byNumber = new Map(issues.map((issue) => [issue.number, issue]))
37
+ const work = issues.filter((issue) => !issue.isParent)
38
+ const criticalPath = raw.criticalPath ?? 0
39
+ const repo = raw.repo ?? ''
40
+
41
+ const counts = Object.fromEntries(
42
+ STATUS_ORDER.map((status) => [status, work.filter((i) => i.status === status).length]),
43
+ ) as Record<Status, number>
44
+ const openCount = work.length - counts.done
45
+
46
+ /** 誰擋著誰的反向索引。亮鏈與「關掉後解鎖」都用它。 */
47
+ const unlocks = new Map<number, number[]>()
48
+ for (const issue of issues) {
49
+ for (const blocker of issue.blockedBy) {
50
+ const dependents = unlocks.get(blocker) ?? []
51
+ dependents.push(issue.number)
52
+ unlocks.set(blocker, dependents)
53
+ }
54
+ }
55
+
56
+ function byStatusThenNumber(a: MapIssue, b: MapIssue): number {
57
+ return STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || a.number - b.number
58
+ }
59
+
60
+ const ESCAPES: Readonly<Record<string, string>> = {
61
+ '&': '&amp;',
62
+ '<': '&lt;',
63
+ '>': '&gt;',
64
+ '"': '&quot;',
65
+ }
66
+
67
+ function esc(text: string): string {
68
+ return text.replace(/[&<>"]/g, (c) => ESCAPES[c] ?? c)
69
+ }
70
+
71
+ /**
72
+ * 站名就是標題開頭。刻意不另設一個「短名」欄位——那要每張票靠人維護,而且會變成票名的第二個
73
+ * 來源,改了標題不會跟著改。截到讀不通的時候,滑鼠停留與下面的清單都有完整標題。
74
+ */
75
+ const HEAD_CHARS = 7
76
+
77
+ function head(title: string): string {
78
+ return title.length > HEAD_CHARS ? `${title.slice(0, HEAD_CHARS)}…` : title
79
+ }
80
+
81
+ function issueAt(number: number): MapIssue | undefined {
82
+ return byNumber.get(number)
83
+ }
84
+
85
+ // ---- 收合狀態 ----
86
+
87
+ /**
88
+ * 收合狀態只有一份,地圖與清單都讀它、都能改它。每個瀏覽器記自己的;讀不到(無痕、封鎖)
89
+ * 就當成全部展開。
90
+ */
91
+ const COLLAPSE_KEY = `issue-map:collapsed:${repo}`
92
+ const onFold: (() => void)[] = []
93
+
94
+ function readFolded(): Set<string> {
95
+ try {
96
+ return new Set(JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '[]') as string[])
97
+ } catch {
98
+ return new Set()
99
+ }
100
+ }
101
+
102
+ const folded = readFolded()
103
+
104
+ function isFolded(parent: number): boolean {
105
+ return folded.has(String(parent))
106
+ }
107
+
108
+ function setFolded(parent: number, shut: boolean): void {
109
+ if (shut) folded.add(String(parent))
110
+ else folded.delete(String(parent))
111
+ try {
112
+ localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...folded]))
113
+ } catch {
114
+ // 存不了就只在這一次有效,畫面照樣能開合。
115
+ }
116
+ for (const repaint of onFold) repaint()
117
+ }
118
+
119
+ /** 做一顆收合鈕。`parent` 是主票號碼,同一個號碼的鈕與清單列連動。 */
120
+ function foldButton(parent: number): HTMLButtonElement {
121
+ const button = document.createElement('button')
122
+ button.type = 'button'
123
+ button.className = 'fold'
124
+ const paint = () => {
125
+ const open = !isFolded(parent)
126
+ button.setAttribute('aria-expanded', String(open))
127
+ button.setAttribute('aria-label', `${open ? '收起' : '展開'} #${parent} 的子票`)
128
+ button.textContent = open ? '−' : '+'
129
+ }
130
+ paint()
131
+ onFold.push(paint)
132
+ button.addEventListener('click', () => setFolded(parent, !isFolded(parent)))
133
+ return button
134
+ }
135
+
136
+ /** 收合只在這裡畫一次:列、就地展開的細節、地圖本體,凡是掛了 data-parent 的都聽它。 */
137
+ function paintFolded(): void {
138
+ for (const node of document.querySelectorAll<HTMLElement>('[data-parent]')) {
139
+ node.hidden = isFolded(Number(node.dataset.parent))
140
+ }
141
+ for (const section of document.querySelectorAll<HTMLElement>('section.group[data-fold]')) {
142
+ section.dataset.folded = String(isFolded(Number(section.dataset.fold)))
143
+ }
144
+ }
145
+
146
+ // ---- 抬頭 ----
147
+
148
+ function renderHeader(): void {
149
+ const stats = pick('stats')
150
+ const tiles: { label: string; value: number; unit?: string; tone?: string }[] = [
151
+ { label: '現在可動', value: counts.ready, tone: 'ready' },
152
+ { label: '有人接手', value: counts.active, tone: 'active' },
153
+ { label: '被前置擋住', value: counts.blocked, unit: `/ ${openCount}`, tone: 'blocked' },
154
+ { label: '規格未定案', value: counts.triage, tone: 'triage' },
155
+ { label: '關鍵路徑', value: criticalPath, unit: '張' },
156
+ ]
157
+ for (const tile of tiles) {
158
+ const box = document.createElement('div')
159
+ box.className = 'stat'
160
+ if (tile.tone) box.dataset.tone = tile.tone
161
+ const unit = tile.unit ? `<small>${tile.unit}</small>` : ''
162
+ box.innerHTML = `<b>${tile.value}${unit}</b><span>${tile.label}</span>`
163
+ stats.appendChild(box)
164
+ }
165
+
166
+ // 抬頭全部照資料寫,換 repo 或換標籤字彙才不會留著上一個專案的字。
167
+ const heading = `${repo.split('/').pop() || '開發地圖'} 開發地圖`
168
+ pick('page-title').textContent = heading
169
+ document.title = heading
170
+ const when = raw.generatedAt
171
+ ? ` · ${new Date(raw.generatedAt).toLocaleString('zh-TW', { hour12: false })}`
172
+ : ''
173
+ pick('eyebrow').textContent = repo + when
174
+
175
+ pick('lede').innerHTML = lede()
176
+
177
+ const vocab = raw.labels ?? { ready: [], unready: [] }
178
+ pick('foot-config').textContent = vocab.ready.length
179
+ ? `這一次的判準:掛 ${vocab.ready.join(' 或 ')} 才算可動;掛 ${vocab.unready.join('/')} 或沒掛角色標籤算未定案。`
180
+ : '這個 repo 沒有在用 triage 標籤,狀態只看阻擋與接手。'
181
+ }
182
+
183
+ /** 導言只講數得出來的事實。沒有可動的票、或整批都關完了,句子跟著換。 */
184
+ function lede(): string {
185
+ if (!openCount) return '這個 repo 沒有未完成的票。'
186
+ const frontline = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
187
+ const parts = [`${openCount} 張未完成。`]
188
+ if (frontline.length) {
189
+ const first = frontline
190
+ .slice(0, 3)
191
+ .map((i) => `<a href="${esc(i.url)}" target="_blank" rel="noopener">#${i.number}</a>`)
192
+ .join('、')
193
+ parts.push(`<strong>現在可動 ${frontline.length} 張,最前面是 ${first}</strong>。`)
194
+ } else {
195
+ parts.push('<strong>現在沒有可動的票</strong>——每一張都在等前置或等 triage。')
196
+ }
197
+ if (counts.blocked) parts.push(`${counts.blocked} 張被前置擋住。`)
198
+ if (counts.triage) parts.push(`${counts.triage} 張規格還沒定案,要先 triage。`)
199
+ if (criticalPath > 1) parts.push(`關鍵路徑 ${criticalPath} 張,那是最少要幾輪才收得完。`)
200
+ return parts.join(' ')
201
+ }
202
+
203
+ // ---- 線路圖 ----
204
+
205
+ /**
206
+ * 同一條線就是一橫。跨線走「向右、轉、向下、轉、向右」,而垂直那一段刻意走在站與站之間的
207
+ * 間隙裡——走中點的話會壓到中間那幾條線的站名。同一個終點有多條邊時各自錯開一點,不然它們
208
+ * 會完全重疊成一條。
209
+ */
210
+ function railPath(a: Point, b: Point, nudge: number): string {
211
+ const x1 = a.x + MAP.dot + 3
212
+ const x2 = b.x - MAP.dot - 5
213
+ if (a.y === b.y) return `M${x1} ${a.y} H${x2}`
214
+ const gap = Math.max(x1 + MAP.bend + 2, b.x - MAP.step * 0.44 + nudge * 7)
215
+ const turn = Math.min(gap, x2 - MAP.bend - 2)
216
+ const dir = b.y > a.y ? 1 : -1
217
+ return (
218
+ `M${x1} ${a.y} H${turn - MAP.bend}` +
219
+ ` Q${turn} ${a.y} ${turn} ${a.y + dir * MAP.bend}` +
220
+ ` V${b.y - dir * MAP.bend}` +
221
+ ` Q${turn} ${b.y} ${turn + MAP.bend} ${b.y}` +
222
+ ` H${x2}`
223
+ )
224
+ }
225
+
226
+ function svgTag(tag: string, attrs: Readonly<Record<string, string | number>>): string {
227
+ const pairs = Object.entries(attrs)
228
+ .map(([key, value]) => `${key}="${value}"`)
229
+ .join(' ')
230
+ return `<${tag} ${pairs}/>`
231
+ }
232
+
233
+ /**
234
+ * 站點用形狀分辨,不只靠線條粗細——粗細在 7px 的圓點上分不出來。
235
+ *
236
+ * 圓是流程上的票(實心已關、空心等前置、靶心加光暈是現在可動),三角是有人在做,菱形是規格
237
+ * 還沒定案。兩個例外各給一個形狀,掃一眼就分得開。
238
+ */
239
+ function stationShape(status: Status, q: Point): string {
240
+ if (status === 'active') {
241
+ // 往右指的三角形:這一站正在往下一站走。
242
+ const r = MAP.dot + 1.5
243
+ const d = `M${q.x - r * 0.72} ${q.y - r} L${q.x + r * 0.86} ${q.y} L${q.x - r * 0.72} ${q.y + r} Z`
244
+ return svgTag('path', { class: 'mark', d })
245
+ }
246
+ if (status === 'triage') {
247
+ const d = MAP.dot + 1
248
+ return svgTag('rect', {
249
+ class: 'mark',
250
+ x: q.x - d,
251
+ y: q.y - d,
252
+ width: d * 2,
253
+ height: d * 2,
254
+ rx: 1,
255
+ transform: `rotate(45 ${q.x} ${q.y})`,
256
+ })
257
+ }
258
+ const dot = svgTag('circle', {
259
+ class: 'mark',
260
+ cx: q.x,
261
+ cy: q.y,
262
+ r: status === 'ready' ? MAP.dot + 2 : MAP.dot,
263
+ })
264
+ if (status !== 'ready') return dot
265
+ return (
266
+ svgTag('circle', { class: 'halo', cx: q.x, cy: q.y, r: MAP.dot + 7 }) +
267
+ dot +
268
+ svgTag('circle', { class: 'core', cx: q.x, cy: q.y, r: 3 })
269
+ )
270
+ }
271
+
272
+ /** 一群票對應的一張圖。`members` 是票,`track` 是這一組的線色。 */
273
+ type Shown = { group: Group; members: readonly MapIssue[]; track: string }
274
+
275
+ function trackLabel(svg: SVGSVGElement, y: number, name: string, sub: string): void {
276
+ const main = document.createElementNS(SVG_NS, 'text')
277
+ main.setAttribute('class', 'tname')
278
+ main.setAttribute('x', '8')
279
+ main.setAttribute('y', String(y - 3))
280
+ main.textContent = name
281
+ svg.appendChild(main)
282
+ const note = document.createElementNS(SVG_NS, 'text')
283
+ note.setAttribute('class', 'tsub')
284
+ note.setAttribute('x', '8')
285
+ note.setAttribute('y', String(y + 12))
286
+ note.textContent = sub
287
+ svg.appendChild(note)
288
+ }
289
+
290
+ function railFor(edge: Edge, layout: Layout, nudge: number): SVGPathElement | null {
291
+ const a = layout.xy.get(edge.from)
292
+ const b = layout.xy.get(edge.to)
293
+ if (!a || !b) return null
294
+ const rail = document.createElementNS(SVG_NS, 'path')
295
+ rail.setAttribute('class', 'edge')
296
+ rail.setAttribute('d', railPath(a, b, nudge))
297
+ rail.dataset.from = String(edge.from)
298
+ rail.dataset.to = String(edge.to)
299
+ rail.dataset.done = String(issueAt(edge.from)?.status === 'done')
300
+ return rail
301
+ }
302
+
303
+ function stationFor(issue: MapIssue, q: Point): SVGAElement {
304
+ const station = document.createElementNS(SVG_NS, 'a')
305
+ station.setAttribute('class', 'node')
306
+ station.setAttribute('href', issue.url)
307
+ station.setAttribute('target', '_blank')
308
+ station.setAttribute('rel', 'noopener')
309
+ station.setAttribute(
310
+ 'aria-label',
311
+ `#${issue.number} ${issue.title},${STATUS_LABEL[issue.status]}`,
312
+ )
313
+ station.dataset.status = issue.status
314
+ station.dataset.number = String(issue.number)
315
+ station.innerHTML =
316
+ stationShape(issue.status, q) +
317
+ `<text class="sid" x="${q.x}" y="${q.y - 15}" text-anchor="middle">#${issue.number}</text>` +
318
+ `<text class="sdesc" x="${q.x}" y="${q.y + 24}" text-anchor="middle">${esc(head(issue.title))}</text>` +
319
+ `<title>#${issue.number} ${esc(issue.title)}</title>`
320
+ // 點站看詳細;要開 GitHub 用 ⌘/Ctrl 點,或詳細面板裡的連結。
321
+ station.addEventListener('click', (ev) => {
322
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey) return
323
+ ev.preventDefault()
324
+ ev.stopPropagation()
325
+ // 再點同一站就取消,跟點空白處一樣——不然亮起來之後只剩 Esc 能收,那沒人找得到。
326
+ if (selected === issue.number) clearSelection()
327
+ else select(issue.number)
328
+ })
329
+ return station
330
+ }
331
+
332
+ function mapFor(shown: Shown): HTMLDivElement {
333
+ const layout = layoutOf(shown.members)
334
+ const wrap = document.createElement('div')
335
+ wrap.className = 'map-wrap'
336
+ const svg = document.createElementNS(SVG_NS, 'svg')
337
+ svg.setAttribute('width', String(layout.width))
338
+ svg.setAttribute('height', String(layout.height))
339
+ svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`)
340
+ svg.setAttribute('role', 'img')
341
+ svg.style.setProperty('--track', shown.track)
342
+
343
+ // 線名就是終點站的票號。單站線沒有「這條線在做什麼」可講,不標。
344
+ layout.tracks.forEach((chain, index) => {
345
+ const terminus = chain[chain.length - 1]
346
+ if (chain.length < 2 || terminus === undefined) return
347
+ trackLabel(svg, MAP.top + index * MAP.row, `→ #${terminus}`, `${chain.length} 站`)
348
+ })
349
+ if (layout.islandRows) {
350
+ trackLabel(svg, MAP.top + layout.islandFrom * MAP.row, '無前置', '可各自開工')
351
+ }
352
+
353
+ const seenTo = new Map<number, number>()
354
+ for (const edge of layout.edges) {
355
+ const nth = seenTo.get(edge.to) ?? 0
356
+ seenTo.set(edge.to, nth + 1)
357
+ const rail = railFor(edge, layout, nth)
358
+ if (rail) svg.appendChild(rail)
359
+ }
360
+
361
+ for (const member of shown.members) {
362
+ const q = layout.xy.get(member.number)
363
+ if (q) svg.appendChild(stationFor(member, q))
364
+ }
365
+
366
+ // 點圖上的空白處就取消亮線。
367
+ svg.addEventListener('click', (ev) => {
368
+ const target = ev.target
369
+ if (target instanceof Element && target.closest('.node')) return
370
+ clearSelection()
371
+ })
372
+ wrap.appendChild(svg)
373
+ return wrap
374
+ }
375
+
376
+ /** 每張圖底下重複一份 key。圖可以收起來,key 跟著收,不會留一段沒有圖的說明。 */
377
+ function mapKey(): HTMLDivElement {
378
+ const key = document.createElement('div')
379
+ key.className = 'map-key'
380
+ key.innerHTML =
381
+ '<span><i class="k-ready"></i>現在可動</span>' +
382
+ '<span><i class="k-active"></i>有人接手</span>' +
383
+ '<span><i class="k-blocked"></i>等前置</span>' +
384
+ '<span><i class="k-triage"></i>規格未定案</span>' +
385
+ '<span><i class="k-done"></i>已關閉</span>' +
386
+ '<span>實線=還沒解開的前置</span>' +
387
+ '<span>虛線=前置已關</span>'
388
+ return key
389
+ }
390
+
391
+ const TRACK_COLOURS = ['--t1', '--t2', '--t3', '--t4']
392
+
393
+ function renderGroups(): void {
394
+ const groupsEl = pick('groups')
395
+ const shownGroups: Shown[] = (raw.groups ?? []).map((group, index) => ({
396
+ group,
397
+ members: group.members.map(issueAt).filter((issue): issue is MapIssue => issue !== undefined),
398
+ track: `var(${TRACK_COLOURS[index % TRACK_COLOURS.length]})`,
399
+ }))
400
+
401
+ for (const shown of shownGroups) {
402
+ const section = document.createElement('section')
403
+ section.className = 'group'
404
+ section.style.setProperty('--track', shown.track)
405
+
406
+ const header = document.createElement('div')
407
+ header.className = 'group-head'
408
+ header.innerHTML = `<h2>${esc(shown.group.title)}</h2><span class="sub">${groupSub(shown)}</span>`
409
+ section.appendChild(header)
410
+
411
+ const body = mapFor(shown)
412
+ section.appendChild(body)
413
+ section.appendChild(mapKey())
414
+
415
+ // 主票的子票群可以收起來。獨立票沒有主票,不給收合。
416
+ const parent = shown.group.parent
417
+ if (parent !== null) {
418
+ header.insertBefore(foldButton(parent), header.firstChild)
419
+ section.dataset.fold = String(parent)
420
+ body.dataset.parent = String(parent)
421
+ }
422
+ groupsEl.appendChild(section)
423
+ }
424
+ }
425
+
426
+ function groupSub(shown: Shown): string {
427
+ const parent = shown.group.parent
428
+ if (parent === null) return '互不阻擋,可各自開工'
429
+ const done = shown.members.filter((m) => m.status === 'done').length
430
+ const spec = issueAt(parent)
431
+ const specLink = spec
432
+ ? `<a href="${esc(spec.url)}" target="_blank" rel="noopener">#${parent} parent spec</a> · `
433
+ : ''
434
+ return `${specLink}子票 ${done} / ${shown.members.length} 已完成 · 全關後才關 parent`
435
+ }
436
+
437
+ // ---- 詳細 ----
438
+
439
+ /**
440
+ * 等誰那一欄只列前幾張。一張主票可以等十幾張子票,全列出來會把整欄撐到把標題壓扁——完整
441
+ * 清單放 title,看得到也不爆版。
442
+ */
443
+ const BLOCKERS_SHOWN = 4
444
+
445
+ function hash(n: number): string {
446
+ return `#${n}`
447
+ }
448
+
449
+ function blockers(list: readonly number[]): string {
450
+ if (!list.length) return '—'
451
+ if (list.length <= BLOCKERS_SHOWN) return list.map(hash).join(' ')
452
+ const rest = list.length - BLOCKERS_SHOWN
453
+ return `${list.slice(0, BLOCKERS_SHOWN).map(hash).join(' ')} <span class="more">+${rest}</span>`
454
+ }
455
+
456
+ function link(n: number): string {
457
+ const issue = issueAt(n)
458
+ return issue ? `<a href="${esc(issue.url)}" target="_blank" rel="noopener">#${n}</a>` : `#${n}`
459
+ }
460
+
461
+ function pill(issue: MapIssue): string {
462
+ return `<span class="pill" data-status="${issue.status}">${STATUS_LABEL[issue.status]}</span>`
463
+ }
464
+
465
+ /**
466
+ * 下一步是斜線指令的時候做成按鈕,按了把「指令 + 票號」整句複製走——真正要貼進去的是那一
467
+ * 整句,只顯示指令的話還得自己補票號。不是指令的(接手的人、等哪幾張)就純文字。
468
+ */
469
+ function stepCell(issue: MapIssue): string {
470
+ if (!issue.nextStep) return '<span class="next">—</span>'
471
+ if (!issue.nextStep.startsWith('/')) return `<span class="next">${esc(issue.nextStep)}</span>`
472
+ const command = `${issue.nextStep} #${issue.number}`
473
+ return (
474
+ `<button type="button" class="copy" data-copy="${esc(command)}" title="複製 ${esc(command)}">` +
475
+ `<span class="copy-text">${esc(issue.nextStep)}</span></button>`
476
+ )
477
+ }
478
+
479
+ /** 一張票的細節。上面的面板與清單裡展開的那一列共用同一份標記。 */
480
+ function detailHTML(number: number): string {
481
+ const issue = issueAt(number)
482
+ if (!issue) return ''
483
+ const settled = issue.blockedBy.filter((b) => !issue.waitingFor.includes(b))
484
+ const opens = (unlocks.get(number) ?? []).filter((n) => issueAt(n)?.status !== 'done')
485
+ const closed = issue.closedAt
486
+ ? ` ${new Date(issue.closedAt).toLocaleDateString('zh-TW')} 關閉`
487
+ : ''
488
+ const rows: [string, string][] = [
489
+ ['狀態', pill(issue) + closed],
490
+ ['下一步', stepCell(issue)],
491
+ ]
492
+ if (issue.author) rows.push(['開票', esc(issue.author)])
493
+ if (issue.parent !== null) rows.push(['Parent', link(issue.parent)])
494
+ if (issue.waitingFor.length) rows.push(['等誰', issue.waitingFor.map(link).join(' ')])
495
+ if (settled.length) rows.push(['已解鎖的前置', settled.map(link).join(' ')])
496
+ if (opens.length) rows.push(['關掉後解鎖', opens.map(link).join(' ')])
497
+ if (issue.labels.length) {
498
+ rows.push(['標籤', issue.labels.map((l) => `<span class="label">${esc(l)}</span>`).join('')])
499
+ }
500
+ if (issue.assignees.length) rows.push(['接手', esc(issue.assignees.join(', '))])
501
+ return (
502
+ `<h3><span class="num">#${issue.number}</span>${esc(issue.title)}</h3>` +
503
+ `<a class="open" href="${esc(issue.url)}" target="_blank" rel="noopener">在 GitHub 開啟 ↗</a>` +
504
+ `<dl class="rows">${rows.map(([dt, dd]) => `<dt>${dt}</dt><dd>${dd}</dd>`).join('')}</dl>`
505
+ )
506
+ }
507
+
508
+ const detail = pick('detail')
509
+
510
+ function renderDetail(number: number | undefined): void {
511
+ const issue = number === undefined ? undefined : issueAt(number)
512
+ if (!issue) {
513
+ detail.dataset.status = ''
514
+ detail.innerHTML = '<h3>沒有 issue</h3>'
515
+ return
516
+ }
517
+ detail.dataset.status = issue.status
518
+ detail.innerHTML = detailHTML(issue.number)
519
+ }
520
+
521
+ // ---- 選取 ----
522
+
523
+ let selected: number | null = null
524
+
525
+ /** 一張票的整條上下游,用來在選取時把鏈亮起來。 */
526
+ function chainOf(start: number): Set<number> {
527
+ const out = new Set([start])
528
+ const walks: ((n: number) => readonly number[])[] = [
529
+ (n) => issueAt(n)?.blockedBy ?? [],
530
+ (n) => unlocks.get(n) ?? [],
531
+ ]
532
+ for (const neighboursOf of walks) {
533
+ const stack = [start]
534
+ while (stack.length) {
535
+ const at = stack.pop()
536
+ if (at === undefined) break
537
+ for (const next of neighboursOf(at)) {
538
+ if (out.has(next)) continue
539
+ out.add(next)
540
+ stack.push(next)
541
+ }
542
+ }
543
+ }
544
+ return out
545
+ }
546
+
547
+ function defaultPick(): number | undefined {
548
+ const first = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)[0]
549
+ return (first ?? issues[0])?.number
550
+ }
551
+
552
+ /**
553
+ * 選一張票。再點同一張不會取消——在清單上你往往只是想再看一次,取消掉反而要重新找。要清掉
554
+ * 按 Esc。
555
+ */
556
+ function select(number: number): void {
557
+ selected = number
558
+ paintSelection()
559
+ }
560
+
561
+ function clearSelection(): void {
562
+ selected = null
563
+ closeExpander()
564
+ paintSelection()
565
+ }
566
+
567
+ function paintSelection(): void {
568
+ const lit = selected === null ? null : chainOf(selected)
569
+ for (const svg of document.querySelectorAll<SVGSVGElement>('svg')) {
570
+ svg.classList.toggle('has-selection', selected !== null)
571
+ for (const node of svg.querySelectorAll<SVGAElement>('.node')) {
572
+ const number = Number(node.dataset.number)
573
+ node.classList.toggle('lit', lit !== null && lit.has(number))
574
+ node.classList.toggle('selected', number === selected)
575
+ }
576
+ for (const edge of svg.querySelectorAll<SVGPathElement>('.edge')) {
577
+ const from = Number(edge.dataset.from)
578
+ const to = Number(edge.dataset.to)
579
+ edge.classList.toggle('lit', lit !== null && lit.has(from) && lit.has(to))
580
+ }
581
+ }
582
+ for (const row of rowsEl.querySelectorAll<HTMLElement>('.row[data-number]')) {
583
+ row.classList.toggle('selected', Number(row.dataset.number) === selected)
584
+ }
585
+ renderDetail(selected ?? defaultPick())
586
+ }
587
+
588
+ // ---- 清單 ----
589
+
590
+ type Row = { issue: MapIssue; parent: number | null; hasKids: boolean }
591
+
592
+ const rowsEl = pick('rows')
593
+ let filter: Status | 'all' = 'all'
594
+
595
+ /** 主票在前、它的子票跟在後面。篩選時主票只要有子票入選就留著當標頭。 */
596
+ function rowOrder(): Row[] {
597
+ const pass = new Set(
598
+ issues.filter((i) => filter === 'all' || i.status === filter).map((i) => i.number),
599
+ )
600
+ const kids = new Map<number, MapIssue[]>()
601
+ const roots: MapIssue[] = []
602
+ for (const issue of issues) {
603
+ const parent = issue.parent !== null && byNumber.has(issue.parent) ? issue.parent : null
604
+ if (parent === null) {
605
+ if (pass.has(issue.number)) roots.push(issue)
606
+ continue
607
+ }
608
+ if (!pass.has(issue.number)) continue
609
+ const siblings = kids.get(parent) ?? []
610
+ siblings.push(issue)
611
+ kids.set(parent, siblings)
612
+ }
613
+ // 有子票入選但自己沒入選的主票,仍要出現,否則子票就沒了歸屬。
614
+ for (const parent of kids.keys()) {
615
+ const issue = issueAt(parent)
616
+ if (issue && !roots.includes(issue)) roots.push(issue)
617
+ }
618
+
619
+ const rows: Row[] = []
620
+ for (const root of roots.sort(byStatusThenNumber)) {
621
+ const children = (kids.get(root.number) ?? []).sort(byStatusThenNumber)
622
+ rows.push({ issue: root, parent: null, hasKids: children.length > 0 })
623
+ for (const child of children) rows.push({ issue: child, parent: root.number, hasKids: false })
624
+ }
625
+ return rows
626
+ }
627
+
628
+ function rowHTML(row: Row): string {
629
+ const issue = row.issue
630
+ const parent = row.parent === null ? '' : ` data-parent="${row.parent}"`
631
+ const kids = row.hasKids ? ' data-haskids="true"' : ''
632
+ const slot = row.hasKids ? '<span class="fold-slot"></span>' : ''
633
+ const waitsTitle =
634
+ issue.waitingFor.length > BLOCKERS_SHOWN
635
+ ? ` title="${esc(issue.waitingFor.map(hash).join(' '))}"`
636
+ : ''
637
+ const waits = issue.waitingFor.length
638
+ ? `<span class="waits"${waitsTitle}>等 ${blockers(issue.waitingFor)}</span>`
639
+ : ''
640
+ return (
641
+ `<div class="row" tabindex="0" data-status="${issue.status}" data-number="${issue.number}"${parent}${kids}>` +
642
+ `<div class="id">${slot}` +
643
+ `<a class="no" href="${esc(issue.url)}" target="_blank" rel="noopener">#${issue.number}</a>` +
644
+ `<span class="who">${issue.author ? esc(issue.author) : '—'}</span></div>` +
645
+ `<div class="what"><span class="title">${esc(issue.title)}</span>${waits}</div>` +
646
+ `<div class="step">${stepCell(issue)}</div>` +
647
+ `<div class="gate">${pill(issue)}</div>` +
648
+ '</div>'
649
+ )
650
+ }
651
+
652
+ function renderRows(): void {
653
+ const rows = rowOrder()
654
+ pick('list-sub').textContent = `${rows.filter((r) => !r.issue.isParent).length} 張`
655
+ rowsEl.innerHTML = rows.map(rowHTML).join('')
656
+ // 主票的收合鈕:innerHTML 重建過,鈕要重新放進去。
657
+ for (const row of rowsEl.querySelectorAll<HTMLElement>('.row[data-haskids="true"]')) {
658
+ row.querySelector('.fold-slot')?.appendChild(foldButton(Number(row.dataset.number)))
659
+ }
660
+ }
661
+
662
+ function renderTabs(): void {
663
+ const tabs = pick('tabs')
664
+ const defs: [Status | 'all', string, number][] = [
665
+ ['all', '全部', work.length],
666
+ ...STATUS_ORDER.map((s) => [s, STATUS_LABEL[s], counts[s]] as [Status, string, number]),
667
+ ]
668
+ for (const [key, label, count] of defs) {
669
+ const tab = document.createElement('button')
670
+ tab.type = 'button'
671
+ tab.className = 'tab'
672
+ tab.dataset.filter = key
673
+ tab.setAttribute('aria-pressed', String(key === filter))
674
+ tab.innerHTML = `${esc(label)}<span class="n">${count}</span>`
675
+ tab.addEventListener('click', () => {
676
+ filter = key
677
+ for (const other of tabs.querySelectorAll<HTMLElement>('.tab')) {
678
+ other.setAttribute('aria-pressed', String(other.dataset.filter === filter))
679
+ }
680
+ renderRows()
681
+ paintSelection()
682
+ const back =
683
+ selected === null
684
+ ? null
685
+ : rowsEl.querySelector<HTMLElement>(`.row[data-number="${selected}"]`)
686
+ if (back && selected !== null) openExpander(back, selected)
687
+ })
688
+ tabs.appendChild(tab)
689
+ }
690
+ }
691
+
692
+ // ---- 就地展開 ----
693
+
694
+ /**
695
+ * 清單的細節就在被點的那一列底下展開,不把畫面捲到上面的面板——連著看三張票時,視線不用
696
+ * 來回跑。
697
+ */
698
+ function closeExpander(): void {
699
+ rowsEl.querySelector('.detail.expand')?.remove()
700
+ }
701
+
702
+ function openExpander(row: HTMLElement, number: number): void {
703
+ closeExpander()
704
+ const issue = issueAt(number)
705
+ if (!issue) return
706
+ const panel = document.createElement('div')
707
+ panel.className = 'detail expand'
708
+ panel.dataset.status = issue.status
709
+ panel.dataset.forNumber = String(number)
710
+ // 掛上 data-parent,主票收起來時 paintFolded 會一起把它藏掉。
711
+ if (row.dataset.parent) panel.dataset.parent = row.dataset.parent
712
+ panel.innerHTML = detailHTML(number)
713
+ row.insertAdjacentElement('afterend', panel)
714
+ paintFolded()
715
+ }
716
+
717
+ function pickRow(row: HTMLElement): void {
718
+ const number = Number(row.dataset.number)
719
+ const already = rowsEl.querySelector(`.detail.expand[data-for-number="${number}"]`)
720
+ // 上面的面板換一張票會改變自己的高度,把清單整段推走。先記下這一列在畫面上的位置,改完
721
+ // 再補回去,手指底下的那一列就不會跑。
722
+ const before = row.getBoundingClientRect().top
723
+ select(number)
724
+ if (already) closeExpander()
725
+ else openExpander(row, number)
726
+ const shift = row.getBoundingClientRect().top - before
727
+ if (shift) window.scrollBy({ top: shift, behavior: 'instant' })
728
+ }
729
+
730
+ // ---- 複製 ----
731
+
732
+ /**
733
+ * 複製到剪貼簿。`navigator.clipboard` 在非安全來源沒有,而就算有也可能被權限擋掉(沒有使用者
734
+ * 手勢、或瀏覽器設定),所以失敗一律退回選取再 execCommand。
735
+ */
736
+ function legacyCopy(text: string): Promise<void> {
737
+ const box = document.createElement('textarea')
738
+ box.value = text
739
+ box.setAttribute('readonly', '')
740
+ box.style.cssText = 'position:fixed;top:-100px;opacity:0'
741
+ document.body.appendChild(box)
742
+ box.select()
743
+ const ok = document.execCommand('copy')
744
+ box.remove()
745
+ return ok ? Promise.resolve() : Promise.reject(new Error('複製失敗'))
746
+ }
747
+
748
+ function copyText(text: string): Promise<void> {
749
+ if (!navigator.clipboard || !window.isSecureContext) return legacyCopy(text)
750
+ return navigator.clipboard.writeText(text).catch(() => legacyCopy(text))
751
+ }
752
+
753
+ function flash(label: HTMLElement, text: string, ms: number, then: () => void): void {
754
+ label.textContent = text
755
+ setTimeout(then, ms)
756
+ }
757
+
758
+ function handleCopy(button: HTMLElement): void {
759
+ const label = button.querySelector<HTMLElement>('.copy-text')
760
+ const command = button.dataset.copy
761
+ if (!label || !command) return
762
+ const was = label.textContent ?? ''
763
+ copyText(command).then(
764
+ () => {
765
+ button.dataset.copied = 'true'
766
+ flash(label, '已複製', 1200, () => {
767
+ delete button.dataset.copied
768
+ label.textContent = was
769
+ })
770
+ },
771
+ () => flash(label, '複製不了', 1600, () => (label.textContent = was)),
772
+ )
773
+ }
774
+
775
+ // ---- 事件 ----
776
+
777
+ function elementAt(ev: Event): Element | null {
778
+ return ev.target instanceof Element ? ev.target : null
779
+ }
780
+
781
+ // 一個委派聽事件,換分頁不用重掛。
782
+ rowsEl.addEventListener('click', (ev) => {
783
+ const target = elementAt(ev)
784
+ if (!target) return
785
+ const copy = target.closest<HTMLElement>('.copy')
786
+ if (copy) {
787
+ // 複製是自己一件事,不要順手把那一列也選起來。
788
+ ev.stopPropagation()
789
+ handleCopy(copy)
790
+ return
791
+ }
792
+ if (target.closest('a') || target.closest('.fold')) return
793
+ const row = target.closest<HTMLElement>('.row[data-number]')
794
+ if (row) pickRow(row)
795
+ })
796
+
797
+ rowsEl.addEventListener('keydown', (ev) => {
798
+ const target = elementAt(ev)
799
+ const row = target?.closest<HTMLElement>('.row[data-number]')
800
+ if (!row) return
801
+ if (ev.key === 'Enter' || ev.key === ' ') {
802
+ ev.preventDefault()
803
+ pickRow(row)
804
+ return
805
+ }
806
+ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return
807
+ ev.preventDefault()
808
+ const rows = [...rowsEl.querySelectorAll<HTMLElement>('.row[data-number]:not([hidden])')]
809
+ const next = rows[rows.indexOf(row) + (ev.key === 'ArrowDown' ? 1 : -1)]
810
+ next?.focus()
811
+ })
812
+
813
+ document.addEventListener('keydown', (ev) => {
814
+ if (ev.key === 'Escape') clearSelection()
815
+ })
816
+
817
+ // ---- 起動 ----
818
+
819
+ renderHeader()
820
+ renderGroups()
821
+ renderTabs()
822
+ renderRows()
823
+ onFold.push(paintFolded)
824
+ paintFolded()
825
+ renderDetail(defaultPick())