jackherd 1.10.0 → 1.11.1

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 (66) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +24 -16
  3. package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
  4. package/dist/config.d.ts +60 -0
  5. package/dist/config.js +39 -4
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +4 -2
  8. package/docs/follow-dark.svg +48 -0
  9. package/docs/follow.svg +48 -0
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/open-question/run.ts +8 -3
  13. package/kit/rules/rules/kit/pull-requests.md +2 -0
  14. package/kit/workflow/skills/follow/SKILL.md +3 -3
  15. package/kit/workflow/skills/orchestrate/SKILL.md +11 -3
  16. package/package.json +2 -2
  17. package/recipes/sources.md +4 -0
  18. package/schemas/jackherd.schema.json +80 -0
  19. package/src/cli.ts +1 -0
  20. package/src/config/schema.ts +24 -1
  21. package/src/config/tones.ts +27 -0
  22. package/src/lane-prompt/additions.ts +14 -0
  23. package/src/lane-prompt/command.ts +8 -2
  24. package/src/lane-prompt/input.ts +44 -3
  25. package/src/lane-prompt/options.ts +2 -0
  26. package/src/mermaid/command.ts +36 -0
  27. package/src/mermaid/problems.ts +48 -0
  28. package/src/plan-format/dependencies.ts +2 -2
  29. package/src/plan-format/waiting.ts +15 -2
  30. package/src/plans/appointment-rows.ts +34 -10
  31. package/src/plans/ask-coordinator.ts +46 -12
  32. package/src/plans/ask-target.ts +13 -2
  33. package/src/plans/board-close.ts +85 -0
  34. package/src/plans/frame.ts +5 -12
  35. package/src/plans/git.ts +35 -0
  36. package/src/plans/groomed.ts +27 -0
  37. package/src/plans/layout-phase.ts +7 -4
  38. package/src/plans/layout.ts +258 -48
  39. package/src/plans/link-chips.ts +22 -4
  40. package/src/plans/live.ts +73 -5
  41. package/src/plans/next-rows.ts +44 -0
  42. package/src/plans/planned.ts +22 -0
  43. package/src/plans/row-actions.ts +52 -0
  44. package/src/plans/row-focus.ts +148 -0
  45. package/src/plans/row-targets.ts +7 -0
  46. package/src/plans/selection.ts +14 -1
  47. package/src/plans/settings.ts +10 -0
  48. package/src/plans/snapshot.ts +79 -21
  49. package/src/plans/source-asked.ts +19 -0
  50. package/src/plans/source-prefix.ts +17 -0
  51. package/src/plans/source-rows.ts +188 -0
  52. package/src/plans/texts.ts +28 -3
  53. package/src/plans/tones.ts +30 -0
  54. package/src/plans/view.ts +8 -2
  55. package/src/plans/waiting-click.ts +129 -24
  56. package/src/terminal/text.ts +8 -0
  57. package/src/terminal/types.ts +4 -0
  58. package/src/vocabulary/index.ts +10 -0
  59. package/src/watch/plan-badges-read.ts +9 -2
  60. package/src/watch/plan-badges.ts +10 -2
  61. package/src/watch/report-questions.ts +4 -3
  62. package/src/watch/source-entries.ts +55 -0
  63. package/src/watch/source-fields.ts +1 -0
  64. package/src/watch/source-statuses.ts +60 -0
  65. package/src/watch/source-tickets.ts +21 -1
  66. package/src/watch/sources.ts +41 -1
@@ -12,6 +12,10 @@ export type WaitingItem = {
12
12
  since: string
13
13
  item: string
14
14
  open: boolean
15
+ /** The board row's origin cell, one line; absent when the table has no such column or the cell is empty. */
16
+ origin?: string
17
+ /** What the herd shows before the item (the label of the source the row came from); set by the herd, never read from the board. */
18
+ prefix?: string
15
19
  /** A `file:` address the item links to, for a sent file; absent for a row of the board. */
16
20
  url?: string
17
21
  /** Absent for a row of the board. */
@@ -30,7 +34,7 @@ const isOpen = (status: string, language: PlanLanguage): boolean => {
30
34
 
31
35
  /**
32
36
  * The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
33
- * found by their header names in any order; the origin column is not read. A table without the since, item or status
37
+ * found by their header names in any order; the origin column is read when the table has it and the cell is not empty. A table without the since, item or status
34
38
  * column, a section without a table and a row without an item give nothing.
35
39
  */
36
40
  export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
@@ -40,11 +44,20 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
40
44
  const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
41
45
  if (positions.includes(-1)) return []
42
46
  const [since, item, status] = positions
47
+ const originPosition = header.indexOf(columns.origin)
43
48
  return rows.flatMap((cells) => {
44
49
  const text = oneLine(cells[item] ?? '')
50
+ const origin = oneLine(cells[originPosition] ?? '')
45
51
  return text === ''
46
52
  ? []
47
- : [{ since: oneLine(cells[since] ?? ''), item: text, open: isOpen(cells[status] ?? '', language) }]
53
+ : [
54
+ {
55
+ since: oneLine(cells[since] ?? ''),
56
+ item: text,
57
+ open: isOpen(cells[status] ?? '', language),
58
+ ...(origin === '' ? {} : { origin }),
59
+ },
60
+ ]
48
61
  })
49
62
  }
50
63
 
@@ -1,13 +1,15 @@
1
- import { twoDigits } from '../plan-format/clock'
1
+ import { DAY_MS, twoDigits } from '../plan-format/clock'
2
2
  import { padVisible } from '../terminal/text'
3
3
  import { textWidth } from '../terminal/width'
4
4
  import type { PlanLanguage } from '../vocabulary'
5
5
  import { type Appointment, readAppointmentTable } from '../watch/appointments'
6
+ import { ASK_KIND } from './ask-target'
7
+ import { DONE_KIND } from './row-targets'
6
8
  import { shortTitle } from './task-name'
7
- import type { BlockItem } from './waiting-click'
9
+ import { type BlockButton, type BlockItem, DONE_BUTTON, MOVE_BUTTON, RUN_BUTTON } from './waiting-click'
8
10
 
9
11
  /** An open appointment as the herd shows it: `label` is the weekday and the time, `Mon 09:03` (the weekday is English whatever the board's language); `key` is the watcher's. */
10
- export type AppointmentRow = { key: string; label: string; action: string }
12
+ export type AppointmentRow = { key: string; when: string; label: string; action: string }
11
13
 
12
14
  /** The appointment block: the open rows, the earliest first. */
13
15
  export type AppointmentBlock = { rows: AppointmentRow[] }
@@ -18,11 +20,26 @@ const WEEKDAY_LOCALE = 'en-US'
18
20
 
19
21
  const weekdayOf = (when: Date): string => new Intl.DateTimeFormat(WEEKDAY_LOCALE, { weekday: 'short' }).format(when)
20
22
 
21
- const rowOf = ({ when, action, key }: Appointment): AppointmentRow => ({
22
- key,
23
- label: `${weekdayOf(when)} ${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())}`,
24
- action,
25
- })
23
+ const DATE_FROM_DAYS_AHEAD = 7
24
+
25
+ const startOfDay = (when: Date): number => new Date(when.getFullYear(), when.getMonth(), when.getDate()).getTime()
26
+
27
+ const daysAhead = (when: Date, now: Date): number => Math.round((startOfDay(when) - startOfDay(now)) / DAY_MS)
28
+
29
+ /** The weekday names a day within the week; from a week on it is the date, `13.01.`, since the weekday would repeat. */
30
+ const dayOf = (when: Date, now: Date): string =>
31
+ daysAhead(when, now) >= DATE_FROM_DAYS_AHEAD
32
+ ? `${twoDigits(when.getDate())}.${twoDigits(when.getMonth() + 1)}.`
33
+ : weekdayOf(when)
34
+
35
+ const rowOf =
36
+ (now: Date) =>
37
+ ({ when, whenText, action, key }: Appointment): AppointmentRow => ({
38
+ key,
39
+ when: whenText,
40
+ label: `${dayOf(when, now)} ${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())}`,
41
+ action,
42
+ })
26
43
 
27
44
  /**
28
45
  * The open appointments of the board's section, read as the watcher reads them, the earliest first. A done row, a
@@ -32,7 +49,7 @@ export const appointmentRows = (board: string, language: PlanLanguage, now: Date
32
49
  readAppointmentTable(board, language, now)
33
50
  .appointments.filter(({ open }) => open)
34
51
  .sort((left, right) => left.when.getTime() - right.when.getTime())
35
- .map(rowOf)
52
+ .map(rowOf(now))
36
53
 
37
54
  /** The block of the board's open appointments, as the herd shows them. */
38
55
  export const appointmentBlock = (board: string, language: PlanLanguage, now: Date): AppointmentBlock => ({
@@ -41,7 +58,13 @@ export const appointmentBlock = (board: string, language: PlanLanguage, now: Dat
41
58
 
42
59
  const appointmentKey = ({ key }: AppointmentRow): string => `appointment>${key}`
43
60
 
44
- /** The rows as the block draws them; a click on one only expands it, so they carry no address and no question. */
61
+ const buttonsOf = ({ when, action }: AppointmentRow): BlockButton[] => [
62
+ { label: DONE_BUTTON, click: { done: { kind: DONE_KIND.appointment, when, action } } },
63
+ { label: RUN_BUTTON, click: { ask: { kind: ASK_KIND.appointmentRun, when, action } } },
64
+ { label: MOVE_BUTTON, click: { ask: { kind: ASK_KIND.appointmentMove, when, action } } },
65
+ ]
66
+
67
+ /** The rows as the block draws them; a click on the row only expands it, the buttons close it, run it or move it. */
45
68
  export const appointmentItems = (rows: readonly AppointmentRow[]): BlockItem[] => {
46
69
  const timeWidth = Math.max(0, ...rows.map(({ label }) => textWidth(label)))
47
70
  return rows.map((row) => ({
@@ -52,6 +75,7 @@ export const appointmentItems = (rows: readonly AppointmentRow[]): BlockItem[] =
52
75
  title: shortTitle(row.action),
53
76
  url: null,
54
77
  ask: null,
78
+ buttons: buttonsOf(row),
55
79
  }))
56
80
  }
57
81
 
@@ -17,34 +17,68 @@ export type AskDependencies = {
17
17
  run: HerdrRunner
18
18
  }
19
19
 
20
- type AskSentences = { question: (id: string) => string; boardRow: (key: string) => string }
20
+ /** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
21
+ type AskSentences = Record<AskTarget['kind'], (subject: string, scope: string) => string>
21
22
 
22
23
  const ASK_LINES: Record<PlanLanguage, AskSentences> = {
23
24
  [PLAN_LANGUAGE.de]: {
24
- question: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting).`,
25
- boardRow: (key) => `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).`,
25
+ [ASK_KIND.question]: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting).`,
26
+ [ASK_KIND.boardRow]: (key) => `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).`,
27
+ [ASK_KIND.appointmentRun]: (key) => `Führe den Termin \`${key}\` jetzt aus, unter den Freigaben, die er braucht.`,
28
+ [ASK_KIND.appointmentMove]: (key) =>
29
+ `Frag den User per Dialog nach der neuen Zeit für den Termin \`${key}\` und trag sie ins Board ein (verschobene Zeile = neuer Termin).`,
30
+ [ASK_KIND.sourceEntry]: (title, source) =>
31
+ `Sieh dir die Meldung \`${title}\` der Quelle \`${source}\` an und stell mir, falls nötig, die Frage dazu (jh waiting).`,
26
32
  },
27
33
  [PLAN_LANGUAGE.en]: {
28
- question: (id) => `Ask me the open question ${id} now (jh waiting).`,
29
- boardRow: (key) => `Ask me the open board row \`${key}\` now (jh waiting).`,
34
+ [ASK_KIND.question]: (id) => `Ask me the open question ${id} now (jh waiting).`,
35
+ [ASK_KIND.boardRow]: (key) => `Ask me the open board row \`${key}\` now (jh waiting).`,
36
+ [ASK_KIND.appointmentRun]: (key) => `Run the appointment \`${key}\` now, under the approvals it needs.`,
37
+ [ASK_KIND.appointmentMove]: (key) =>
38
+ `Ask the user in a dialog for the new time of the appointment \`${key}\` and write it into the board (a moved row is a new appointment).`,
39
+ [ASK_KIND.sourceEntry]: (title, source) =>
40
+ `Look at the report \`${title}\` of the source \`${source}\` and, if it needs it, put the question about it to me (jh waiting).`,
30
41
  },
31
42
  }
32
43
 
44
+ /** What the sentence names: the id of a question, else the sanitised key of the row (`boardRowKey`). */
45
+ const subjectKeyOf = (target: AskTarget): string => {
46
+ if (target.kind === ASK_KIND.question) return target.id
47
+ if (target.kind === ASK_KIND.sourceEntry) return boardRowKey('', target.title)
48
+ return target.kind === ASK_KIND.boardRow
49
+ ? boardRowKey(target.since, target.item)
50
+ : boardRowKey(target.when, target.action)
51
+ }
52
+
53
+ /** The source of an entry, sanitised like the key; `''` for every other target. */
54
+ const scopeOf = (target: AskTarget): string =>
55
+ target.kind === ASK_KIND.sourceEntry ? boardRowKey('', target.source) : ''
56
+
33
57
  /**
34
58
  * The line sent to the coordinator: a fixed sentence that carries the id of a question, or the sanitised key of a
35
- * board row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
59
+ * row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
36
60
  * the file it reads. The caller checks the id with `isQuestionId`.
37
61
  */
38
62
  export const askLine = (target: AskTarget, language: PlanLanguage): string =>
39
- target.kind === ASK_KIND.question
40
- ? ASK_LINES[language].question(target.id)
41
- : ASK_LINES[language].boardRow(boardRowKey(target.since, target.item))
63
+ ASK_LINES[language][target.kind](subjectKeyOf(target), scopeOf(target))
42
64
 
43
65
  const isAskable = (target: AskTarget): boolean =>
44
- target.kind === ASK_KIND.question ? isQuestionId(target.id) : boardRowKey(target.since, target.item) !== ''
66
+ target.kind === ASK_KIND.question
67
+ ? isQuestionId(target.id)
68
+ : subjectKeyOf(target) !== '' && (target.kind !== ASK_KIND.sourceEntry || scopeOf(target) !== '')
69
+
70
+ const SUBJECT_NAMES: Record<AskTarget['kind'], string> = {
71
+ [ASK_KIND.question]: 'question',
72
+ [ASK_KIND.boardRow]: 'board row',
73
+ [ASK_KIND.appointmentRun]: 'appointment',
74
+ [ASK_KIND.appointmentMove]: 'appointment',
75
+ [ASK_KIND.sourceEntry]: 'report',
76
+ }
45
77
 
46
- const subjectOf = (target: AskTarget): string =>
47
- target.kind === ASK_KIND.question ? `question ${target.id}` : `board row ${boardRowKey(target.since, target.item)}`
78
+ const subjectOf = (target: AskTarget): string => {
79
+ const scope = scopeOf(target)
80
+ return `${SUBJECT_NAMES[target.kind]} ${subjectKeyOf(target)}${scope === '' ? '' : ` of ${scope}`}`
81
+ }
48
82
 
49
83
  const agentNamesOf = (run: HerdrRunner): string[] => parseAgentList(run(['agent', 'list'])).map(({ name }) => name)
50
84
 
@@ -1,11 +1,22 @@
1
1
  import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
2
 
3
- /** What the coordinator can be asked to put to the user: an open dialog question, or an open row of the board. */
4
- export const ASK_KIND = { question: 'question', boardRow: 'boardRow' } as const
3
+ /**
4
+ * What the coordinator can be asked: to put an open dialog question or an open board row to the user, to run an
5
+ * appointment now, or to ask the user for a new time for it.
6
+ */
7
+ export const ASK_KIND = {
8
+ question: 'question',
9
+ boardRow: 'boardRow',
10
+ appointmentRun: 'appointmentRun',
11
+ appointmentMove: 'appointmentMove',
12
+ sourceEntry: 'sourceEntry',
13
+ } as const
5
14
 
6
15
  export type AskTarget =
7
16
  | { kind: typeof ASK_KIND.question; id: string }
8
17
  | { kind: typeof ASK_KIND.boardRow; since: string; item: string }
18
+ | { kind: typeof ASK_KIND.appointmentRun | typeof ASK_KIND.appointmentMove; when: string; action: string }
19
+ | { kind: typeof ASK_KIND.sourceEntry; source: string; title: string }
9
20
 
10
21
  /** The longest key a prompt line carries for a board row, the ellipsis included. */
11
22
  export const MAX_ROW_KEY_LENGTH = 120
@@ -0,0 +1,85 @@
1
+ import { twoDigits } from '../plan-format/clock'
2
+ import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
3
+ import { APPOINTMENT_VOCABULARY, PLAN_LANGUAGE, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
4
+
5
+ type SectionColumns = {
6
+ heading: string
7
+ /** The header names of the two cells that identify a row, then the names that count as the status column. */
8
+ keys: readonly [string, string]
9
+ status: readonly string[]
10
+ }
11
+
12
+ const DONE_WORD: Record<PlanLanguage, string> = { [PLAN_LANGUAGE.de]: 'erledigt', [PLAN_LANGUAGE.en]: 'done' }
13
+
14
+ /** The status a closed row gets, `erledigt (User <date> <time>)`: the time comes from `now`, never from an estimate. */
15
+ export const doneStatus = (language: PlanLanguage, now: Date): string =>
16
+ `${DONE_WORD[language]} (User ${twoDigits(now.getDate())}.${twoDigits(now.getMonth() + 1)}. ${twoDigits(now.getHours())}:${twoDigits(now.getMinutes())})`
17
+
18
+ const squeezed = (text: string): string => text.replace(/\s+/g, ' ').trim()
19
+
20
+ const waitingColumns = (language: PlanLanguage): SectionColumns => {
21
+ const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
22
+ return { heading, keys: [columns.since, columns.item], status: [columns.status, ...statusAlso] }
23
+ }
24
+
25
+ const appointmentColumns = (language: PlanLanguage): SectionColumns => {
26
+ const { heading, columns, statusAlso } = APPOINTMENT_VOCABULARY[language]
27
+ return { heading, keys: [columns.when, columns.action], status: [columns.status, ...statusAlso] }
28
+ }
29
+
30
+ /** The line with the status cell (the `index`th between the bars) replaced; the other cells keep their text as written. */
31
+ const withStatus = (line: string, index: number, status: string): string => {
32
+ const parts = line.split('|')
33
+ if (index + 1 >= parts.length) return line
34
+ parts[index + 1] = ` ${status} `
35
+ return parts.join('|')
36
+ }
37
+
38
+ /**
39
+ * The board with the status cell of the first row of the section's table whose two key cells equal `keys` replaced by
40
+ * `status`; null when the section, a column or the row is missing, so a row the board lost since the herd drew it
41
+ * writes nothing.
42
+ */
43
+ const closeRow = (
44
+ board: string,
45
+ { heading, keys: keyNames, status: statusNames }: SectionColumns,
46
+ keys: readonly [string, string],
47
+ status: string,
48
+ ): string | null => {
49
+ const start = sectionStart(board, heading)
50
+ if (start === -1) return null
51
+ const lines = sectionLines(board, heading)
52
+ const header = lines.find((line) => line.startsWith('|'))
53
+ if (!header) return null
54
+ const names = header
55
+ .slice(1, header.endsWith('|') ? -1 : undefined)
56
+ .split('|')
57
+ .map((cell) => cell.trim())
58
+ const positions = keyNames.map((name) => names.indexOf(name))
59
+ const statusAt = names.findIndex((name) => statusNames.includes(name))
60
+ if (positions.includes(-1) || statusAt === -1) return null
61
+ const row = firstTableRows(lines).find(({ cells }) =>
62
+ positions.every((position, index) => squeezed(cells[position] ?? '') === squeezed(keys[index])),
63
+ )
64
+ if (!row) return null
65
+ const all = board.split('\n')
66
+ const at = start + row.index
67
+ all[at] = withStatus(all[at], statusAt, status)
68
+ return all.join('\n')
69
+ }
70
+
71
+ /** The board with the waiting row `since`/`item` closed at `now`; null when the board has no such row. */
72
+ export const closeWaitingRow = (
73
+ board: string,
74
+ language: PlanLanguage,
75
+ { since, item }: { since: string; item: string },
76
+ now: Date,
77
+ ): string | null => closeRow(board, waitingColumns(language), [since, item], doneStatus(language, now))
78
+
79
+ /** The board with the appointment `when`/`action` closed at `now`; null when the board has no such row. */
80
+ export const closeAppointmentRow = (
81
+ board: string,
82
+ language: PlanLanguage,
83
+ { when, action }: { when: string; action: string },
84
+ now: Date,
85
+ ): string | null => closeRow(board, appointmentColumns(language), [when, action], doneStatus(language, now))
@@ -1,4 +1,5 @@
1
- import { BAR_SEGMENT, INVERSE_ON, paint, RESET, stripAnsi, visibleLength } from '../terminal/text'
1
+ import { paint, visibleLength } from '../terminal/text'
2
+ import { focusLine, isFocused } from './row-focus'
2
3
  import type { ClickTarget, Selection } from './selection'
3
4
 
4
5
  /**
@@ -73,17 +74,9 @@ const branches = (children: Frame[]): Frame[] => {
73
74
  export const nest = (children: Frame[]): Frame => joinFrames(branches(children))
74
75
 
75
76
  /**
76
- * A row drawn inverse across the pane. A progress bar inside it is switched back to normal video: inverted,
77
- * its filled and empty blocks would trade places with the background and the bar would be unreadable.
77
+ * A selectable row: drawn with the focus background and mark (`focusLine`) when selected or under the mouse, clickable
78
+ * across the pane on every line, `below` follows unselectable. Only the selected row holds the cursor.
78
79
  */
79
- const highlight = (line: string, width: number): string => {
80
- const bars = line.match(BAR_SEGMENT) ?? []
81
- const texts = line.split(BAR_SEGMENT).map(stripAnsi)
82
- const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
83
- return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
84
- }
85
-
86
- /** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
87
80
  export const rowFrame = (
88
81
  key: string,
89
82
  rows: string[],
@@ -92,7 +85,7 @@ export const rowFrame = (
92
85
  ): Frame => {
93
86
  const selected = state.selected === key
94
87
  return {
95
- lines: [...(selected ? rows.map((row) => highlight(row, width)) : rows), ...below],
88
+ lines: [...(isFocused(state, key) ? rows.map((row, index) => focusLine(row, width, index === 0)) : rows), ...below],
96
89
  cursorLine: selected ? 0 : -1,
97
90
  targets: rows.map((_, line) => ({ line, from: 0, to: width, key, wholeLine: true as const })),
98
91
  }
package/src/plans/git.ts CHANGED
@@ -32,6 +32,41 @@ export const showAtRef = (root: string, ref: string, path: string): string | nul
32
32
  export const commitOf = (root: string, ref: string): string | null =>
33
33
  git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
34
34
 
35
+ const lastChangeAt = (root: string, commit: string, path: string): number | null => {
36
+ const seconds = Number(git(root, ['log', '-1', '--format=%ct', commit, '--', path]))
37
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
38
+ }
39
+
40
+ /** The last-change times read per `root\0commit`, by path, the commit asked last at the end; a change at a commit never moves. */
41
+ const cachedChanges = new Map<string, Map<string, number | null>>()
42
+
43
+ const changesOf = (root: string, commit: string): Map<string, number | null> => {
44
+ const key = `${root}\0${commit}`
45
+ const changes = cachedChanges.get(key) ?? new Map<string, number | null>()
46
+ cachedChanges.delete(key)
47
+ cachedChanges.set(key, changes)
48
+ const oldest = cachedChanges.keys().next().value
49
+ if (cachedChanges.size > CACHED_COMMITS && oldest !== undefined) cachedChanges.delete(oldest)
50
+ return changes
51
+ }
52
+
53
+ /**
54
+ * A reader of the commit time, in epoch milliseconds, of the last commit at `ref` that changed a path; null when the
55
+ * ref or the file is unknown there. The commit is resolved once per reader and each answer is kept per commit, so a
56
+ * refresh while the ref stands costs one `git rev-parse`, and a moved ref reads afresh.
57
+ */
58
+ export const lastChangeReaderOf = (root: string, ref: string): ((path: string) => number | null) => {
59
+ const commit = commitOf(root, ref)
60
+ if (commit === null) return () => null
61
+ const changes = changesOf(root, commit)
62
+ return (path) => {
63
+ if (changes.has(path)) return changes.get(path) ?? null
64
+ const at = lastChangeAt(root, commit, path)
65
+ changes.set(path, at)
66
+ return at
67
+ }
68
+ }
69
+
35
70
  /** Reads files as they are at the base ref. */
36
71
  export type BaseReader = (path: string) => string | null
37
72
 
@@ -0,0 +1,27 @@
1
+ import { DAY_MS } from '../plan-format/clock'
2
+ import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
+ import { readAppointmentTable } from '../watch/appointments'
4
+
5
+ const GROOMING_INTERVAL_DAYS = 7
6
+ const STALE_FROM_DAYS = 8
7
+
8
+ /**
9
+ * Whole days since the board was last groomed, or null when the board has no open grooming appointment. The
10
+ * coordinator moves that appointment a week on after each grooming, so the last one was a week before it. A time
11
+ * before that (a board never groomed on the clock) counts as 0.
12
+ */
13
+ export const groomedDaysAgo = (board: string, language: PlanLanguage, now: Date): number | null => {
14
+ const word = APPOINTMENT_VOCABULARY[language].grooming
15
+ const next = readAppointmentTable(board, language, now)
16
+ .appointments.filter(({ open, action }) => open && action.toLowerCase().startsWith(word))
17
+ .sort((left, right) => left.when.getTime() - right.when.getTime())[0]
18
+ if (!next) return null
19
+ const groomedAt = next.when.getTime() - GROOMING_INTERVAL_DAYS * DAY_MS
20
+ return Math.max(Math.floor((now.getTime() - groomedAt) / DAY_MS), 0)
21
+ }
22
+
23
+ /** The header text; `stale` from eight days on, when the herd paints it amber. */
24
+ export const groomedText = (days: number): { text: string; stale: boolean } => ({
25
+ text: days === 0 ? 'groomed today' : `groomed ${days}d ago`,
26
+ stale: days >= STALE_FROM_DAYS,
27
+ })
@@ -177,8 +177,11 @@ export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext
177
177
  const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
178
178
  fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
179
179
 
180
- const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number): Frame => ({
181
- lines: [fit(paint.dim(`${chevronOf(false)} ${TEXTS.tickedSummary(count)}`), width)],
180
+ /** The clickable line of a phase's ticked tasks: `▸ <n> ticked · …lists them`, or `▾ …folds them` above the listed ones. */
181
+ const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number, listed: boolean): Frame => ({
182
+ lines: [
183
+ fit(paint.dim(`${chevronOf(listed)} ${listed ? TEXTS.tickedFold(count) : TEXTS.tickedSummary(count)}`), width),
184
+ ],
182
185
  cursorLine: -1,
183
186
  targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey), wholeLine: true }],
184
187
  })
@@ -203,7 +206,7 @@ export const phaseChildren = (
203
206
  const framesWhere = (done: boolean): Frame[] =>
204
207
  phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
205
208
  const tickedCount = phase.items.filter((item) => item.done).length
206
- const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
209
+ const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width, state.showDone)] : []
207
210
  const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
208
- return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) : summary)]
211
+ return [...framesWhere(false), ...deferred, ...summary, ...(state.showDone ? framesWhere(true) : [])]
209
212
  }