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.
- package/CHANGELOG.md +26 -0
- package/README.md +24 -16
- package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +39 -4
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +4 -2
- package/docs/follow-dark.svg +48 -0
- package/docs/follow.svg +48 -0
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/run.ts +8 -3
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +11 -3
- package/package.json +2 -2
- package/recipes/sources.md +4 -0
- package/schemas/jackherd.schema.json +80 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -1
- package/src/config/tones.ts +27 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/command.ts +8 -2
- package/src/lane-prompt/input.ts +44 -3
- package/src/lane-prompt/options.ts +2 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/dependencies.ts +2 -2
- package/src/plan-format/waiting.ts +15 -2
- package/src/plans/appointment-rows.ts +34 -10
- package/src/plans/ask-coordinator.ts +46 -12
- package/src/plans/ask-target.ts +13 -2
- package/src/plans/board-close.ts +85 -0
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +35 -0
- package/src/plans/groomed.ts +27 -0
- package/src/plans/layout-phase.ts +7 -4
- package/src/plans/layout.ts +258 -48
- package/src/plans/link-chips.ts +22 -4
- package/src/plans/live.ts +73 -5
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +14 -1
- package/src/plans/settings.ts +10 -0
- package/src/plans/snapshot.ts +79 -21
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/texts.ts +28 -3
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +8 -2
- package/src/plans/waiting-click.ts +129 -24
- package/src/terminal/text.ts +8 -0
- package/src/terminal/types.ts +4 -0
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/plan-badges-read.ts +9 -2
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- 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
|
|
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
|
-
: [
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/plans/ask-target.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
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))
|
package/src/plans/frame.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
77
|
-
*
|
|
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: [...(
|
|
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
|
-
|
|
181
|
-
|
|
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) :
|
|
211
|
+
return [...framesWhere(false), ...deferred, ...summary, ...(state.showDone ? framesWhere(true) : [])]
|
|
209
212
|
}
|