k0-board 0.3.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.
- package/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +426 -1
- package/README.md +1021 -119
- package/bin/k0-board.js +81 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +37 -6
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +3 -1
- package/platform/linux/index.js +14 -0
- package/platform/linux/shell.js +10 -1
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +6 -2
- package/platform/win32/shell.js +4 -1
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +156 -26
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1286 -189
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +404 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +533 -75
- package/web/fuzzy.js +73 -21
- package/web/index.html +109 -12
- package/web/json.js +145 -0
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- package/web/whatsnew.js +339 -0
package/web/list.js
ADDED
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
import { render, esc } from '/md.js'
|
|
2
|
+
|
|
3
|
+
// ── The list ─────────────────────────────────────────────────────────────────
|
|
4
|
+
// The dashboard's other shape. The kanban is for working — one note at a time, the one in front
|
|
5
|
+
// of you; this is for planning — everything at once, in the order you put it in, three levels
|
|
6
|
+
// deep and as many repositories as you have.
|
|
7
|
+
//
|
|
8
|
+
// Three things decide how it is built.
|
|
9
|
+
//
|
|
10
|
+
// It shows the same stories the board is showing. The state pills, the repository and the epic
|
|
11
|
+
// lane are applied before anything gets here, so switching between the two views never changes
|
|
12
|
+
// what is in scope — only its shape. That is why `drawList` is handed a list of stories and never
|
|
13
|
+
// goes and asks for one.
|
|
14
|
+
//
|
|
15
|
+
// Nothing is worked out here that the server already knows. Whether a story is blocked, how far
|
|
16
|
+
// an epic has got, and above all WHAT TO DO NEXT — all of it arrives decided. The button
|
|
17
|
+
// at the end of a row draws the answer `server/backlog.js` gave; it does not have an opinion.
|
|
18
|
+
//
|
|
19
|
+
// And every call it makes says so when it is refused. A button that goes quietly dead is worse
|
|
20
|
+
// than no button, because the story it did nothing to still looks like it moved.
|
|
21
|
+
|
|
22
|
+
// What board.js lends this view: the fetch that throws readable errors, the toast, the redraw, the
|
|
23
|
+
// epic hue, the flag glyph, and the two things only the board knows how to do — do what the next
|
|
24
|
+
// step says, and close a story. Handed in rather than imported, so this file has no way to reach
|
|
25
|
+
// back into the board and the two cannot quietly grow into one.
|
|
26
|
+
let hooks = null
|
|
27
|
+
|
|
28
|
+
// `stories` is what is on screen, after the pills, the repository and the epic lane have had
|
|
29
|
+
// their say. `all` is every story the board knows, and it is here for one reason: a drag rewrites
|
|
30
|
+
// the order of a whole group, and a group half of which is hidden behind a filter would come back
|
|
31
|
+
// renumbered with the hidden half at the top.
|
|
32
|
+
let last = { stories: [], all: [], epics: [] }
|
|
33
|
+
|
|
34
|
+
// The epic groups that have been closed, by `path|key`. Closed rather than open: a group you have
|
|
35
|
+
// never touched should show what is in it, and remembering the exceptions is what makes that true
|
|
36
|
+
// for an epic that did not exist the last time this list was drawn.
|
|
37
|
+
const folded = new Set(JSON.parse(localStorage.getItem('k0-list-folded') || '[]'))
|
|
38
|
+
const saveFolded = () => localStorage.setItem('k0-list-folded', JSON.stringify([...folded]))
|
|
39
|
+
|
|
40
|
+
// What is open in the panel beside the rows: a story, or an epic, never both. An epic's discussion
|
|
41
|
+
// is the same kind of reading as a story's, and two panels stacked would be two places to look for
|
|
42
|
+
// one thing.
|
|
43
|
+
let openId = 0
|
|
44
|
+
let openEpicId = 0
|
|
45
|
+
let view = null // the whole of whatever is open, as `/api/backlog/story|epic/:id` gives it
|
|
46
|
+
let drawn = '' // the JSON of what is on screen, so a poll that changed nothing redraws nothing
|
|
47
|
+
let ticker = null
|
|
48
|
+
let ticks = 0
|
|
49
|
+
let beat = ''
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the board's own answer does not carry: how many decisions are standing, how much of the
|
|
53
|
+
* checklist has passed, whether the last counter-check found something broken.
|
|
54
|
+
*
|
|
55
|
+
* `/api/board` is asked once a second by every open tab and it draws post-its, which have room for
|
|
56
|
+
* none of those; counting them there would be three more queries per story per second for numbers
|
|
57
|
+
* nothing on the board can show. So they are read from `/api/backlog?repo=…`, which counts them
|
|
58
|
+
* already, at the pace the old dense page read them at — and only while this view is on screen.
|
|
59
|
+
*
|
|
60
|
+
* They can be a few seconds behind the rest of the row and that is the trade: a number that lags is
|
|
61
|
+
* worth more than a board that crawls. The story you have actually opened is not affected — its
|
|
62
|
+
* panel re-reads the whole story and puts its own row straight.
|
|
63
|
+
*/
|
|
64
|
+
const counts = new Map()
|
|
65
|
+
let countedAt = 0
|
|
66
|
+
let counting = false
|
|
67
|
+
|
|
68
|
+
const COUNT_EVERY = 30000
|
|
69
|
+
|
|
70
|
+
// ── Small change ─────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
|
|
73
|
+
|
|
74
|
+
/** The shape every time here takes. No seconds: nothing on this page is worth reading that finely. */
|
|
75
|
+
const CLOCK = { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }
|
|
76
|
+
|
|
77
|
+
const when = (t) => (t ? new Date(Number(t)).toLocaleString(undefined, CLOCK) : '')
|
|
78
|
+
|
|
79
|
+
const day = (t) => (t ? new Date(Number(t)).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : '')
|
|
80
|
+
|
|
81
|
+
/** A state as a pill. The colours are the board's own, from base.css: nothing new was invented. */
|
|
82
|
+
const pill = (state) => `<span class="pill" data-state="${esc(state)}">${esc(state)}</span>`
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How deep in the tree a story sits, which is how far its title is indented — and, at nought, what
|
|
86
|
+
* makes it a story you can drag rather than a task belonging to one.
|
|
87
|
+
*
|
|
88
|
+
* Walked over every story the board knows and not over the rows on screen: a task whose parent has
|
|
89
|
+
* been filtered away is still a task, and drawing it flush with the stories would say it was not.
|
|
90
|
+
* `seen` is the guard against a pair of rows each naming the other as parent — a shape `.k0/` is
|
|
91
|
+
* hand-edited into now and then, and one that would otherwise spin here forever.
|
|
92
|
+
*/
|
|
93
|
+
function depth(s) {
|
|
94
|
+
const seen = new Set()
|
|
95
|
+
let at = s?.parent_story_id ?? null
|
|
96
|
+
let deep = 0
|
|
97
|
+
while (at && !seen.has(at)) {
|
|
98
|
+
seen.add(at)
|
|
99
|
+
deep++
|
|
100
|
+
at = parents.get(at) ?? null
|
|
101
|
+
}
|
|
102
|
+
return deep
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every story's parent, for `depth`, rebuilt whenever the board hands a new set of rows over. */
|
|
106
|
+
let parents = new Map()
|
|
107
|
+
|
|
108
|
+
/** The story's counted facts, from whichever of the two answers arrived last. */
|
|
109
|
+
const countsOf = (s) => counts.get(s.id) ?? s
|
|
110
|
+
|
|
111
|
+
// ── The groups ───────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The rows, in the three levels they are drawn in: repository, epic, story.
|
|
115
|
+
*
|
|
116
|
+
* The order inside a group is the one the rows arrived in, which is the order the server put them
|
|
117
|
+
* in — `sort_hint`, then id — with the tasks falling in directly under the story they belong to.
|
|
118
|
+
* It is also the order a drag rewrites, so what you see and what you are moving are the same list.
|
|
119
|
+
*/
|
|
120
|
+
function grouped(stories, epics) {
|
|
121
|
+
const repos = new Map()
|
|
122
|
+
const of = (path, name) => {
|
|
123
|
+
if (!repos.has(path)) repos.set(path, { path, name, groups: new Map(), loose: [] })
|
|
124
|
+
return repos.get(path)
|
|
125
|
+
}
|
|
126
|
+
// Every repository with an epic gets a heading whether or not a story of that epic is on screen:
|
|
127
|
+
// an epic being argued out in a terminal has no stories yet, and it is exactly the one you are
|
|
128
|
+
// watching. The stories bring in the rest.
|
|
129
|
+
for (const e of epics) of(e.project_path, e.project_name).groups.set(e.key, { epic: e, stories: [] })
|
|
130
|
+
for (const s of stories) {
|
|
131
|
+
const repo = of(s.project_path, s.project_name)
|
|
132
|
+
if (!s.epic_key) {
|
|
133
|
+
repo.loose.push(s)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
// A story that names an epic the list was not handed one for still belongs to it, and the
|
|
137
|
+
// heading is written from what the story itself carries. Dropping it in with the ones that
|
|
138
|
+
// have no epic would say something about it that is not true — and it is exactly the name you
|
|
139
|
+
// came to the list to read.
|
|
140
|
+
if (!repo.groups.has(s.epic_key)) {
|
|
141
|
+
const stub = { key: s.epic_key, title: s.epic_title ?? '', project_path: s.project_path }
|
|
142
|
+
repo.groups.set(s.epic_key, { epic: stub, stories: [] })
|
|
143
|
+
}
|
|
144
|
+
repo.groups.get(s.epic_key).stories.push(s)
|
|
145
|
+
}
|
|
146
|
+
return [...repos.values()]
|
|
147
|
+
.map((r) => ({ ...r, groups: [...r.groups.values()], loose: r.loose }))
|
|
148
|
+
// Only what has something in it. A repository whose every story is filtered away is a heading
|
|
149
|
+
// over nothing, and a dozen of those is the list saying "no" a dozen times.
|
|
150
|
+
.filter((r) => r.loose.length || r.groups.some((g) => g.stories.length || g.epic))
|
|
151
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const groupKey = (path, key) => `${path}|${key ?? ''}`
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How tall the scroller is, and how far down the window it starts — written onto the root as
|
|
158
|
+
* `--port` and `--head` so the stylesheet can have the two numbers it cannot work out for itself.
|
|
159
|
+
*
|
|
160
|
+
* The panel sticks to the top of the view and scrolls on its own, so it has to know the height of
|
|
161
|
+
* what it is sticking inside. Above it are the top bar and, inside an epic, the lane: two elements
|
|
162
|
+
* in another stylesheet, one of which changes height with the width of the window. A number
|
|
163
|
+
* written down here instead would be wrong the first time either of them moved — and wrong in the
|
|
164
|
+
* direction that hides the bottom of the panel below the edge of the screen.
|
|
165
|
+
*/
|
|
166
|
+
const port = document.querySelector('#viewport')
|
|
167
|
+
if (port) {
|
|
168
|
+
const measure = () => {
|
|
169
|
+
const r = port.getBoundingClientRect()
|
|
170
|
+
document.documentElement.style.setProperty('--port', `${Math.round(r.height)}px`)
|
|
171
|
+
document.documentElement.style.setProperty('--head', `${Math.round(r.top)}px`)
|
|
172
|
+
}
|
|
173
|
+
new ResizeObserver(measure).observe(port)
|
|
174
|
+
measure()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── The rows ─────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
const rowsEl = document.createElement('div')
|
|
180
|
+
rowsEl.className = 'rows'
|
|
181
|
+
|
|
182
|
+
const panelEl = document.createElement('section')
|
|
183
|
+
panelEl.id = 'panel'
|
|
184
|
+
panelEl.hidden = true
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The one thing to do with this story next, as a button.
|
|
188
|
+
*
|
|
189
|
+
* The label, the command and the reason are all the server's — see `nextStep` in
|
|
190
|
+
* `server/backlog.js`. One button and not a row of them: the interface says what to do next and
|
|
191
|
+
* starts it, and a story offering ten things to do is a story offering none.
|
|
192
|
+
*
|
|
193
|
+
* `null` is an answer and the page does not argue with it: a story whose terminal is open, one
|
|
194
|
+
* waiting to be looked at, one that is done. Nothing here invents a label the server did not give,
|
|
195
|
+
* because a row and a post-it that disagree about the same story is the whole failure this was
|
|
196
|
+
* moved to one place to avoid.
|
|
197
|
+
*/
|
|
198
|
+
function goHtml(s) {
|
|
199
|
+
const step = s.next_step
|
|
200
|
+
if (!step) return ''
|
|
201
|
+
return `<button type="button" class="go" data-go="${s.id}" title="${esc(step.why ?? '')}">${esc(
|
|
202
|
+
step.label
|
|
203
|
+
)}</button>`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function storyRow(s) {
|
|
207
|
+
const c = countsOf(s)
|
|
208
|
+
const broken = c.violations_open > 0
|
|
209
|
+
const decisions = c.decisions_total ? `${c.decisions_open}/${c.decisions_total}` : ''
|
|
210
|
+
const checks = c.checks_total ? `${c.checks_passed}/${c.checks_total}` : ''
|
|
211
|
+
const title = broken
|
|
212
|
+
? `The last counter-check found ${plural(c.violations_open, 'decision')} broken: ` +
|
|
213
|
+
'it cannot be finished until that is answered.'
|
|
214
|
+
: s.title
|
|
215
|
+
return `<div class="story${s.id === openId ? ' on' : ''}${broken ? ' broken' : ''}" data-id="${s.id}"
|
|
216
|
+
data-repo="${esc(s.project_path)}"${depth(s) ? '' : ' draggable="true"'} title="${esc(title)}">
|
|
217
|
+
<span class="k">${esc(s.key)}</span>
|
|
218
|
+
<span class="title"><span class="in" style="--deep:${depth(s)}">${esc(s.title)}</span></span>
|
|
219
|
+
<span>${pill(s.state)}</span>
|
|
220
|
+
<span class="flag">${s.starred ? hooks.flag : ''}</span>
|
|
221
|
+
<span class="num${broken ? ' bad' : ''}"
|
|
222
|
+
title="Decisions still standing, of all that were taken">${decisions}</span>
|
|
223
|
+
<span class="num" title="Checks passed, of the whole checklist">${checks}</span>
|
|
224
|
+
${goHtml(s)}
|
|
225
|
+
</div>`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* An epic: a row that opens and closes, how far it has got, and which round the discussion is on.
|
|
230
|
+
*
|
|
231
|
+
* That last line is the only thing moving on an epic that has not turned into stories yet, which is
|
|
232
|
+
* most of an epic's life — `/k0-epic` runs its rounds before a single story exists. Without it the
|
|
233
|
+
* card said "0 of 0" for as long as anybody was watching.
|
|
234
|
+
*/
|
|
235
|
+
function epicRow(e, open, n) {
|
|
236
|
+
// An epic the list was handed knows how far it has got and which round it is on. One written
|
|
237
|
+
// from a story that merely names it knows neither, and says so by leaving the bar out rather
|
|
238
|
+
// than drawing an empty one — and it carries no id, so it folds and does not open a panel.
|
|
239
|
+
const { done, total } = e.progress ?? {}
|
|
240
|
+
const pct = total ? Math.round((done / total) * 100) : 0
|
|
241
|
+
const of = e.round?.estimated_total ? ` of about ${e.round.estimated_total}` : ''
|
|
242
|
+
const round = e.round ? `round ${e.round.n}${of}` : ''
|
|
243
|
+
const known = e.progress != null
|
|
244
|
+
return `<button type="button" class="epic" style="--h:${hooks.hue(e.key)}" data-group="${groupKey(
|
|
245
|
+
e.project_path,
|
|
246
|
+
e.key
|
|
247
|
+
)}"${e.id ? ` data-epic-id="${e.id}"` : ''} data-repo="${esc(e.project_path)}" data-key="${esc(e.key)}"
|
|
248
|
+
aria-expanded="${open}" title="${esc(`${e.key} — click to open it, or drop a story on it to move it here`)}">
|
|
249
|
+
<span class="caret">${open ? '⌄' : '›'}</span>
|
|
250
|
+
<span class="key">${esc(e.key)}</span>
|
|
251
|
+
<span class="name">${esc(e.title)}</span>
|
|
252
|
+
${
|
|
253
|
+
known
|
|
254
|
+
? `<span class="prog" role="img" aria-label="${done} of ${total} done"><i style="width:${pct}%"></i></span>
|
|
255
|
+
<span class="count">${done}/${total}</span>`
|
|
256
|
+
: `<span class="count push">${n}</span>`
|
|
257
|
+
}
|
|
258
|
+
${round ? `<span class="round">${esc(round)}</span>` : ''}
|
|
259
|
+
</button>`
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The group at the end: the stories of this repository that belong to no epic. */
|
|
263
|
+
function looseRow(path, open, n) {
|
|
264
|
+
return `<button type="button" class="epic loose" data-group="${groupKey(path, '')}" data-repo="${esc(path)}"
|
|
265
|
+
data-key="" aria-expanded="${open}" title="Stories with no epic — drop one here to take it out of its epic">
|
|
266
|
+
<span class="caret">${open ? '⌄' : '›'}</span>
|
|
267
|
+
<span class="name">No epic</span>
|
|
268
|
+
<span class="count">${n}</span>
|
|
269
|
+
</button>`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function paintRows() {
|
|
273
|
+
// Not while something is in the air. This runs off a poll, and rebuilding the rows under a row
|
|
274
|
+
// being dragged takes the target out from under the pointer half way through the gesture.
|
|
275
|
+
if (dragging) return
|
|
276
|
+
const tree = grouped(last.stories, last.epics)
|
|
277
|
+
if (!tree.length) {
|
|
278
|
+
rowsEl.innerHTML = '<p class="none">Nothing here — or the filters have put it all away.</p>'
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
const open = (path, key) => !folded.has(groupKey(path, key))
|
|
282
|
+
rowsEl.innerHTML = tree
|
|
283
|
+
.map((r) => {
|
|
284
|
+
const all = [...r.groups.flatMap((g) => g.stories), ...r.loose]
|
|
285
|
+
const live = all.filter((s) => s.state !== 'Done').length
|
|
286
|
+
const body = [
|
|
287
|
+
...r.groups.map((g) => {
|
|
288
|
+
const on = open(r.path, g.epic.key)
|
|
289
|
+
return epicRow(g.epic, on, g.stories.length) + (on ? g.stories.map(storyRow).join('') : '')
|
|
290
|
+
}),
|
|
291
|
+
r.loose.length
|
|
292
|
+
? looseRow(r.path, open(r.path, ''), r.loose.length) +
|
|
293
|
+
(open(r.path, '') ? r.loose.map(storyRow).join('') : '')
|
|
294
|
+
: '',
|
|
295
|
+
].join('')
|
|
296
|
+
return `<section><div class="repo"><h2>${esc(r.name)}</h2><small>${live} open</small></div>${body}</section>`
|
|
297
|
+
})
|
|
298
|
+
.join('')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── One story, whole ─────────────────────────────────────────────────────────
|
|
302
|
+
// Everything from here to the end of the panel is the old dense page's drawing, lifted rather than
|
|
303
|
+
// written again. Getting a broken decision to be impossible to miss took a long time, and doing it
|
|
304
|
+
// a second time from memory would have lost exactly the details that do it.
|
|
305
|
+
|
|
306
|
+
/** What the live poll says, and what the first paint says before there has been a poll. */
|
|
307
|
+
const beatOf = (v) => ({
|
|
308
|
+
state: v.story.state,
|
|
309
|
+
round: v.rounds.length ? { n: v.rounds.at(-1).n, estimated_total: v.rounds.at(-1).estimated_total } : null,
|
|
310
|
+
session: v.story.session,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
/** The same for an epic, which has rounds and a state and never a session. */
|
|
314
|
+
const beatOfEpic = (v) => ({
|
|
315
|
+
state: v.epic.state,
|
|
316
|
+
round: v.rounds.length ? { n: v.rounds.at(-1).n, estimated_total: v.rounds.at(-1).estimated_total } : null,
|
|
317
|
+
session: null,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The line that moves while somebody is being asked questions in a terminal. "Round 3 of about 8"
|
|
322
|
+
* is the estimate as it stood at that round, and it is meant to move: the discussion recounts what
|
|
323
|
+
* is still open every time round, so the total goes up as often as down.
|
|
324
|
+
*/
|
|
325
|
+
function liveHtml(l) {
|
|
326
|
+
const bits = []
|
|
327
|
+
if (l.round) {
|
|
328
|
+
bits.push(`<b>Round ${l.round.n}${l.round.estimated_total ? ` of about ${l.round.estimated_total}` : ''}</b>`)
|
|
329
|
+
}
|
|
330
|
+
bits.push(pill(l.state))
|
|
331
|
+
if (l.session?.status) {
|
|
332
|
+
bits.push(
|
|
333
|
+
`<span class="sess" data-status="${esc(l.session.status)}">${esc(l.session.status)}` +
|
|
334
|
+
`${l.session.alive ? '' : ' · ended'}</span>`
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
return bits.join('')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The counter-check, and it sits directly under the title on every story whether or not anything
|
|
342
|
+
* is wrong. It is why any of this exists: a decision the last run found broken has to be the first
|
|
343
|
+
* thing you see, not something you find by scrolling.
|
|
344
|
+
*/
|
|
345
|
+
function counterHtml(v) {
|
|
346
|
+
const latest = v.runs[0] ?? null
|
|
347
|
+
const history = v.runs.length
|
|
348
|
+
? `<table class="runs"><tr><th>Run</th><th>When</th><th>Kept</th><th>Broken</th><th>N/A</th></tr>${v.runs
|
|
349
|
+
.map(
|
|
350
|
+
(r) =>
|
|
351
|
+
`<tr><td>${r.run}</td><td>${esc(when(r.at))}</td><td>${r.kept}</td>` +
|
|
352
|
+
`<td class="${r.violated ? 'bad' : ''}">${r.violated}</td><td>${r.na}</td></tr>`
|
|
353
|
+
)
|
|
354
|
+
.join('')}</table>`
|
|
355
|
+
: ''
|
|
356
|
+
|
|
357
|
+
if (!v.violations.length) {
|
|
358
|
+
return `<section class="counter clean">
|
|
359
|
+
<h3>Counter-check</h3>
|
|
360
|
+
<p>${
|
|
361
|
+
latest
|
|
362
|
+
? esc(`Run ${latest.run} held every decision: ${latest.kept} kept, ${latest.na} not applicable, none broken.`)
|
|
363
|
+
: 'Nobody has counter-checked this story yet.'
|
|
364
|
+
}</p>
|
|
365
|
+
${history}
|
|
366
|
+
</section>`
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const rows = v.violations
|
|
370
|
+
.map(
|
|
371
|
+
(x) =>
|
|
372
|
+
`<div class="v">
|
|
373
|
+
<div class="what"><b>${esc(x.label)}</b><span>${esc(x.text)}</span></div>
|
|
374
|
+
<div class="verdict">violated</div>
|
|
375
|
+
<div class="ev">${x.evidence ? esc(x.evidence) : '<i>no evidence was written</i>'}</div>
|
|
376
|
+
</div>`
|
|
377
|
+
)
|
|
378
|
+
.join('')
|
|
379
|
+
|
|
380
|
+
return `<section class="counter broken">
|
|
381
|
+
<h3>${esc(`${plural(v.violations.length, 'decision')} broken`)}</h3>
|
|
382
|
+
<div class="verdicts">
|
|
383
|
+
<div class="vhead"><span>What was decided</span><span>The verdict</span><span>The evidence</span></div>
|
|
384
|
+
${rows}
|
|
385
|
+
</div>
|
|
386
|
+
<p class="rule">Put it right and run the counter-check again, or supersede the decision if it is
|
|
387
|
+
the decision that was wrong. Until then this story cannot be marked Done.</p>
|
|
388
|
+
${history}
|
|
389
|
+
</section>`
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function roundHtml(r) {
|
|
393
|
+
return `<article class="round">
|
|
394
|
+
<h4>Round ${r.n}${r.estimated_total ? ` of about ${r.estimated_total}` : ''}<time>${esc(when(r.at))}</time></h4>
|
|
395
|
+
<div class="q prose">${render(r.question || '')}</div>
|
|
396
|
+
${
|
|
397
|
+
r.answer
|
|
398
|
+
? `<div class="a prose">${render(r.answer)}</div>`
|
|
399
|
+
: '<p class="a waiting">Asked. Nothing answered yet.</p>'
|
|
400
|
+
}
|
|
401
|
+
</article>`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function discussionHtml(v) {
|
|
405
|
+
if (!v.rounds.length && !v.epic_rounds.length) return ''
|
|
406
|
+
const epicPart = v.epic_rounds.length
|
|
407
|
+
? `<details class="inherited" data-keep="inherited">
|
|
408
|
+
<summary>${esc(
|
|
409
|
+
`${plural(v.epic_rounds.length, 'round')} on ${v.epic?.key ?? 'the epic'}, before this story existed`
|
|
410
|
+
)}</summary>
|
|
411
|
+
${v.epic_rounds.map(roundHtml).join('')}
|
|
412
|
+
</details>`
|
|
413
|
+
: ''
|
|
414
|
+
return `<section class="block">
|
|
415
|
+
<h3>Discussion</h3>
|
|
416
|
+
${epicPart}
|
|
417
|
+
${v.rounds.map(roundHtml).join('') || '<p class="none">This story has not been discussed.</p>'}
|
|
418
|
+
</section>`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The list itself, so a story's decisions and an epic's own are drawn by one piece of code. */
|
|
422
|
+
function decisionList(decisions, { broken = new Set(), epicKey = null } = {}) {
|
|
423
|
+
const items = decisions
|
|
424
|
+
.map((d) => {
|
|
425
|
+
const bits = []
|
|
426
|
+
if (d.owner === 'epic' && epicKey) bits.push(`inherited from ${esc(epicKey)}`)
|
|
427
|
+
if (d.source && d.source !== 'discussion') bits.push(esc(d.source))
|
|
428
|
+
if (d.superseded_by) bits.push('superseded')
|
|
429
|
+
return `<li class="${d.superseded_by ? 'gone' : ''}${broken.has(d.id) ? ' bad' : ''}${
|
|
430
|
+
d.owner === 'epic' && epicKey ? ' up' : ''
|
|
431
|
+
}"><b>${esc(d.label)}</b><span>${esc(d.text)}</span>${bits.length ? `<em>${bits.join(' · ')}</em>` : ''}</li>`
|
|
432
|
+
})
|
|
433
|
+
.join('')
|
|
434
|
+
return `<ul class="decisions">${items}</ul>`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function decisionsHtml(v) {
|
|
438
|
+
if (!v.decisions.length) return ''
|
|
439
|
+
const broken = new Set(v.violations.map((x) => x.decision_id))
|
|
440
|
+
return `<section class="block">
|
|
441
|
+
<h3>Decisions</h3>
|
|
442
|
+
${decisionList(v.decisions, { broken, epicKey: v.epic?.key ?? 'the epic' })}
|
|
443
|
+
</section>`
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function checksHtml(v) {
|
|
447
|
+
if (!v.checks.length) return ''
|
|
448
|
+
const mark = { pass: '✓', fail: '✗', skip: '–', todo: '' }
|
|
449
|
+
const items = v.checks
|
|
450
|
+
.map(
|
|
451
|
+
(c) =>
|
|
452
|
+
`<li class="${esc(c.state)}">
|
|
453
|
+
<button type="button" class="tick" data-check="${c.id}" data-state="${esc(c.state)}"
|
|
454
|
+
title="${c.state === 'pass' ? 'Passed — click to put it back' : 'Click when you have seen it work'}"
|
|
455
|
+
aria-pressed="${c.state === 'pass'}">${mark[c.state] ?? ''}</button>
|
|
456
|
+
<span>${esc(c.text)}</span>
|
|
457
|
+
${c.evidence ? `<em>${esc(c.evidence)}</em>` : ''}
|
|
458
|
+
<i class="by">${esc(c.by)}</i>
|
|
459
|
+
</li>`
|
|
460
|
+
)
|
|
461
|
+
.join('')
|
|
462
|
+
const done = v.checks.filter((c) => c.state === 'pass').length
|
|
463
|
+
return `<section class="block">
|
|
464
|
+
<h3>Checklist<small>${done}/${v.checks.length}</small></h3>
|
|
465
|
+
<ul class="checks">${items}</ul>
|
|
466
|
+
</section>`
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function logHtml(v) {
|
|
470
|
+
if (!v.log.length && !v.sessions.length) return ''
|
|
471
|
+
const entries = v.log
|
|
472
|
+
.map(
|
|
473
|
+
(l) =>
|
|
474
|
+
`<li><time>${esc(when(l.at))}</time>${
|
|
475
|
+
l.session_id ? `<i>${esc(String(l.session_id).slice(0, 8))}</i>` : ''
|
|
476
|
+
}<div class="prose">${render(l.text || '')}</div></li>`
|
|
477
|
+
)
|
|
478
|
+
.join('')
|
|
479
|
+
const sessions = v.sessions.length
|
|
480
|
+
? `<p class="sessions">${esc(
|
|
481
|
+
v.sessions
|
|
482
|
+
.map(
|
|
483
|
+
(s) =>
|
|
484
|
+
`${String(s.session_id ?? '—').slice(0, 8)} · ${day(s.started_at)}${
|
|
485
|
+
s.ended_at ? ` → ${day(s.ended_at)}` : ' · still open'
|
|
486
|
+
}`
|
|
487
|
+
)
|
|
488
|
+
.join(' · ')
|
|
489
|
+
)}</p>`
|
|
490
|
+
: ''
|
|
491
|
+
return `<section class="block">
|
|
492
|
+
<h3>Log</h3>
|
|
493
|
+
${entries ? `<ul class="log">${entries}</ul>` : '<p class="none">Nothing has been written down yet.</p>'}
|
|
494
|
+
${sessions}
|
|
495
|
+
</section>`
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* What this story waits for, and what is waiting for it.
|
|
500
|
+
*
|
|
501
|
+
* The second half is the one nothing else in k0 shows. A post-it can say what a story is waiting
|
|
502
|
+
* for; the reason to finish one story before another is usually what is stuck behind it, and until
|
|
503
|
+
* this existed that was only ever visible from the other end.
|
|
504
|
+
*/
|
|
505
|
+
function waitsHtml(v) {
|
|
506
|
+
const keys = (list) =>
|
|
507
|
+
list
|
|
508
|
+
.map(
|
|
509
|
+
(d) =>
|
|
510
|
+
`<button type="button" class="dep${d.state === 'Done' ? '' : ' out'}" data-story="${d.id}" ` +
|
|
511
|
+
`title="${esc(`${d.key} · ${d.title} · ${d.state}`)}">${esc(d.key)}</button>`
|
|
512
|
+
)
|
|
513
|
+
.join('')
|
|
514
|
+
const bits = []
|
|
515
|
+
if (v.story.deps.length) bits.push(`<span>waits on</span>${keys(v.story.deps)}`)
|
|
516
|
+
if (v.blocks?.length) bits.push(`<span>and is waited on by</span>${keys(v.blocks)}`)
|
|
517
|
+
return bits.length ? `<p class="waits">${bits.join('')}</p>` : ''
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const shutHtml = `<button type="button" class="x" data-shut title="Close" aria-label="Close">
|
|
521
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
522
|
+
<path d="M6 6l12 12M18 6L6 18"/>
|
|
523
|
+
</svg>
|
|
524
|
+
</button>`
|
|
525
|
+
|
|
526
|
+
function storyHtml(v) {
|
|
527
|
+
const s = v.story
|
|
528
|
+
const where = [
|
|
529
|
+
s.project_name,
|
|
530
|
+
s.epic_key ? `${s.epic_key} · ${s.epic_title}` : null,
|
|
531
|
+
s.parent_key ? `under ${s.parent_key}` : null,
|
|
532
|
+
]
|
|
533
|
+
.filter(Boolean)
|
|
534
|
+
.join(' · ')
|
|
535
|
+
return `<header class="head">
|
|
536
|
+
<div class="who">
|
|
537
|
+
<b class="key">${esc(s.key)}</b>
|
|
538
|
+
<h2>${esc(s.title)}</h2>
|
|
539
|
+
${s.starred ? `<span class="flag" title="Flagged">${hooks.flag}</span>` : ''}
|
|
540
|
+
</div>
|
|
541
|
+
<p class="where">${esc(where)}</p>
|
|
542
|
+
${waitsHtml(v)}
|
|
543
|
+
<p class="live" id="live">${liveHtml(beatOf(v))}</p>
|
|
544
|
+
${shutHtml}
|
|
545
|
+
</header>
|
|
546
|
+
${counterHtml(v)}
|
|
547
|
+
${s.body ? `<section class="block"><h3>Why</h3><div class="prose">${render(s.body)}</div></section>` : ''}
|
|
548
|
+
${discussionHtml(v)}
|
|
549
|
+
${decisionsHtml(v)}
|
|
550
|
+
${v.plan ? `<section class="block"><h3>Plan</h3><div class="prose">${render(v.plan)}</div></section>` : ''}
|
|
551
|
+
${checksHtml(v)}
|
|
552
|
+
${logHtml(v)}`
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* An epic in the same panel a story opens in: why it exists, the rounds it was argued out in, the
|
|
557
|
+
* decisions those rounds settled, and what it turned into.
|
|
558
|
+
*
|
|
559
|
+
* It is here because `/k0-epic` is the long way into the backlog and all of it happens before a
|
|
560
|
+
* single story exists. Without this the whole of that conversation would be invisible while it was
|
|
561
|
+
* happening and stay invisible afterwards.
|
|
562
|
+
*/
|
|
563
|
+
function epicPanelHtml(v) {
|
|
564
|
+
const e = v.epic
|
|
565
|
+
const pct = e.progress.total ? Math.round((e.progress.done / e.progress.total) * 100) : 0
|
|
566
|
+
const list = v.stories
|
|
567
|
+
.filter((s) => !s.parent_key)
|
|
568
|
+
.map(
|
|
569
|
+
(s) =>
|
|
570
|
+
`<li><button type="button" data-id="${s.id}"><b>${esc(s.key)}</b>` +
|
|
571
|
+
`<span>${esc(s.title)}</span></button>${pill(s.state)}</li>`
|
|
572
|
+
)
|
|
573
|
+
.join('')
|
|
574
|
+
return `<header class="head">
|
|
575
|
+
<div class="who">
|
|
576
|
+
<b class="key">${esc(e.key)}</b>
|
|
577
|
+
<h2>${esc(e.title)}</h2>
|
|
578
|
+
</div>
|
|
579
|
+
<p class="where">${esc(`${e.project_name} · ${e.progress.done} of ${e.progress.total} done`)}</p>
|
|
580
|
+
<p class="live" id="live">${liveHtml(beatOfEpic(v))}</p>
|
|
581
|
+
${shutHtml}
|
|
582
|
+
</header>
|
|
583
|
+
<div class="bar" role="img" aria-label="${e.progress.done} of ${e.progress.total} done">
|
|
584
|
+
<i style="width:${pct}%"></i>
|
|
585
|
+
</div>
|
|
586
|
+
${e.body ? `<section class="block"><h3>Why</h3><div class="prose">${render(e.body)}</div></section>` : ''}
|
|
587
|
+
<section class="block">
|
|
588
|
+
<h3>Discussion</h3>
|
|
589
|
+
${v.rounds.map(roundHtml).join('') || '<p class="none">This epic has not been discussed yet.</p>'}
|
|
590
|
+
</section>
|
|
591
|
+
${
|
|
592
|
+
v.decisions.length
|
|
593
|
+
? `<section class="block"><h3>Decisions</h3>${decisionList(v.decisions)}
|
|
594
|
+
<p class="rule">Every story under this epic is held to these, and counter-checked against
|
|
595
|
+
them one by one. Supersede one here and it changes for all of them at once.</p></section>`
|
|
596
|
+
: ''
|
|
597
|
+
}
|
|
598
|
+
<section class="block">
|
|
599
|
+
<h3>Stories<small>${e.progress.done}/${e.progress.total}</small></h3>
|
|
600
|
+
${list ? `<ul class="mini">${list}</ul>` : '<p class="none">Nothing under it yet.</p>'}
|
|
601
|
+
</section>
|
|
602
|
+
<section class="block">
|
|
603
|
+
<h3>The whole thing at once</h3>
|
|
604
|
+
<p class="rule">A terminal on <code>/k0-ultracode ${esc(e.key)}</code>: one story at a time,
|
|
605
|
+
agents under it, and the tests and the counter-check before each one is called done.</p>
|
|
606
|
+
<button type="button" class="run" data-epic-run="${e.id}">Ultracode</button>
|
|
607
|
+
</section>`
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Which disclosures the reader has opened, by name.
|
|
612
|
+
*
|
|
613
|
+
* The panel is rebuilt from scratch every time the thing it is showing moves — which is the whole
|
|
614
|
+
* point of the once-a-second poll — and a `<details>` drawn fresh is a `<details>` closed. Without
|
|
615
|
+
* this, the inherited rounds somebody opened to read what the epic had settled shut under them on
|
|
616
|
+
* the very round they were waiting for.
|
|
617
|
+
*/
|
|
618
|
+
const opened = new Set()
|
|
619
|
+
|
|
620
|
+
function keepOpen(box) {
|
|
621
|
+
for (const d of box.querySelectorAll('details[data-keep]')) {
|
|
622
|
+
const name = d.dataset.keep
|
|
623
|
+
d.open = opened.has(name)
|
|
624
|
+
d.ontoggle = () => (d.open ? opened.add(name) : opened.delete(name))
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Draws whatever is open, and only when it has actually changed: this runs on a poll. */
|
|
629
|
+
function drawPanel() {
|
|
630
|
+
panelEl.hidden = !openId && !openEpicId
|
|
631
|
+
if (!openId && !openEpicId) {
|
|
632
|
+
panelEl.innerHTML = ''
|
|
633
|
+
drawn = ''
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
if (!view) return // the "Reading…" line is already in there; leave it alone
|
|
637
|
+
const same = JSON.stringify(view)
|
|
638
|
+
if (same === drawn) return
|
|
639
|
+
drawn = same
|
|
640
|
+
panelEl.innerHTML = openEpicId ? epicPanelHtml(view) : storyHtml(view)
|
|
641
|
+
keepOpen(panelEl)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── Following it live ────────────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The cheap round, once a second. It moves the line under the title straight away and re-reads the
|
|
648
|
+
* whole story only when something has moved.
|
|
649
|
+
*
|
|
650
|
+
* The `ticks % 5` is not belt and braces. `…/live` reports the LAST round's number and estimate,
|
|
651
|
+
* and a round is written twice — the question when it is asked, the answer when it comes back —
|
|
652
|
+
* with the same number both times. So an answer landing in a round already announced changes
|
|
653
|
+
* nothing the cheap answer can see, and without this the words would sit there unread until the
|
|
654
|
+
* next question. Five seconds is close enough to watch a conversation by.
|
|
655
|
+
*/
|
|
656
|
+
async function tick() {
|
|
657
|
+
const story = openId
|
|
658
|
+
const epic = openEpicId
|
|
659
|
+
if (!story && !epic) return
|
|
660
|
+
let now
|
|
661
|
+
try {
|
|
662
|
+
now = await hooks.api(story ? `/api/backlog/story/${story}/live` : `/api/backlog/epic/${epic}/live`)
|
|
663
|
+
} catch {
|
|
664
|
+
return // the server is restarting, or it has gone: the next round will find out
|
|
665
|
+
}
|
|
666
|
+
if (story !== openId || epic !== openEpicId || !now?.state) return
|
|
667
|
+
|
|
668
|
+
const line = panelEl.querySelector('#live')
|
|
669
|
+
if (line) line.innerHTML = liveHtml(now)
|
|
670
|
+
if (story) freshen(story, now)
|
|
671
|
+
|
|
672
|
+
const s = JSON.stringify(now)
|
|
673
|
+
ticks += 1
|
|
674
|
+
if (s === beat && ticks % 5) return
|
|
675
|
+
beat = s
|
|
676
|
+
await reread()
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The row, brought up to date from the same cheap answer the line under the title just used.
|
|
681
|
+
*
|
|
682
|
+
* The numbers are only touched where the totals are already known. `…/live` says how many decisions
|
|
683
|
+
* are standing and how many checks have passed and never says of how many, so writing them into a
|
|
684
|
+
* row nothing has counted yet would leave `7/` — half a fraction, which the row draws as nothing at
|
|
685
|
+
* all. That row waits for the listing, which is along in half a minute.
|
|
686
|
+
*/
|
|
687
|
+
function freshen(id, now) {
|
|
688
|
+
const row = last.stories.find((s) => s.id === id)
|
|
689
|
+
const moved = !!row && row.state !== now.state
|
|
690
|
+
if (moved) row.state = now.state
|
|
691
|
+
const before = counts.get(id)
|
|
692
|
+
const counted =
|
|
693
|
+
!!before && (before.decisions_open !== now.decisions || before.checks_passed !== now.checks_passed)
|
|
694
|
+
if (counted) counts.set(id, { ...before, decisions_open: now.decisions, checks_passed: now.checks_passed })
|
|
695
|
+
if (moved || counted) paintRows()
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Reads back whichever of the two is open, and puts the row it came from in step with it. */
|
|
699
|
+
async function reread() {
|
|
700
|
+
const story = openId
|
|
701
|
+
const epic = openEpicId
|
|
702
|
+
try {
|
|
703
|
+
if (story) {
|
|
704
|
+
const whole = await hooks.api(`/api/backlog/story/${story}`)
|
|
705
|
+
if (story !== openId || !whole?.story) return
|
|
706
|
+
view = whole
|
|
707
|
+
// The story view has just recounted everything the row shows. Keeping the two in step matters
|
|
708
|
+
// more than the round trip it would take to ask the listing for it again.
|
|
709
|
+
counts.set(story, whole.story)
|
|
710
|
+
const row = last.stories.find((s) => s.id === story)
|
|
711
|
+
if (row) Object.assign(row, { state: whole.story.state, starred: whole.story.starred })
|
|
712
|
+
} else if (epic) {
|
|
713
|
+
const whole = await hooks.api(`/api/backlog/epic/${epic}`)
|
|
714
|
+
if (epic !== openEpicId || !whole?.epic) return
|
|
715
|
+
view = whole
|
|
716
|
+
} else return
|
|
717
|
+
drawPanel()
|
|
718
|
+
paintRows()
|
|
719
|
+
} catch {
|
|
720
|
+
/* nothing is lost by waiting a second: the next round asks again */
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function follow() {
|
|
725
|
+
clearInterval(ticker)
|
|
726
|
+
ticker = null
|
|
727
|
+
ticks = 0
|
|
728
|
+
beat = ''
|
|
729
|
+
if (openId || openEpicId) ticker = setInterval(tick, 1000)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ── Opening and shutting ─────────────────────────────────────────────────────
|
|
733
|
+
|
|
734
|
+
async function openStory(id) {
|
|
735
|
+
const n = Number(id)
|
|
736
|
+
if (!n) return
|
|
737
|
+
openId = n
|
|
738
|
+
openEpicId = 0
|
|
739
|
+
await opening('Reading the story…')
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function openEpic(id) {
|
|
743
|
+
const n = Number(id)
|
|
744
|
+
if (!n) return
|
|
745
|
+
openEpicId = n
|
|
746
|
+
openId = 0
|
|
747
|
+
await opening('Reading the epic…')
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** The half the two of them share: empty the panel, read the thing, start following it. */
|
|
751
|
+
async function opening(waiting) {
|
|
752
|
+
view = null
|
|
753
|
+
drawn = ''
|
|
754
|
+
panelEl.hidden = false
|
|
755
|
+
panelEl.innerHTML = `<p class="none">${waiting}</p>`
|
|
756
|
+
panelEl.scrollTop = 0
|
|
757
|
+
rowsEl.parentElement?.classList.add('open')
|
|
758
|
+
paintRows()
|
|
759
|
+
await reread()
|
|
760
|
+
follow()
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function shut() {
|
|
764
|
+
openId = 0
|
|
765
|
+
openEpicId = 0
|
|
766
|
+
view = null
|
|
767
|
+
drawn = ''
|
|
768
|
+
clearInterval(ticker)
|
|
769
|
+
ticker = null
|
|
770
|
+
rowsEl.parentElement?.classList.remove('open')
|
|
771
|
+
drawPanel()
|
|
772
|
+
paintRows()
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ── The counted facts ────────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Fills `counts` from the backlog's own listing, for every repository on screen, and no more often
|
|
779
|
+
* than every half minute. It runs beside the board's round rather than inside it: the board is
|
|
780
|
+
* asked once a second and must not wait for this.
|
|
781
|
+
*/
|
|
782
|
+
async function ensureCounts() {
|
|
783
|
+
if (counting || Date.now() - countedAt < COUNT_EVERY) return
|
|
784
|
+
const paths = [...new Set(last.stories.map((s) => s.project_path))]
|
|
785
|
+
if (!paths.length) return
|
|
786
|
+
counting = true
|
|
787
|
+
try {
|
|
788
|
+
const answers = await Promise.all(
|
|
789
|
+
paths.map((p) => hooks.api(`/api/backlog?repo=${encodeURIComponent(p)}`).catch(() => null))
|
|
790
|
+
)
|
|
791
|
+
for (const a of answers) for (const s of a?.stories ?? []) counts.set(s.id, s)
|
|
792
|
+
countedAt = Date.now()
|
|
793
|
+
paintRows()
|
|
794
|
+
} finally {
|
|
795
|
+
counting = false
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ── Dragging ─────────────────────────────────────────────────────────────────
|
|
800
|
+
// The two things the interface could not do: put a story somewhere else in the order, and move it
|
|
801
|
+
// into another epic. Both are one gesture, and neither changes state — the states are the pills at
|
|
802
|
+
// the top and the road a story walks, not somewhere a piece of paper is dropped.
|
|
803
|
+
|
|
804
|
+
let dragging = null // the row being carried, as `{ id, repo }`
|
|
805
|
+
|
|
806
|
+
const clearMarks = () => {
|
|
807
|
+
for (const el of rowsEl.querySelectorAll('.over')) el.classList.remove('over')
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The top-level stories of one group, in the order they are drawn in.
|
|
812
|
+
*
|
|
813
|
+
* Tasks are not in it and are never dragged: a task's place is under the story it was split out
|
|
814
|
+
* of. Moving one is moving its parent.
|
|
815
|
+
*
|
|
816
|
+
* The order is the one the rows arrived in, which is the server's — `sort_hint`, then id — so the
|
|
817
|
+
* list being renumbered is the same list that was on the screen.
|
|
818
|
+
*/
|
|
819
|
+
function groupOf(repo, epicKey) {
|
|
820
|
+
return last.all.filter((s) => s.project_path === repo && (s.epic_key ?? '') === epicKey && !depth(s))
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Where the story has just been dropped, written down.
|
|
825
|
+
*
|
|
826
|
+
* The whole destination group is renumbered rather than a gap being found between two neighbours:
|
|
827
|
+
* every `sort_hint` starts at nought, so on a fresh backlog there is no gap to find. Only the rows
|
|
828
|
+
* whose number really changes are sent, so the second drag in a group costs one request and not
|
|
829
|
+
* eleven.
|
|
830
|
+
*
|
|
831
|
+
* The refusal, whatever it is — a repository gone read-only, a story deleted in another tab — is
|
|
832
|
+
* said out loud. A drag that silently does nothing leaves the row where it was and looks exactly
|
|
833
|
+
* like a drag that was not allowed.
|
|
834
|
+
*/
|
|
835
|
+
async function drop(story, epicKey, at) {
|
|
836
|
+
const moving = story.epic_key !== (epicKey || null)
|
|
837
|
+
const list = groupOf(story.project_path, epicKey).filter((s) => s.id !== story.id)
|
|
838
|
+
list.splice(Math.max(0, Math.min(at, list.length)), 0, story)
|
|
839
|
+
const writes = []
|
|
840
|
+
list.forEach((s, i) => {
|
|
841
|
+
const hint = (i + 1) * 10
|
|
842
|
+
const change = {}
|
|
843
|
+
if (s.sort_hint !== hint) change.sort_hint = hint
|
|
844
|
+
if (s.id === story.id && moving) change.epic_key = epicKey || null
|
|
845
|
+
if (Object.keys(change).length) writes.push([s.id, change])
|
|
846
|
+
})
|
|
847
|
+
if (!writes.length) return
|
|
848
|
+
try {
|
|
849
|
+
await Promise.all(
|
|
850
|
+
writes.map(([id, change]) =>
|
|
851
|
+
hooks.api(`/api/backlog/story/${id}`, { method: 'PATCH', body: JSON.stringify(change) })
|
|
852
|
+
)
|
|
853
|
+
)
|
|
854
|
+
} catch (e) {
|
|
855
|
+
hooks.toast(`Couldn't move it: ${e.message}`, 8000)
|
|
856
|
+
}
|
|
857
|
+
hooks.redraw()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ── What you can press ───────────────────────────────────────────────────────
|
|
861
|
+
|
|
862
|
+
rowsEl.onclick = (e) => {
|
|
863
|
+
const go = e.target.closest('[data-go]')
|
|
864
|
+
if (go) {
|
|
865
|
+
e.stopPropagation()
|
|
866
|
+
const story = last.stories.find((s) => s.id === Number(go.dataset.go))
|
|
867
|
+
return story && hooks.next(story)
|
|
868
|
+
}
|
|
869
|
+
const epic = e.target.closest('.epic')
|
|
870
|
+
if (epic) {
|
|
871
|
+
// One click and it does the two things that go together: the group opens, and the epic itself
|
|
872
|
+
// opens beside it — its rounds, its decisions and what it turned into. The loose group has no
|
|
873
|
+
// epic to read, so there it is only the fold.
|
|
874
|
+
const key = epic.dataset.group
|
|
875
|
+
folded.has(key) ? folded.delete(key) : folded.add(key)
|
|
876
|
+
saveFolded()
|
|
877
|
+
paintRows()
|
|
878
|
+
if (epic.dataset.epicId && !folded.has(key)) openEpic(epic.dataset.epicId)
|
|
879
|
+
return
|
|
880
|
+
}
|
|
881
|
+
const row = e.target.closest('.story[data-id]')
|
|
882
|
+
if (!row) return
|
|
883
|
+
// Clicking the row that is already open shuts it. The same gesture in both directions, which is
|
|
884
|
+
// what makes a list you can run down with the mouse rather than one you close between stories.
|
|
885
|
+
if (Number(row.dataset.id) === openId) shut()
|
|
886
|
+
else openStory(row.dataset.id)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
rowsEl.addEventListener('dragstart', (e) => {
|
|
890
|
+
const row = e.target.closest('.story[draggable="true"]')
|
|
891
|
+
if (!row) return
|
|
892
|
+
dragging = { id: Number(row.dataset.id), repo: row.dataset.repo }
|
|
893
|
+
row.classList.add('dragging')
|
|
894
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
895
|
+
// Nothing reads it — the row is held in `dragging` — but a drag carrying no data at all never
|
|
896
|
+
// starts in Firefox.
|
|
897
|
+
e.dataTransfer.setData('text/plain', row.dataset.id)
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
rowsEl.addEventListener('dragend', (e) => {
|
|
901
|
+
dragging = null
|
|
902
|
+
e.target.closest('.story')?.classList.remove('dragging')
|
|
903
|
+
clearMarks()
|
|
904
|
+
// Whatever the poll wanted to draw while the row was in the air, it can have now.
|
|
905
|
+
paintRows()
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
rowsEl.addEventListener('dragover', (e) => {
|
|
909
|
+
if (!dragging) return
|
|
910
|
+
const target = e.target.closest('.story[draggable], .epic')
|
|
911
|
+
// Only inside the repository it came from. Dropping a story into another repository's epic would
|
|
912
|
+
// be moving work between repositories, which is not something you do by letting go of a piece of
|
|
913
|
+
// paper — and the key it is called by would change under it.
|
|
914
|
+
if (!target || target.dataset.repo !== dragging.repo) return
|
|
915
|
+
if (target.dataset.id && Number(target.dataset.id) === dragging.id) return
|
|
916
|
+
e.preventDefault() // without this the browser refuses the drop, silently
|
|
917
|
+
e.dataTransfer.dropEffect = 'move'
|
|
918
|
+
clearMarks()
|
|
919
|
+
target.classList.add('over')
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
rowsEl.addEventListener('dragleave', (e) => {
|
|
923
|
+
if (!rowsEl.contains(e.relatedTarget)) clearMarks()
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
rowsEl.addEventListener('drop', (e) => {
|
|
927
|
+
e.preventDefault()
|
|
928
|
+
const target = e.target.closest('.story[draggable], .epic')
|
|
929
|
+
const carried = dragging
|
|
930
|
+
clearMarks()
|
|
931
|
+
dragging = null
|
|
932
|
+
if (!carried || !target || target.dataset.repo !== carried.repo) return
|
|
933
|
+
const story = last.stories.find((s) => s.id === carried.id)
|
|
934
|
+
if (!story) return
|
|
935
|
+
// On an epic's own row it goes to the top of that group — you dropped it on the name, not
|
|
936
|
+
// between two rows. On a story it takes that story's place, and the one it displaced goes down.
|
|
937
|
+
if (!target.dataset.id) return drop(story, target.dataset.key ?? '', 0)
|
|
938
|
+
const to = last.stories.find((s) => s.id === Number(target.dataset.id))
|
|
939
|
+
if (!to || depth(to)) return // a task is not a place: it belongs under the story it came from
|
|
940
|
+
const group = groupOf(to.project_path, to.epic_key ?? '')
|
|
941
|
+
drop(story, to.epic_key ?? '', group.findIndex((s) => s.id === to.id))
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
panelEl.onclick = (e) => {
|
|
945
|
+
if (e.target.closest('[data-shut]')) return shut()
|
|
946
|
+
const check = e.target.closest('[data-check]')
|
|
947
|
+
if (check) return tickCheck(check.dataset.check, check.dataset.state)
|
|
948
|
+
// The manager, on the whole epic. It is looked up in what the panel is already showing rather
|
|
949
|
+
// than composed here: the key on the command line has to be the key on the screen.
|
|
950
|
+
const run = e.target.closest('[data-epic-run]')
|
|
951
|
+
if (run && view?.epic?.id === Number(run.dataset.epicRun)) return hooks.epicCommand(view.epic, 'k0-ultracode')
|
|
952
|
+
// A key in the panel — one this story waits for, one waiting for it, or one under the open
|
|
953
|
+
// epic — is the same gesture as a row: it takes you to that story.
|
|
954
|
+
const jump = e.target.closest('[data-story]')
|
|
955
|
+
if (jump) return openStory(jump.dataset.story)
|
|
956
|
+
const story = e.target.closest('[data-id]')
|
|
957
|
+
if (story) return openStory(story.dataset.id)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Escape shuts the panel, which is the way out of anything on this page. Not while you are typing
|
|
961
|
+
// in something: there is a search box on other pages of k0 and this must not learn a habit that
|
|
962
|
+
// eats a keystroke somewhere else.
|
|
963
|
+
document.addEventListener('keydown', (e) => {
|
|
964
|
+
if (e.key === 'Escape' && (openId || openEpicId) && !e.target.closest('input, select, textarea')) shut()
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
/** Ticking a line off the checklist by hand, which is what `by: user` on a check means. */
|
|
968
|
+
async function tickCheck(id, state) {
|
|
969
|
+
try {
|
|
970
|
+
await hooks.api(`/api/backlog/check/${Number(id)}`, {
|
|
971
|
+
method: 'PATCH',
|
|
972
|
+
body: JSON.stringify({ state: state === 'pass' ? 'todo' : 'pass', by: 'user' }),
|
|
973
|
+
})
|
|
974
|
+
await reread()
|
|
975
|
+
} catch (e) {
|
|
976
|
+
hooks.toast(`Couldn't do it: ${e.message}`, 8000)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ── The way in and the way out ───────────────────────────────────────────────
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Draw the list into `#board`.
|
|
984
|
+
*
|
|
985
|
+
* The two halves are elements this module keeps rather than makes, and that `render()` leaves alone
|
|
986
|
+
* while the list is on screen: a panel rebuilt from nothing — or merely taken out of the document
|
|
987
|
+
* and put back — would lose where it was scrolled to and shut every disclosure in it, on a poll
|
|
988
|
+
* that runs once a second while somebody is reading.
|
|
989
|
+
*/
|
|
990
|
+
export function drawList(board, data, wiring) {
|
|
991
|
+
hooks = wiring
|
|
992
|
+
last = data
|
|
993
|
+
parents = new Map((data.all ?? []).map((s) => [s.id, s.parent_story_id ?? null]))
|
|
994
|
+
paintRows()
|
|
995
|
+
drawPanel()
|
|
996
|
+
// Put in place once and then left alone. `append` on a child that is already where it should be
|
|
997
|
+
// still takes it out of the document and puts it back, and an element that has been out of the
|
|
998
|
+
// document comes back scrolled to the top: doing that on every poll is the panel jumping to its
|
|
999
|
+
// header once a second while somebody is reading the decisions at the bottom of it.
|
|
1000
|
+
if (rowsEl.parentNode !== board) {
|
|
1001
|
+
board.textContent = ''
|
|
1002
|
+
board.append(rowsEl, panelEl)
|
|
1003
|
+
}
|
|
1004
|
+
board.classList.toggle('open', !!(openId || openEpicId))
|
|
1005
|
+
// Coming back from the board with something still open: pick the poll up again rather than
|
|
1006
|
+
// leaving a panel that has stopped moving in front of a discussion that has not.
|
|
1007
|
+
if ((openId || openEpicId) && !ticker) follow()
|
|
1008
|
+
ensureCounts()
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Leaving the list for the board: whatever was open stops being followed. */
|
|
1012
|
+
export function leaveList() {
|
|
1013
|
+
clearInterval(ticker)
|
|
1014
|
+
ticker = null
|
|
1015
|
+
}
|