issue-map 0.4.0 → 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.
@@ -8,9 +8,10 @@
8
8
  * 文案一律走 `issue-map-i18n.ts` 的 `t()`,它讀的是當下語言。
9
9
  */
10
10
 
11
- import { locale, t } from './issue-map-i18n.ts'
11
+ import { LOCALE_NAME, LOCALES, locale, t } from './issue-map-i18n.ts'
12
12
  import {
13
13
  type Edge,
14
+ edgesWithin,
14
15
  type Group,
15
16
  layoutOf,
16
17
  type Layout,
@@ -34,6 +35,14 @@ export function esc(text: string): string {
34
35
  return text.replace(/[&<>"]/g, (c) => ESCAPES[c] ?? c)
35
36
  }
36
37
 
38
+ /**
39
+ * 語言選單的選項。建置時先畫一份進樣板,畫面那一側重建選單時用的是同一支——這一支檔案是標記
40
+ * 的唯一產生處,選單也不例外。
41
+ */
42
+ export function langOptionsHTML(): string {
43
+ return LOCALES.map((l) => `<option value="${l}">${esc(LOCALE_NAME[l])}</option>`).join('')
44
+ }
45
+
37
46
  /**
38
47
  * 站名就是標題開頭。刻意不另設一個「短名」欄位——那要每張票靠人維護,而且會變成票名的第二個
39
48
  * 來源,改了標題不會跟著改。截到讀不通的時候,滑鼠停留與下面的清單都有完整標題。
@@ -57,7 +66,13 @@ const MAX_MAPS = 20
57
66
  /** 一群票對應的一張圖。`members` 是票,`track` 是這一組的線色。 */
58
67
  export type Shown = { group: Group; members: readonly MapIssue[]; track: string }
59
68
 
60
- export type Row = { issue: MapIssue; parent: number | null; hasKids: boolean }
69
+ /** 清單上的一列。`parent` 是直屬主票,`depth` 是它在 parent 樹裡的層數(頂層是 0)。 */
70
+ export type Row = {
71
+ issue: MapIssue
72
+ parent: number | null
73
+ depth: number
74
+ hasKids: boolean
75
+ }
61
76
 
62
77
  export type Filter = Status | 'all'
63
78
 
@@ -67,12 +82,12 @@ export type Filter = Status | 'all'
67
82
  * 先算一次衍生資料(計數、反向索引),再把畫的函式掛上去——兩側都只要 `viewOf(snapshot)` 一次
68
83
  * 就能重複畫。
69
84
  */
70
- export function viewOf(snapshot: Partial<Snapshot>) {
71
- const issues: readonly MapIssue[] = snapshot.issues ?? []
85
+ export function viewOf(snapshot: Snapshot) {
86
+ const issues: readonly MapIssue[] = snapshot.issues
72
87
  const byNumber = new Map(issues.map((issue) => [issue.number, issue]))
73
88
  const work = issues.filter((issue) => !issue.isParent)
74
- const criticalPath = snapshot.criticalPath ?? 0
75
- const repo = snapshot.repo ?? ''
89
+ const criticalPath = snapshot.criticalPath
90
+ const repo = snapshot.repo
76
91
 
77
92
  const counts = Object.fromEntries(
78
93
  STATUS_ORDER.map((status) => [status, work.filter((i) => i.status === status).length]),
@@ -96,6 +111,13 @@ export function viewOf(snapshot: Partial<Snapshot>) {
96
111
 
97
112
  const statusLabel = (status: Status): string => t(`status.${status}`)
98
113
 
114
+ /**
115
+ * 現在可動的票,照清單的順序。導言與詳細面板的預設那一張都讀它——兩邊各算一次的話,導言說
116
+ * 「最前面是 #N」而面板開的卻是別張。
117
+ */
118
+ const frontline = (): MapIssue[] =>
119
+ work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
120
+
99
121
  /** 日期一律照當下語言排。快照裡存的是 ISO 字串,格式化是畫面的事。 */
100
122
  const dateTime = (iso: string): string =>
101
123
  new Date(iso).toLocaleString(locale(), { hour12: false })
@@ -169,14 +191,14 @@ export function viewOf(snapshot: Partial<Snapshot>) {
169
191
  /** 導言只講數得出來的事實。沒有可動的票、或整批都關完了,句子跟著換。 */
170
192
  const lede = (): string => {
171
193
  if (!openCount) return t('lede.allDone')
172
- const frontline = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)
194
+ const ready = frontline()
173
195
  const parts = [t('lede.open', { n: openCount })]
174
- if (frontline.length) {
175
- const first = frontline
196
+ if (ready.length) {
197
+ const first = ready
176
198
  .slice(0, 3)
177
- .map((i) => `<a href="${esc(i.url)}" target="_blank" rel="noopener">#${i.number}</a>`)
199
+ .map((i) => link(i.number))
178
200
  .join(t('join.items'))
179
- parts.push(t('lede.ready', { n: frontline.length, issues: first }))
201
+ parts.push(t('lede.ready', { n: ready.length, issues: first }))
180
202
  } else {
181
203
  parts.push(t('lede.none'))
182
204
  }
@@ -207,9 +229,14 @@ export function viewOf(snapshot: Partial<Snapshot>) {
207
229
  .join('')
208
230
  }
209
231
 
232
+ /**
233
+ * 頁尾的三段。**三段都是 HTML**,不是純文字——`refresh` 帶 `<code>`,另外兩段是逃脫過的文字,
234
+ * 所以兩邊都要當 HTML 塞。當成文字塞的話逃脫會被看見:repo 把標籤取名 `A&B` 時畫面上會出現
235
+ * `A&amp;B`。
236
+ */
210
237
  const footerHTML = (): { truth: string; refresh: string; config: string } => {
211
238
  const code = (command: string) => `<code>${esc(command)}</code>`
212
- const vocab = snapshot.labels ?? { ready: [], unready: [] }
239
+ const vocab = snapshot.labels
213
240
  return {
214
241
  truth: esc(t('foot.truth')),
215
242
  refresh: t('foot.refresh', {
@@ -218,7 +245,8 @@ export function viewOf(snapshot: Partial<Snapshot>) {
218
245
  }),
219
246
  // 標籤名是 repo 給的字,逃脫過才進 innerHTML。
220
247
  config: esc(
221
- vocab.ready.length
248
+ // 照快照記下的閘門說話。字彙一律有預設值、永遠非空,拿它的長度判斷的話這句必然說謊。
249
+ vocab.gated
222
250
  ? t('foot.vocab', {
223
251
  ready: vocab.ready.join(t('join.or')),
224
252
  unready: vocab.unready.join(t('join.slash')),
@@ -346,14 +374,15 @@ export function viewOf(snapshot: Partial<Snapshot>) {
346
374
  })
347
375
  if (layout.islandRows) {
348
376
  labels.push(
349
- `<div class="tlabel" style="top:${MAP.top + layout.islandFrom * MAP.row}px">` +
377
+ `<div class="tlabel" style="top:${MAP.top + layout.tracks.length * MAP.row}px">` +
350
378
  `<b>${esc(t('map.islandName'))}</b><span>${esc(t('map.islandSub'))}</span></div>`,
351
379
  )
352
380
  }
353
381
  return labels.join('')
354
382
  }
355
383
 
356
- const mapHTML = (shown: Shown): string => {
384
+ /** `parent` 不是 null 時,圖本體跟著那張主票收合——收合的對象由呼叫端指定。 */
385
+ const mapHTML = (shown: Shown, parent: number | null): string => {
357
386
  const layout = layoutOf(shown.members)
358
387
  const seenTo = new Map<number, number>()
359
388
  const rails = layout.edges
@@ -370,7 +399,7 @@ export function viewOf(snapshot: Partial<Snapshot>) {
370
399
  })
371
400
  .join('')
372
401
  return (
373
- `<div class="map-wrap" style="--track:${shown.track}">` +
402
+ `<div class="map-wrap"${foldTarget(parent)} style="--track:${shown.track}">` +
374
403
  trackLabelsHTML(layout) +
375
404
  `<svg width="${layout.width}" height="${layout.height}"` +
376
405
  ` viewBox="0 0 ${layout.width} ${layout.height}" role="img">` +
@@ -380,23 +409,20 @@ export function viewOf(snapshot: Partial<Snapshot>) {
380
409
  )
381
410
  }
382
411
 
383
- /** 每張圖底下重複一份 key。圖可以收起來,key 跟著收,不會留一段沒有圖的說明。 */
384
- const mapKeyHTML = (): string => {
385
- const shapes: readonly [string, string][] = [
386
- ['k-ready', t('legend.ready')],
387
- ['k-active', t('legend.active')],
388
- ['k-blocked', t('legend.blocked')],
389
- ['k-triage', t('legend.triage')],
390
- ['k-done', t('legend.done')],
391
- ]
392
- return (
393
- '<div class="map-key">' +
394
- shapes.map(([mark, label]) => `<span><i class="${mark}"></i>${esc(label)}</span>`).join('') +
395
- `<span>${esc(t('legend.solid'))}</span>` +
396
- `<span>${esc(t('legend.dashed'))}</span>` +
397
- '</div>'
398
- )
399
- }
412
+ /**
413
+ * 每張圖底下重複一份 key。圖可以收起來,key 跟著收,不會留一段沒有圖的說明。
414
+ *
415
+ * 五個項目照 `STATUS_ORDER` 長出來,順序與狀態本身同一份;class 是 `k-<狀態>`,文案鍵是
416
+ * `legend.<狀態>`,所以新增一個狀態不必回來改這裡。
417
+ */
418
+ const mapKeyHTML = (): string =>
419
+ '<div class="map-key">' +
420
+ STATUS_ORDER.map(
421
+ (status) => `<span><i class="k-${status}"></i>${esc(t(`legend.${status}`))}</span>`,
422
+ ).join('') +
423
+ `<span>${esc(t('legend.solid'))}</span>` +
424
+ `<span>${esc(t('legend.dashed'))}</span>` +
425
+ '</div>'
400
426
 
401
427
  /** 一群票的標題。主票那一群用主票標題(真資料),其他三種是頁面自己的分類。 */
402
428
  const groupTitle = (group: Group): string => {
@@ -430,18 +456,19 @@ export function viewOf(snapshot: Partial<Snapshot>) {
430
456
  const foldButtonHTML = (parent: number): string =>
431
457
  `<button type="button" class="fold" data-fold-for="${parent}" aria-expanded="true"></button>`
432
458
 
459
+ /** 掛了這個屬性的東西會跟著那張主票一起收起來。沒有主票的群收不了,就不掛。 */
460
+ const foldTarget = (parent: number | null): string =>
461
+ parent === null ? '' : ` data-parent="${parent}"`
462
+
433
463
  const shownGroups = (): Shown[] =>
434
- (snapshot.groups ?? []).map((group, index) => ({
464
+ snapshot.groups.map((group, index) => ({
435
465
  group,
436
466
  members: group.members.map(issueAt).filter((issue): issue is MapIssue => issue !== undefined),
437
467
  track: `var(${TRACK_COLOURS[index % TRACK_COLOURS.length]})`,
438
468
  }))
439
469
 
440
470
  /** 這一組裡有沒有票互相擋著。沒有的話畫出來只是一片點陣,不是線路圖。 */
441
- const hasRails = (shown: Shown): boolean => {
442
- const inGroup = new Set(shown.members.map((m) => m.number))
443
- return shown.members.some((m) => m.blockedBy.some((n) => inGroup.has(n)))
444
- }
471
+ const hasRails = (shown: Shown): boolean => edgesWithin(shown.members).length > 0
445
472
 
446
473
  const groupsHTML = (): string => {
447
474
  const groups = shownGroups()
@@ -456,11 +483,9 @@ export function viewOf(snapshot: Partial<Snapshot>) {
456
483
  const parent = shown.group.parent
457
484
  const fold = parent === null ? '' : foldButtonHTML(parent)
458
485
  const attrs = parent === null ? '' : ` data-fold="${parent}"`
459
- const bodyAttrs = parent === null ? '' : ` data-parent="${parent}"`
460
486
  const body = drawn.has(shown)
461
- ? mapHTML(shown).replace('class="map-wrap"', `class="map-wrap"${bodyAttrs}`) +
462
- mapKeyHTML()
463
- : `<p class="undrawn"${bodyAttrs}>${esc(t('group.undrawn'))}</p>`
487
+ ? mapHTML(shown, parent) + mapKeyHTML()
488
+ : `<p class="undrawn"${foldTarget(parent)}>${esc(t('group.undrawn'))}</p>`
464
489
  return (
465
490
  `<section class="group" style="--track:${shown.track}"${attrs}>` +
466
491
  `<div class="group-head">${fold}<h2>${esc(groupTitle(shown.group))}</h2>` +
@@ -513,10 +538,7 @@ export function viewOf(snapshot: Partial<Snapshot>) {
513
538
  }
514
539
 
515
540
  /** 沒有選取時預設看哪一張:第一張可接手的,再不然就第一張。 */
516
- const defaultPick = (): number | undefined => {
517
- const first = work.filter((i) => i.status === 'ready').sort(byStatusThenNumber)[0]
518
- return (first ?? issues[0])?.number
519
- }
541
+ const defaultPick = (): number | undefined => (frontline()[0] ?? issues[0])?.number
520
542
 
521
543
  const detailPanelHTML = (number: number | undefined): { status: string; html: string } => {
522
544
  const issue = number === undefined ? undefined : issueAt(number)
@@ -526,42 +548,66 @@ export function viewOf(snapshot: Partial<Snapshot>) {
526
548
 
527
549
  // ---- 清單 ----
528
550
 
529
- /** 主票在前、它的子票跟在後面。篩選時主票只要有子票入選就留著當標頭。 */
551
+ /**
552
+ * 主票在前、它的子票跟在後面。
553
+ *
554
+ * `parent` 是一個指標欄位,語意上就是任意深度的森林,所以這裡真的把它折成森林再深度優先
555
+ * 展平——**每張票只會出現一次**。把它當成「有沒有 parent 就是 root」的兩層結構、再補一條
556
+ * 「把漏掉的主票撿回來」的例外的話,三層鏈的中間那張會同時是子票又是主票,渲染成兩列。
557
+ *
558
+ * 篩選時祖先只要有後代入選就留著當標頭,否則子票沒了歸屬。
559
+ */
530
560
  const rowOrder = (filter: Filter): Row[] => {
531
- const pass = new Set(
532
- issues.filter((i) => filter === 'all' || i.status === filter).map((i) => i.number),
533
- )
534
561
  const kids = new Map<number, MapIssue[]>()
535
562
  const roots: MapIssue[] = []
536
563
  for (const issue of issues) {
564
+ // 指向沒被帶進快照的 parent,就當它自己是一枝的頂端。
537
565
  const parent = issue.parent !== null && byNumber.has(issue.parent) ? issue.parent : null
538
566
  if (parent === null) {
539
- if (pass.has(issue.number)) roots.push(issue)
567
+ roots.push(issue)
540
568
  continue
541
569
  }
542
- if (!pass.has(issue.number)) continue
543
570
  const siblings = kids.get(parent) ?? []
544
571
  siblings.push(issue)
545
572
  kids.set(parent, siblings)
546
573
  }
547
- // 有子票入選但自己沒入選的主票,仍要出現,否則子票就沒了歸屬。
548
- for (const parent of kids.keys()) {
549
- const issue = issueAt(parent)
550
- if (issue && !roots.includes(issue)) roots.push(issue)
551
- }
552
574
 
575
+ const hits = (issue: MapIssue): boolean => filter === 'all' || issue.status === filter
553
576
  const rows: Row[] = []
554
- for (const root of roots.sort(byStatusThenNumber)) {
555
- const children = (kids.get(root.number) ?? []).sort(byStatusThenNumber)
556
- rows.push({ issue: root, parent: null, hasKids: children.length > 0 })
557
- for (const child of children) rows.push({ issue: child, parent: root.number, hasKids: false })
577
+ const seen = new Set<number>()
578
+
579
+ /** 展平這一枝,回傳它有沒有東西入選。整枝都沒入選就把自己也收回去。 */
580
+ const walk = (issue: MapIssue, parent: number | null, depth: number): boolean => {
581
+ // parent 成環時止血:資料不該有環,真的有就讓它在這裡停下來而不是無限遞迴。
582
+ if (seen.has(issue.number)) return false
583
+ seen.add(issue.number)
584
+ const at = rows.length
585
+ rows.push({ issue, parent, depth, hasKids: false })
586
+ let keptChild = false
587
+ for (const child of (kids.get(issue.number) ?? []).sort(byStatusThenNumber)) {
588
+ keptChild = walk(child, issue.number, depth + 1) || keptChild
589
+ }
590
+ if (!keptChild && !hits(issue)) {
591
+ // 整枝沒人入選,連自己一起收回去——沒有後代被留下,這裡只會砍到自己那一列。
592
+ rows.length = at
593
+ return false
594
+ }
595
+ rows[at] = { issue, parent, depth, hasKids: keptChild }
596
+ return true
558
597
  }
598
+
599
+ for (const root of [...roots].sort(byStatusThenNumber)) walk(root, null, 0)
600
+ // 成環的票進不了任何一枝,但它們還是得看得見。
601
+ const stranded = issues.filter((issue) => !seen.has(issue.number)).sort(byStatusThenNumber)
602
+ for (const issue of stranded) walk(issue, null, 0)
559
603
  return rows
560
604
  }
561
605
 
562
606
  const rowHTML = (row: Row): string => {
563
607
  const issue = row.issue
564
- const parent = row.parent === null ? '' : ` data-parent="${row.parent}"`
608
+ // 縮排的級距在 CSS 裡,這裡只說第幾層。
609
+ const parent =
610
+ row.parent === null ? '' : ` data-parent="${row.parent}" style="--depth:${row.depth}"`
565
611
  const kids = row.hasKids ? ' data-haskids="true"' : ''
566
612
  const slot = row.hasKids ? `<span class="fold-slot">${foldButtonHTML(issue.number)}</span>` : ''
567
613
  const waitsTitle =
@@ -646,5 +692,3 @@ export function viewOf(snapshot: Partial<Snapshot>) {
646
692
  tabsHTML,
647
693
  }
648
694
  }
649
-
650
- export type View = ReturnType<typeof viewOf>
@@ -714,8 +714,9 @@
714
714
  .row[data-haskids='true'] {
715
715
  background: var(--paper);
716
716
  }
717
+ /* 縮排跟著 parent 樹的層數走,級距只有這一份。 */
717
718
  .row[data-parent] {
718
- margin-left: 28px;
719
+ margin-left: calc(var(--depth, 1) * 28px);
719
720
  }
720
721
 
721
722
  .row .id {