issue-map 0.1.0 → 0.3.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.
- package/README.ja.md +163 -0
- package/README.md +120 -55
- package/README.zh-CN.md +135 -0
- package/README.zh-TW.md +133 -0
- package/package.json +6 -2
- package/scripts/issue-map-i18n.ts +486 -0
- package/scripts/issue-map-model.ts +33 -13
- package/scripts/issue-map-page.ts +241 -75
- package/scripts/issue-map-serve.ts +3 -2
- package/scripts/issue-map.html +43 -12
- package/scripts/issue-map.ts +16 -8
|
@@ -13,13 +13,21 @@ export type Status = 'ready' | 'active' | 'blocked' | 'triage' | 'done'
|
|
|
13
13
|
/** 五個狀態的顯示順序,也是清單的排序權重。 */
|
|
14
14
|
export const STATUS_ORDER: readonly Status[] = ['ready', 'active', 'blocked', 'triage', 'done']
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 下一步。**這裡只放事實,不放句子**——「等 3 張子票關完」這種話由 `issue-map-i18n.ts` 依當下
|
|
18
|
+
* 語言組出來。快照裡存中文句子的話,換語言就得重抓一次 GitHub。
|
|
19
|
+
*
|
|
20
|
+
* `command` 的內容是設定(`/implement`、`/triage`),不是文案,所以照原字帶著走。
|
|
21
|
+
*/
|
|
22
|
+
export type NextStep =
|
|
23
|
+
| { readonly kind: 'none' }
|
|
24
|
+
| { readonly kind: 'command'; readonly command: string }
|
|
25
|
+
| { readonly kind: 'manual' }
|
|
26
|
+
/** 有人接手。`who` 是 assignee,沒有 assignee 但掛了 active 標籤時是那個標籤名。 */
|
|
27
|
+
| { readonly kind: 'active'; readonly who: readonly string[] }
|
|
28
|
+
| { readonly kind: 'waitIssues'; readonly issues: readonly number[] }
|
|
29
|
+
| { readonly kind: 'waitChildren'; readonly count: number }
|
|
30
|
+
| { readonly kind: 'parentReady' }
|
|
23
31
|
|
|
24
32
|
/** 頁面吃的形狀。這裡是它唯一的定義。 */
|
|
25
33
|
export type MapIssue = {
|
|
@@ -37,17 +45,28 @@ export type MapIssue = {
|
|
|
37
45
|
/** 還開著的阻擋者,就是實際的閘門。 */
|
|
38
46
|
readonly waitingFor: readonly number[]
|
|
39
47
|
readonly status: Status
|
|
40
|
-
/**
|
|
41
|
-
readonly nextStep:
|
|
48
|
+
/** 下一步。句子在 i18n 那一層才組出來。 */
|
|
49
|
+
readonly nextStep: NextStep
|
|
42
50
|
/** 有子票的票。它自己不做事,等子票全關。 */
|
|
43
51
|
readonly isParent: boolean
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 一群票的名字。`spec` 帶的是主票標題——那是真資料,不翻;另外三種是頁面自己的分類,翻譯在
|
|
56
|
+
* i18n 那一層。
|
|
57
|
+
*/
|
|
58
|
+
export type GroupName =
|
|
59
|
+
| { readonly kind: 'spec'; readonly title: string }
|
|
60
|
+
/** 主票沒被帶進快照,只好用票號稱呼這一群。 */
|
|
61
|
+
| { readonly kind: 'orphan'; readonly parent: number }
|
|
62
|
+
| { readonly kind: 'linked' }
|
|
63
|
+
| { readonly kind: 'island' }
|
|
64
|
+
|
|
46
65
|
/** 畫在同一張圖上的一群票。 */
|
|
47
66
|
export type Group = {
|
|
48
67
|
/** 有 parent 的群就是那張主票;沒有的是「其他依賴鏈」與「獨立票」這兩種。 */
|
|
49
68
|
readonly parent: number | null
|
|
50
|
-
readonly
|
|
69
|
+
readonly name: GroupName
|
|
51
70
|
readonly members: readonly number[]
|
|
52
71
|
}
|
|
53
72
|
|
|
@@ -81,7 +100,8 @@ export function groupsOf(issues: readonly MapIssue[]): Group[] {
|
|
|
81
100
|
const groups: Group[] = []
|
|
82
101
|
for (const [parent, members] of byParent) {
|
|
83
102
|
const spec = issues.find((issue) => issue.number === parent)
|
|
84
|
-
|
|
103
|
+
const name: GroupName = spec ? { kind: 'spec', title: spec.title } : { kind: 'orphan', parent }
|
|
104
|
+
groups.push({ parent, name, members })
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
const grouped = new Set([...byParent.values()].flat())
|
|
@@ -92,8 +112,8 @@ export function groupsOf(issues: readonly MapIssue[]): Group[] {
|
|
|
92
112
|
const hasEdge = blocking.has(issue.number) || issue.blockedBy.some((n) => known.has(n))
|
|
93
113
|
;(hasEdge ? linked : alone).push(issue.number)
|
|
94
114
|
}
|
|
95
|
-
if (linked.length) groups.push({ parent: null,
|
|
96
|
-
if (alone.length) groups.push({ parent: null,
|
|
115
|
+
if (linked.length) groups.push({ parent: null, name: { kind: 'linked' }, members: linked })
|
|
116
|
+
if (alone.length) groups.push({ parent: null, name: { kind: 'island' }, members: alone })
|
|
97
117
|
return groups
|
|
98
118
|
}
|
|
99
119
|
|
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
* **只能碰瀏覽器的東西**,不能 import Bun 的 API。
|
|
4
4
|
*
|
|
5
5
|
* 形狀與純推導在 `issue-map-model.ts`:狀態的五個值、票的形狀、分群、關鍵路徑、線路圖的排版
|
|
6
|
-
*
|
|
6
|
+
* 都在那邊,兩側共用同一份定義。文案全部在 `issue-map-i18n.ts`,這一支不寫死任何一句給人看的
|
|
7
|
+
* 話。剩下的是 DOM、事件、收合與語言狀態。
|
|
7
8
|
*/
|
|
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'
|
|
9
20
|
import {
|
|
10
21
|
layoutOf,
|
|
11
22
|
MAP,
|
|
@@ -13,9 +24,9 @@ import {
|
|
|
13
24
|
type Group,
|
|
14
25
|
type Layout,
|
|
15
26
|
type MapIssue,
|
|
27
|
+
type NextStep,
|
|
16
28
|
type Point,
|
|
17
29
|
type Snapshot,
|
|
18
|
-
STATUS_LABEL,
|
|
19
30
|
STATUS_ORDER,
|
|
20
31
|
type Status,
|
|
21
32
|
} from './issue-map-model.ts'
|
|
@@ -82,6 +93,62 @@ function issueAt(number: number): MapIssue | undefined {
|
|
|
82
93
|
return byNumber.get(number)
|
|
83
94
|
}
|
|
84
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
|
+
|
|
85
152
|
// ---- 收合狀態 ----
|
|
86
153
|
|
|
87
154
|
/**
|
|
@@ -89,6 +156,8 @@ function issueAt(number: number): MapIssue | undefined {
|
|
|
89
156
|
* 就當成全部展開。
|
|
90
157
|
*/
|
|
91
158
|
const COLLAPSE_KEY = `issue-map:collapsed:${repo}`
|
|
159
|
+
|
|
160
|
+
/** 每顆收合鈕重畫自己的樣子。整頁重畫時要清掉,不然會留著指向已被移除的鈕的函式。 */
|
|
92
161
|
const onFold: (() => void)[] = []
|
|
93
162
|
|
|
94
163
|
function readFolded(): Set<string> {
|
|
@@ -114,6 +183,7 @@ function setFolded(parent: number, shut: boolean): void {
|
|
|
114
183
|
// 存不了就只在這一次有效,畫面照樣能開合。
|
|
115
184
|
}
|
|
116
185
|
for (const repaint of onFold) repaint()
|
|
186
|
+
paintFolded()
|
|
117
187
|
}
|
|
118
188
|
|
|
119
189
|
/** 做一顆收合鈕。`parent` 是主票號碼,同一個號碼的鈕與清單列連動。 */
|
|
@@ -124,7 +194,10 @@ function foldButton(parent: number): HTMLButtonElement {
|
|
|
124
194
|
const paint = () => {
|
|
125
195
|
const open = !isFolded(parent)
|
|
126
196
|
button.setAttribute('aria-expanded', String(open))
|
|
127
|
-
button.setAttribute(
|
|
197
|
+
button.setAttribute(
|
|
198
|
+
'aria-label',
|
|
199
|
+
open ? t('fold.collapse', { n: parent }) : t('fold.expand', { n: parent }),
|
|
200
|
+
)
|
|
128
201
|
button.textContent = open ? '−' : '+'
|
|
129
202
|
}
|
|
130
203
|
paint()
|
|
@@ -147,56 +220,76 @@ function paintFolded(): void {
|
|
|
147
220
|
|
|
148
221
|
function renderHeader(): void {
|
|
149
222
|
const stats = pick('stats')
|
|
223
|
+
stats.innerHTML = ''
|
|
150
224
|
const tiles: { label: string; value: number; unit?: string; tone?: string }[] = [
|
|
151
|
-
{ label: '
|
|
152
|
-
{ label: '
|
|
153
|
-
{ label: '
|
|
154
|
-
{ label: '
|
|
155
|
-
{
|
|
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
|
+
},
|
|
156
234
|
]
|
|
157
235
|
for (const tile of tiles) {
|
|
158
236
|
const box = document.createElement('div')
|
|
159
237
|
box.className = 'stat'
|
|
160
238
|
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>`
|
|
239
|
+
const unit = tile.unit ? `<small>${esc(tile.unit)}</small>` : ''
|
|
240
|
+
box.innerHTML = `<b>${tile.value}${unit}</b><span>${esc(tile.label)}</span>`
|
|
163
241
|
stats.appendChild(box)
|
|
164
242
|
}
|
|
165
243
|
|
|
166
244
|
// 抬頭全部照資料寫,換 repo 或換標籤字彙才不會留著上一個專案的字。
|
|
167
|
-
const
|
|
245
|
+
const name = repo.split('/').pop()
|
|
246
|
+
const heading = name ? t('title.withRepo', { repo: name }) : t('title.plain')
|
|
168
247
|
pick('page-title').textContent = heading
|
|
169
248
|
document.title = heading
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
249
|
+
document.documentElement.lang = locale()
|
|
250
|
+
pick('lang').setAttribute('aria-label', t('lang.label'))
|
|
251
|
+
const when = raw.generatedAt ? ` · ${dateTime(raw.generatedAt)}` : ''
|
|
173
252
|
pick('eyebrow').textContent = repo + when
|
|
174
253
|
|
|
175
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
|
+
})
|
|
176
265
|
|
|
177
266
|
const vocab = raw.labels ?? { ready: [], unready: [] }
|
|
267
|
+
// textContent:標籤名是 repo 給的字,不經過 innerHTML 就不必逃脫。
|
|
178
268
|
pick('foot-config').textContent = vocab.ready.length
|
|
179
|
-
?
|
|
180
|
-
|
|
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')
|
|
181
274
|
}
|
|
182
275
|
|
|
183
276
|
/** 導言只講數得出來的事實。沒有可動的票、或整批都關完了,句子跟著換。 */
|
|
184
277
|
function lede(): string {
|
|
185
|
-
if (!openCount) return '
|
|
278
|
+
if (!openCount) return t('lede.allDone')
|
|
186
279
|
const frontline = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
|
|
187
|
-
const parts = [
|
|
280
|
+
const parts = [t('lede.open', { n: openCount })]
|
|
188
281
|
if (frontline.length) {
|
|
189
282
|
const first = frontline
|
|
190
283
|
.slice(0, 3)
|
|
191
284
|
.map((i) => `<a href="${esc(i.url)}" target="_blank" rel="noopener">#${i.number}</a>`)
|
|
192
|
-
.join('
|
|
193
|
-
parts.push(
|
|
285
|
+
.join(t('join.items'))
|
|
286
|
+
parts.push(t('lede.ready', { n: frontline.length, issues: first }))
|
|
194
287
|
} else {
|
|
195
|
-
parts.push('
|
|
288
|
+
parts.push(t('lede.none'))
|
|
196
289
|
}
|
|
197
|
-
if (counts.blocked) parts.push(
|
|
198
|
-
if (counts.triage) parts.push(
|
|
199
|
-
if (criticalPath > 1) parts.push(
|
|
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 }))
|
|
200
293
|
return parts.join(' ')
|
|
201
294
|
}
|
|
202
295
|
|
|
@@ -308,7 +401,7 @@ function stationFor(issue: MapIssue, q: Point): SVGAElement {
|
|
|
308
401
|
station.setAttribute('rel', 'noopener')
|
|
309
402
|
station.setAttribute(
|
|
310
403
|
'aria-label',
|
|
311
|
-
|
|
404
|
+
t('node.aria', { n: issue.number, title: issue.title, status: statusLabel(issue.status) }),
|
|
312
405
|
)
|
|
313
406
|
station.dataset.status = issue.status
|
|
314
407
|
station.dataset.number = String(issue.number)
|
|
@@ -344,10 +437,15 @@ function mapFor(shown: Shown): HTMLDivElement {
|
|
|
344
437
|
layout.tracks.forEach((chain, index) => {
|
|
345
438
|
const terminus = chain[chain.length - 1]
|
|
346
439
|
if (chain.length < 2 || terminus === undefined) return
|
|
347
|
-
trackLabel(
|
|
440
|
+
trackLabel(
|
|
441
|
+
svg,
|
|
442
|
+
MAP.top + index * MAP.row,
|
|
443
|
+
`→ #${terminus}`,
|
|
444
|
+
t('map.stations', { n: chain.length }),
|
|
445
|
+
)
|
|
348
446
|
})
|
|
349
447
|
if (layout.islandRows) {
|
|
350
|
-
trackLabel(svg, MAP.top + layout.islandFrom * MAP.row, '
|
|
448
|
+
trackLabel(svg, MAP.top + layout.islandFrom * MAP.row, t('map.islandName'), t('map.islandSub'))
|
|
351
449
|
}
|
|
352
450
|
|
|
353
451
|
const seenTo = new Map<number, number>()
|
|
@@ -377,14 +475,17 @@ function mapFor(shown: Shown): HTMLDivElement {
|
|
|
377
475
|
function mapKey(): HTMLDivElement {
|
|
378
476
|
const key = document.createElement('div')
|
|
379
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
|
+
]
|
|
380
485
|
key.innerHTML =
|
|
381
|
-
|
|
382
|
-
'
|
|
383
|
-
'
|
|
384
|
-
'<span><i class="k-triage"></i>規格未定案</span>' +
|
|
385
|
-
'<span><i class="k-done"></i>已關閉</span>' +
|
|
386
|
-
'<span>實線=還沒解開的前置</span>' +
|
|
387
|
-
'<span>虛線=前置已關</span>'
|
|
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>`
|
|
388
489
|
return key
|
|
389
490
|
}
|
|
390
491
|
|
|
@@ -392,6 +493,7 @@ const TRACK_COLOURS = ['--t1', '--t2', '--t3', '--t4']
|
|
|
392
493
|
|
|
393
494
|
function renderGroups(): void {
|
|
394
495
|
const groupsEl = pick('groups')
|
|
496
|
+
groupsEl.innerHTML = ''
|
|
395
497
|
const shownGroups: Shown[] = (raw.groups ?? []).map((group, index) => ({
|
|
396
498
|
group,
|
|
397
499
|
members: group.members.map(issueAt).filter((issue): issue is MapIssue => issue !== undefined),
|
|
@@ -405,7 +507,7 @@ function renderGroups(): void {
|
|
|
405
507
|
|
|
406
508
|
const header = document.createElement('div')
|
|
407
509
|
header.className = 'group-head'
|
|
408
|
-
header.innerHTML = `<h2>${esc(shown.group
|
|
510
|
+
header.innerHTML = `<h2>${esc(groupTitle(shown.group))}</h2><span class="sub">${groupSub(shown)}</span>`
|
|
409
511
|
section.appendChild(header)
|
|
410
512
|
|
|
411
513
|
const body = mapFor(shown)
|
|
@@ -423,15 +525,33 @@ function renderGroups(): void {
|
|
|
423
525
|
}
|
|
424
526
|
}
|
|
425
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
|
+
|
|
426
542
|
function groupSub(shown: Shown): string {
|
|
427
543
|
const parent = shown.group.parent
|
|
428
|
-
|
|
544
|
+
// 「其他依賴鏈」與「獨立票」都沒有主票,但只有後者互不阻擋——同一句話蓋兩種群會說謊。
|
|
545
|
+
if (shown.group.name.kind === 'linked') return esc(t('group.linkedSub'))
|
|
546
|
+
if (parent === null) return esc(t('group.islandSub'))
|
|
429
547
|
const done = shown.members.filter((m) => m.status === 'done').length
|
|
430
548
|
const spec = issueAt(parent)
|
|
431
549
|
const specLink = spec
|
|
432
|
-
? `<a href="${esc(spec.url)}" target="_blank" rel="noopener"
|
|
550
|
+
? `<a href="${esc(spec.url)}" target="_blank" rel="noopener">${esc(
|
|
551
|
+
t('group.spec', { n: parent }),
|
|
552
|
+
)}</a> · `
|
|
433
553
|
: ''
|
|
434
|
-
return
|
|
554
|
+
return specLink + esc(t('group.progress', { done, total: shown.members.length }))
|
|
435
555
|
}
|
|
436
556
|
|
|
437
557
|
// ---- 詳細 ----
|
|
@@ -459,20 +579,42 @@ function link(n: number): string {
|
|
|
459
579
|
}
|
|
460
580
|
|
|
461
581
|
function pill(issue: MapIssue): string {
|
|
462
|
-
return `<span class="pill" data-status="${issue.status}">${
|
|
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
|
+
}
|
|
463
603
|
}
|
|
464
604
|
|
|
465
605
|
/**
|
|
466
|
-
*
|
|
467
|
-
*
|
|
606
|
+
* 是指令的時候做成按鈕,按了把「指令 + 票號」整句複製走——真正要貼進去的是那一整句,只顯示
|
|
607
|
+
* 指令的話還得自己補票號。其他的(接手的人、等哪幾張)就純文字。
|
|
468
608
|
*/
|
|
469
609
|
function stepCell(issue: MapIssue): string {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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 }))
|
|
473
615
|
return (
|
|
474
|
-
`<button type="button" class="copy" data-copy="${esc(command)}" title="
|
|
475
|
-
`<span class="copy-text">${esc(
|
|
616
|
+
`<button type="button" class="copy" data-copy="${esc(command)}" title="${title}">` +
|
|
617
|
+
`<span class="copy-text">${esc(step.command)}</span></button>`
|
|
476
618
|
)
|
|
477
619
|
}
|
|
478
620
|
|
|
@@ -482,26 +624,33 @@ function detailHTML(number: number): string {
|
|
|
482
624
|
if (!issue) return ''
|
|
483
625
|
const settled = issue.blockedBy.filter((b) => !issue.waitingFor.includes(b))
|
|
484
626
|
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
|
-
: ''
|
|
627
|
+
const closed = issue.closedAt ? esc(t('detail.closedAt', { date: dateOnly(issue.closedAt) })) : ''
|
|
488
628
|
const rows: [string, string][] = [
|
|
489
|
-
['
|
|
490
|
-
['
|
|
629
|
+
[t('detail.status'), pill(issue) + closed],
|
|
630
|
+
[t('detail.next'), stepCell(issue)],
|
|
491
631
|
]
|
|
492
|
-
if (issue.author) rows.push(['
|
|
493
|
-
if (issue.parent !== null) rows.push(['
|
|
494
|
-
if (issue.waitingFor.length)
|
|
495
|
-
|
|
496
|
-
|
|
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(' ')])
|
|
497
639
|
if (issue.labels.length) {
|
|
498
|
-
rows.push([
|
|
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(', '))])
|
|
499
647
|
}
|
|
500
|
-
if (issue.assignees.length) rows.push(['接手', esc(issue.assignees.join(', '))])
|
|
501
648
|
return (
|
|
502
649
|
`<h3><span class="num">#${issue.number}</span>${esc(issue.title)}</h3>` +
|
|
503
|
-
`<a class="open" href="${esc(issue.url)}" target="_blank" rel="noopener"
|
|
504
|
-
|
|
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>`
|
|
505
654
|
)
|
|
506
655
|
}
|
|
507
656
|
|
|
@@ -511,7 +660,7 @@ function renderDetail(number: number | undefined): void {
|
|
|
511
660
|
const issue = number === undefined ? undefined : issueAt(number)
|
|
512
661
|
if (!issue) {
|
|
513
662
|
detail.dataset.status = ''
|
|
514
|
-
detail.innerHTML = '
|
|
663
|
+
detail.innerHTML = `<h3>${esc(t('detail.empty'))}</h3>`
|
|
515
664
|
return
|
|
516
665
|
}
|
|
517
666
|
detail.dataset.status = issue.status
|
|
@@ -635,7 +784,9 @@ function rowHTML(row: Row): string {
|
|
|
635
784
|
? ` title="${esc(issue.waitingFor.map(hash).join(' '))}"`
|
|
636
785
|
: ''
|
|
637
786
|
const waits = issue.waitingFor.length
|
|
638
|
-
? `<span class="waits"${waitsTitle}
|
|
787
|
+
? `<span class="waits"${waitsTitle}>${t('row.waits', {
|
|
788
|
+
list: blockers(issue.waitingFor),
|
|
789
|
+
})}</span>`
|
|
639
790
|
: ''
|
|
640
791
|
return (
|
|
641
792
|
`<div class="row" tabindex="0" data-status="${issue.status}" data-number="${issue.number}"${parent}${kids}>` +
|
|
@@ -651,7 +802,9 @@ function rowHTML(row: Row): string {
|
|
|
651
802
|
|
|
652
803
|
function renderRows(): void {
|
|
653
804
|
const rows = rowOrder()
|
|
654
|
-
|
|
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 })
|
|
655
808
|
rowsEl.innerHTML = rows.map(rowHTML).join('')
|
|
656
809
|
// 主票的收合鈕:innerHTML 重建過,鈕要重新放進去。
|
|
657
810
|
for (const row of rowsEl.querySelectorAll<HTMLElement>('.row[data-haskids="true"]')) {
|
|
@@ -661,9 +814,11 @@ function renderRows(): void {
|
|
|
661
814
|
|
|
662
815
|
function renderTabs(): void {
|
|
663
816
|
const tabs = pick('tabs')
|
|
817
|
+
tabs.innerHTML = ''
|
|
818
|
+
tabs.setAttribute('aria-label', t('tabs.aria'))
|
|
664
819
|
const defs: [Status | 'all', string, number][] = [
|
|
665
|
-
['all', '
|
|
666
|
-
...STATUS_ORDER.map((s) => [s,
|
|
820
|
+
['all', t('tabs.all'), work.length],
|
|
821
|
+
...STATUS_ORDER.map((s) => [s, statusLabel(s), counts[s]] as [Status, string, number]),
|
|
667
822
|
]
|
|
668
823
|
for (const [key, label, count] of defs) {
|
|
669
824
|
const tab = document.createElement('button')
|
|
@@ -742,7 +897,7 @@ function legacyCopy(text: string): Promise<void> {
|
|
|
742
897
|
box.select()
|
|
743
898
|
const ok = document.execCommand('copy')
|
|
744
899
|
box.remove()
|
|
745
|
-
return ok ? Promise.resolve() : Promise.reject(new Error('
|
|
900
|
+
return ok ? Promise.resolve() : Promise.reject(new Error('execCommand copy failed'))
|
|
746
901
|
}
|
|
747
902
|
|
|
748
903
|
function copyText(text: string): Promise<void> {
|
|
@@ -763,12 +918,12 @@ function handleCopy(button: HTMLElement): void {
|
|
|
763
918
|
copyText(command).then(
|
|
764
919
|
() => {
|
|
765
920
|
button.dataset.copied = 'true'
|
|
766
|
-
flash(label, '
|
|
921
|
+
flash(label, t('copy.done'), 1200, () => {
|
|
767
922
|
delete button.dataset.copied
|
|
768
923
|
label.textContent = was
|
|
769
924
|
})
|
|
770
925
|
},
|
|
771
|
-
() => flash(label, '
|
|
926
|
+
() => flash(label, t('copy.fail'), 1600, () => (label.textContent = was)),
|
|
772
927
|
)
|
|
773
928
|
}
|
|
774
929
|
|
|
@@ -816,10 +971,21 @@ document.addEventListener('keydown', (ev) => {
|
|
|
816
971
|
|
|
817
972
|
// ---- 起動 ----
|
|
818
973
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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()
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 最新狀態。資料抓取與渲染都在 `issue-map.ts`,這支只負責包成完整 HTML 文件並回應。
|
|
5
5
|
*
|
|
6
6
|
* 這是 package.json 的預設 bin,所以在**要看的那個 repo** 裡直接跑就會畫那個 repo:
|
|
7
|
-
* bunx
|
|
7
|
+
* bunx issue-map@latest
|
|
8
8
|
*
|
|
9
9
|
* 起來之後直接開瀏覽器。不要的話設 `ISSUE_MAP_OPEN=0`——`bun --watch` 的開發模式就是這樣關掉
|
|
10
10
|
* 的,否則每存一次檔就多一個分頁。
|
|
@@ -47,7 +47,8 @@ async function page(): Promise<string> {
|
|
|
47
47
|
// 樣板是 artifact 用的片段;本機直接看要補上完整文件與 charset。
|
|
48
48
|
const head =
|
|
49
49
|
'<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
50
|
-
|
|
50
|
+
// `lang` 是預設語言;頁面上換語言時畫面那一支會改掉 `documentElement.lang`。
|
|
51
|
+
return `<!doctype html><html lang="en"><head>${head}</head><body>${await renderFragment(snapshot)}</body></html>`
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
const server = Bun.serve({
|