@marcomigozzi/trailhead 0.13.0 → 0.14.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.
Files changed (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -0,0 +1,453 @@
1
+ // Pure view logic of the map pane: parse what `gh` printed, group the rows, word a
2
+ // failure and an age, decide when a refresh may run. No engine calls here, so it is easy to test.
3
+ import { fit } from './work-state-view'
4
+ import type { BurndownHistory, BurndownIssue, MapInfo, MapSummary, PaneTicket, TicketState, TicketType } from '../../types'
5
+
6
+ export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
7
+
8
+ // `isCapped`: gh returned as many rows as it was asked for, so the list may be cut.
9
+ export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: number; isCapped: boolean }
10
+ export type TicketGroups = {
11
+ frontier: readonly PaneTicket[]
12
+ inProgress: readonly PaneTicket[]
13
+ blocked: readonly PaneTicket[]
14
+ }
15
+ export type Failure = { error: unknown } | { exitCode: number; stderr: string }
16
+ export type RefreshDecision = { run: true } | { run: false; waitMs: number }
17
+
18
+ // The most rows one `gh issue list` is asked for.
19
+ export const LIST_LIMIT = 200
20
+ // Same for the history read (`--state all`); newest first, so a cap loses the oldest tickets.
21
+ export const HISTORY_LIMIT = 5000
22
+
23
+ const LABEL_PREFIX = 'trailhead:'
24
+
25
+ const isPosInt = (v: unknown): v is number => Number.isInteger(v) && (v as number) > 0
26
+
27
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
28
+
29
+ // Text from GitHub is untrusted and only drawn: one line, no control, zero-width or bidi characters.
30
+ const flatten = (text: string): string =>
31
+ text
32
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g, '')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+
36
+ // The `.trailhead/active-map` marker: one line, `#<n>` or `<n>`.
37
+ export const parseActiveMap = (text: string): number | null => {
38
+ const m = /^#?(\d+)$/.exec(text.trim())
39
+ const n = m === null ? 0 : Number(m[1])
40
+ return isPosInt(n) ? n : null
41
+ }
42
+
43
+ const labelNames = (raw: unknown): string[] =>
44
+ Array.isArray(raw) ? raw.flatMap(l => (isRecord(l) && typeof l.name === 'string' ? [l.name] : [])) : []
45
+
46
+ const logins = (raw: unknown): string[] =>
47
+ Array.isArray(raw)
48
+ ? raw.flatMap(a => (isRecord(a) && typeof a.login === 'string' && a.login !== '' ? [flatten(a.login)] : []))
49
+ : []
50
+
51
+ const typeOf = (names: readonly string[]): TicketType | null => {
52
+ const found = names.find(n => TICKET_TYPES.some(t => n === `${LABEL_PREFIX}${t}`))
53
+ return found === undefined ? null : (found.slice(LABEL_PREFIX.length) as TicketType)
54
+ }
55
+
56
+ const stateOf = (names: readonly string[], assignees: readonly string[]): TicketState =>
57
+ names.includes(`${LABEL_PREFIX}blocked`) ? 'blocked' : assignees.length > 0 ? 'in-progress' : 'frontier'
58
+
59
+ // Only an https link is kept; anything else would be drawn as plain text.
60
+ const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
61
+
62
+ // `gh issue list --json number,title,labels,assignees,url`. Unverified rows are
63
+ // quarantined (counted, never listed); whiteboard rows do not belong to a map.
64
+ export const parseTickets = (stdout: string): ParsedTickets | null => {
65
+ let raw: unknown
66
+ try {
67
+ raw = JSON.parse(stdout)
68
+ } catch {
69
+ return null
70
+ }
71
+ if (!Array.isArray(raw)) return null
72
+ const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
73
+ const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
74
+ const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
75
+ const tickets = named
76
+ .filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`) && !names.includes(`${LABEL_PREFIX}whiteboard`))
77
+ .map(({ r, names }): PaneTicket => {
78
+ const assignees = logins(r.assignees)
79
+ return {
80
+ number: r.number as number,
81
+ title: typeof r.title === 'string' ? flatten(r.title) : '',
82
+ type: typeOf(names),
83
+ state: stateOf(names, assignees),
84
+ assignees,
85
+ url: safeUrl(r.url),
86
+ }
87
+ })
88
+ return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
89
+ }
90
+
91
+ // Markdown emphasis, code ticks and links reduced to their text.
92
+ const stripMarkdown = (line: string): string =>
93
+ line
94
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
95
+ .replace(/(\*\*|__|\*|`)/g, '')
96
+ .replace(/(^|\s)_([^_]+)_(?=\s|$|[.,;:!?])/g, '$1$2')
97
+ .replace(/^>\s*/, '')
98
+
99
+ const destinationOf = (body: string): string | null => {
100
+ const lines = body.split(/\r?\n/)
101
+ const at = lines.findIndex(l => /^##\s+Destination\s*$/i.test(l))
102
+ if (at < 0) return null
103
+ const first = lines.slice(at + 1).find(l => l.trim() !== '')
104
+ if (first === undefined || /^#/.test(first.trim())) return null
105
+ const text = flatten(stripMarkdown(first))
106
+ return text === '' ? null : text
107
+ }
108
+
109
+ // `gh issue view <n> --json title,body,url`.
110
+ export const parseMap = (stdout: string, number: number): MapInfo | null => {
111
+ let raw: unknown
112
+ try {
113
+ raw = JSON.parse(stdout)
114
+ } catch {
115
+ return null
116
+ }
117
+ if (!isRecord(raw) || typeof raw.title !== 'string') return null
118
+ return {
119
+ number,
120
+ title: flatten(raw.title),
121
+ destination: typeof raw.body === 'string' ? destinationOf(raw.body) : null,
122
+ url: safeUrl(raw.url),
123
+ }
124
+ }
125
+
126
+ // `gh issue list --label trailhead:map --state open --json number,title,url`: the open maps by number.
127
+ // A row with no usable number is skipped; anything that is not a list is unreadable.
128
+ export const parseMaps = (stdout: string): readonly MapSummary[] | null => {
129
+ let raw: unknown
130
+ try {
131
+ raw = JSON.parse(stdout)
132
+ } catch {
133
+ return null
134
+ }
135
+ if (!Array.isArray(raw)) return null
136
+ return raw
137
+ .filter(isRecord)
138
+ .filter(r => isPosInt(r.number))
139
+ .map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '' }))
140
+ .sort((a, b) => a.number - b.number)
141
+ }
142
+
143
+ // The map the pane draws: the one the person is viewing, else the active one.
144
+ export const viewTarget = (active: number | null, viewed: number | null): number | null => viewed ?? active
145
+
146
+ export type ViewChange = { activeChanged: boolean; maps: readonly MapSummary[] | null }
147
+
148
+ // The view goes back to following the active map when that map changed, or when the viewed
149
+ // map is gone from a freshly read open list (a missing list judges nothing).
150
+ export const nextViewed = (viewed: number | null, change: ViewChange): number | null => {
151
+ if (viewed === null || change.activeChanged) return null
152
+ return change.maps !== null && !change.maps.some(m => m.number === viewed) ? null : viewed
153
+ }
154
+
155
+ export type MapRowLayout = {
156
+ mark: string
157
+ label: string
158
+ aside: string | null
159
+ isActive: boolean
160
+ isViewed: boolean
161
+ // Every map but the viewed one: a press shows it (the active one: back to active).
162
+ isPressable: boolean
163
+ }
164
+ export type MapRowContext = { active: number | null; viewed: number | null; columns: number }
165
+
166
+ const ACTIVE_MARK = '● '
167
+ const ACTIVE_ASIDE = ' active'
168
+
169
+ // What one row of the maps list draws, cut to the pane width (a pressable row is a Button).
170
+ export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout => {
171
+ const isActive = m.number === ctx.active
172
+ const isViewed = m.number === viewTarget(ctx.active, ctx.viewed)
173
+ const mark = isActive ? ACTIVE_MARK : ' '
174
+ const aside = isActive ? ACTIVE_ASIDE : null
175
+ const prefix = `#${m.number}`
176
+ const isPressable = !isViewed
177
+ const room = ctx.columns - mark.length - (aside?.length ?? 0) - (isPressable ? BUTTON_CHROME : 0) - prefix.length - 1
178
+ const title = fit(m.title, room)
179
+ return { mark, label: title === '' ? prefix : `${prefix} ${title}`, aside, isActive, isViewed, isPressable }
180
+ }
181
+
182
+ const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
183
+
184
+ export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
185
+ const of = (state: TicketState) => tickets.filter(t => t.state === state).sort(byNumber)
186
+ return { frontier: of('frontier'), inProgress: of('in-progress'), blocked: of('blocked') }
187
+ }
188
+
189
+ const errorText = (err: unknown): string => {
190
+ const e = err as { code?: unknown; message?: unknown } | null
191
+ return `${String(e?.code ?? '')} ${String(e?.message ?? err)}`
192
+ }
193
+
194
+ // One short line saying why `gh` gave nothing.
195
+ export const failureReason = (failure: Failure): string => {
196
+ if ('error' in failure) {
197
+ const text = errorText(failure.error)
198
+ if (/ENOENT|not found/i.test(text)) return 'gh not installed'
199
+ if (/timed? ?out/i.test(text)) return 'offline: cannot reach GitHub'
200
+ return 'gh failed (did not finish)'
201
+ }
202
+ if (/gh auth login|not logged in|authentication/i.test(failure.stderr)) return 'gh not authenticated (run gh auth login)'
203
+ if (/error connecting|could not resolve|dial tcp|no such host|network is unreachable|connection (refused|reset)|timed? ?out/i.test(failure.stderr)) {
204
+ return 'offline: cannot reach GitHub'
205
+ }
206
+ return `gh failed (exit ${failure.exitCode})`
207
+ }
208
+
209
+ // Run now, or wait the rest of the floor (a clock that moved back waits the whole floor at most).
210
+ export const refreshDecision = (now: number, lastStartedAt: number | null, minMs: number): RefreshDecision => {
211
+ if (lastStartedAt === null) return { run: true }
212
+ const waitMs = Math.min(minMs, minMs - (now - lastStartedAt))
213
+ return waitMs <= 0 ? { run: true } : { run: false, waitMs }
214
+ }
215
+
216
+ export const ageLabel = (ms: number): string => {
217
+ if (ms < 5_000) return 'just now'
218
+ if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`
219
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
220
+ return `${Math.floor(ms / 3_600_000)}h ago`
221
+ }
222
+
223
+ export type RowAside = { kind: 'work' | 'queued' | 'holder'; text: string }
224
+ export type RowLayout = {
225
+ mark: string | null
226
+ label: string
227
+ type: string | null
228
+ aside: RowAside | null
229
+ isDim: boolean
230
+ isInPlay: boolean
231
+ }
232
+ export type RowContext = {
233
+ isInPlay: boolean
234
+ // True when the group holds the ticket in play: its rows share a two-cell mark column.
235
+ hasMarkColumn: boolean
236
+ pendingWork: number | null
237
+ columns: number
238
+ }
239
+
240
+ const MARK = '▸ '
241
+ const MARK_PAD = ' '
242
+ // A terminal Button draws `[ label ]`.
243
+ const BUTTON_CHROME = 4
244
+
245
+ const REFRESH_LABEL = 'refresh'
246
+ const CLOSE_LABEL = 'close'
247
+ // The gap between the refresh button and the status, and the one kept before the close button.
248
+ const FOOTER_GAPS = 2
249
+
250
+ export type FooterStatus = { text: string; isInline: boolean }
251
+
252
+ // The footer status beside the `refresh` and `close` buttons when it fits the room they leave;
253
+ // otherwise on a row of its own under them, cut only to the pane width (a reason keeps its hint).
254
+ export const footerStatus = (status: string, columns: number): FooterStatus => {
255
+ const buttons = REFRESH_LABEL.length + CLOSE_LABEL.length + 2 * BUTTON_CHROME
256
+ const room = columns - buttons - FOOTER_GAPS
257
+ return Array.from(status).length <= room ? { text: status, isInline: true } : { text: fit(status, columns), isInline: false }
258
+ }
259
+
260
+ const asideOf = (t: PaneTicket, pendingWork: number | null): RowAside | null => {
261
+ if (t.state === 'frontier') return pendingWork === t.number ? { kind: 'queued', text: 'queued' } : { kind: 'work', text: 'work' }
262
+ return t.assignees.length === 0 ? null : { kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') }
263
+ }
264
+
265
+ const asideWidth = (a: RowAside | null): number =>
266
+ a === null ? 0 : a.text.length + (a.kind === 'work' ? BUTTON_CHROME : 0) + 1
267
+
268
+ // What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
269
+ export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
270
+ const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
271
+ const aside = asideOf(t, ctx.pendingWork)
272
+ const type = t.type === null ? null : ` ${t.type}`
273
+ const prefix = `#${t.number}`
274
+ const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1
275
+ const title = fit(t.title, room)
276
+ return {
277
+ mark,
278
+ label: title === '' ? prefix : `${prefix} ${title}`,
279
+ type,
280
+ aside,
281
+ isDim: t.state === 'blocked',
282
+ isInPlay: ctx.isInPlay,
283
+ }
284
+ }
285
+
286
+ const REMOVED_LABELS = [`${LABEL_PREFIX}superseded`, `${LABEL_PREFIX}out-of-scope`]
287
+
288
+ const parseTime = (raw: unknown): number | null => {
289
+ if (typeof raw !== 'string') return null
290
+ const ms = Date.parse(raw)
291
+ return Number.isNaN(ms) ? null : ms
292
+ }
293
+
294
+ // gh prints an open issue's closedAt as the zero date: null, absent or anything at or before the epoch
295
+ // means open. Any other value that does not parse is unreadable (undefined), and the row is skipped.
296
+ const closedTime = (raw: unknown): number | null | undefined => {
297
+ if (raw === null || raw === undefined) return null
298
+ const ms = parseTime(raw)
299
+ if (ms === null) return undefined
300
+ return ms <= 0 ? null : ms
301
+ }
302
+
303
+ const historyIssue = (r: Record<string, unknown>): BurndownIssue[] => {
304
+ const names = labelNames(r.labels)
305
+ if (names.includes(`${LABEL_PREFIX}unverified`) || names.includes(`${LABEL_PREFIX}whiteboard`)) return []
306
+ const openedAt = parseTime(r.createdAt)
307
+ const closedAt = closedTime(r.closedAt)
308
+ if (openedAt === null || closedAt === undefined || (closedAt !== null && closedAt < openedAt)) return []
309
+ return [{ openedAt, closedAt, isRemoved: REMOVED_LABELS.some(l => names.includes(l)) }]
310
+ }
311
+
312
+ // `gh issue list --state all --json number,labels,createdAt,closedAt`.
313
+ export const parseHistory = (stdout: string): BurndownHistory | null => {
314
+ let raw: unknown
315
+ try {
316
+ raw = JSON.parse(stdout)
317
+ } catch {
318
+ return null
319
+ }
320
+ if (!Array.isArray(raw)) return null
321
+ return { issues: raw.filter(isRecord).flatMap(historyIssue), isCapped: raw.length >= HISTORY_LIMIT }
322
+ }
323
+
324
+ export type BurndownEnd = { at: number; label: string }
325
+
326
+ // Where the chart ends: now when its history was read by the last good fetch, else the time it was
327
+ // read, labelled with its age (a failed read keeps the last curve, it is not stretched to the present).
328
+ export const burndownEnd = (
329
+ d: { historyAt: number | null; fetchedAt: number | null; error: string | null },
330
+ now: number,
331
+ ): BurndownEnd =>
332
+ d.historyAt === null || (d.error === null && d.historyAt === d.fetchedAt)
333
+ ? { at: now, label: 'now' }
334
+ : { at: d.historyAt, label: ageLabel(now - d.historyAt) }
335
+
336
+ export type BurndownSeries = {
337
+ values: readonly number[]
338
+ open: number
339
+ peak: number
340
+ done: number
341
+ removed: number
342
+ startedAt: number
343
+ }
344
+
345
+ // Open tickets at `buckets` evenly spaced times from the first ticket to `now` (the last sample).
346
+ // The most open at once over every event, not only at the samples: same rule as a sample,
347
+ // a time counts every open and close at or before it.
348
+ const truePeak = (opened: readonly number[], closedTimes: readonly number[]): number => {
349
+ let peak = 0
350
+ let closes = 0
351
+ opened.forEach((t, n) => {
352
+ while (closes < closedTimes.length && (closedTimes[closes] as number) <= t) closes += 1
353
+ peak = Math.max(peak, n + 1 - closes)
354
+ })
355
+ return peak
356
+ }
357
+
358
+ export const burndownSeries = (history: BurndownHistory, now: number, buckets: number): BurndownSeries | null => {
359
+ if (history.issues.length === 0 || buckets <= 0) return null
360
+ const issues = history.issues.map(i => ({
361
+ ...i,
362
+ openedAt: Math.min(i.openedAt, now),
363
+ closedAt: i.closedAt === null ? null : Math.min(i.closedAt, now),
364
+ }))
365
+ const startedAt = Math.min(...issues.map(i => i.openedAt))
366
+ const timeAt = (n: number) => (n === buckets - 1 ? now : startedAt + ((now - startedAt) * n) / (buckets - 1))
367
+ // Sort once, then sweep: the samples only move forward, so each time is passed over once.
368
+ const opened = issues.map(i => i.openedAt).sort((a, b) => a - b)
369
+ const closedTimes = issues.flatMap(i => (i.closedAt === null ? [] : [i.closedAt])).sort((a, b) => a - b)
370
+ let opens = 0
371
+ let closes = 0
372
+ const values = Array.from({ length: buckets }, (_, n) => {
373
+ const t = timeAt(n)
374
+ while (opens < opened.length && (opened[opens] as number) <= t) opens += 1
375
+ while (closes < closedTimes.length && (closedTimes[closes] as number) <= t) closes += 1
376
+ return opens - closes
377
+ })
378
+ const closed = issues.filter(i => i.closedAt !== null)
379
+ return {
380
+ values,
381
+ open: values[values.length - 1] as number,
382
+ peak: Math.max(truePeak(opened, closedTimes), ...values),
383
+ done: closed.filter(i => !i.isRemoved).length,
384
+ removed: closed.filter(i => i.isRemoved).length,
385
+ startedAt,
386
+ }
387
+ }
388
+
389
+ const EIGHTHS = '▁▂▃▄▅▆▇█'
390
+
391
+ // Bar height in eighths of a cell over `levels`: zero stays zero, any other value is at least one.
392
+ const heightOf = (value: number, peak: number, levels: number): number =>
393
+ value <= 0 || peak <= 0 ? 0 : Math.min(levels, Math.max(1, Math.round((value / peak) * levels)))
394
+
395
+ // One row of eighth blocks; a zero is a space.
396
+ export const sparkline = (values: readonly number[], peak: number): string =>
397
+ values.map(v => (heightOf(v, peak, 8) === 0 ? ' ' : (EIGHTHS[heightOf(v, peak, 8) - 1] as string))).join('')
398
+
399
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
400
+ const DEFAULT_COLOR = 0x01000000
401
+ const SPACE = 0x20
402
+ const FULL_BLOCK = 0x2588
403
+ const LOWER_BLOCK_BASE = 0x2580
404
+
405
+ // Padded base64 written here: the mod runtime is not promised Buffer, btoa or toBase64.
406
+ const base64 = (bytes: Uint8Array): string => {
407
+ const chars: string[] = []
408
+ for (let i = 0; i < bytes.length; i += 3) {
409
+ const b0 = bytes[i] as number
410
+ const b1 = bytes[i + 1]
411
+ const b2 = bytes[i + 2]
412
+ const n = (b0 << 16) | ((b1 ?? 0) << 8) | (b2 ?? 0)
413
+ chars.push(BASE64[(n >> 18) & 63] as string, BASE64[(n >> 12) & 63] as string)
414
+ chars.push(b1 === undefined ? '=' : (BASE64[(n >> 6) & 63] as string), b2 === undefined ? '=' : (BASE64[n & 63] as string))
415
+ }
416
+ return chars.join('')
417
+ }
418
+
419
+ // The glyph of one cell: `fromBottom` rows below it are full, `eighths` is the bar height.
420
+ const glyphAt = (eighths: number, fromBottom: number): number => {
421
+ const full = Math.floor(eighths / 8)
422
+ if (fromBottom < full) return FULL_BLOCK
423
+ return fromBottom === full && eighths % 8 > 0 ? LOWER_BLOCK_BASE + (eighths % 8) : SPACE
424
+ }
425
+
426
+ // The Raster `cells` prop: row-major (top first) little-endian u32 triplets [codePoint, fg, bg].
427
+ export const rasterCells = (values: readonly number[], peak: number, rows: number, fg: number): string => {
428
+ const columns = values.length
429
+ const heights = values.map(v => heightOf(v, peak, rows * 8))
430
+ const bytes = new Uint8Array(columns * rows * 12)
431
+ const view = new DataView(bytes.buffer)
432
+ for (let row = 0; row < rows; row += 1) {
433
+ heights.forEach((h, col) => {
434
+ const at = (row * columns + col) * 12
435
+ view.setUint32(at, glyphAt(h, rows - 1 - row), true)
436
+ view.setUint32(at + 4, fg, true)
437
+ view.setUint32(at + 8, DEFAULT_COLOR, true)
438
+ })
439
+ }
440
+ return base64(bytes)
441
+ }
442
+
443
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
444
+ const DAY_MS = 86_400_000
445
+ const pad2 = (n: number) => String(n).padStart(2, '0')
446
+
447
+ // Left axis label, in UTC so it is the same everywhere: `Sep 18`, or `today 09:05` under a day.
448
+ export const axisStart = (startedAt: number, now: number): string => {
449
+ const d = new Date(startedAt)
450
+ return now - startedAt < DAY_MS
451
+ ? `today ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`
452
+ : `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`
453
+ }