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