k0-board 0.1.1 → 0.2.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.
@@ -0,0 +1,298 @@
1
+ import { render } from '/md.js'
2
+
3
+ // ── The ChangeLog ────────────────────────────────────────────────────────────
4
+ // k0's third page. The board says what is happening; this says what happened.
5
+ //
6
+ // It is built in two passes on purpose. The facts come back from the server straight away and
7
+ // are on screen before you have finished reading the heading; the words take a model half a
8
+ // minute, and arrive into a page that was already worth looking at. A summary that shows a
9
+ // spinner over an empty sheet is a summary you close.
10
+
11
+ const $ = (s) => document.querySelector(s)
12
+
13
+ const api = async (url, opts) => {
14
+ const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
15
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText)
16
+ return r.json()
17
+ }
18
+
19
+ const DAY = 24 * 60 * 60 * 1000
20
+
21
+ // Something to read while the model reads. They are not jokes exactly — they say what is
22
+ // actually going on, which is the only reason a waiting message is ever worth having.
23
+ const PHRASES = [
24
+ 'Reading the commits…',
25
+ 'Working out what actually shipped…',
26
+ 'Translating git into human…',
27
+ 'Looking for what you left half-done…',
28
+ 'Checking what never made it out of here…',
29
+ 'Putting the day in order…',
30
+ ]
31
+
32
+ let period = 'last'
33
+ let facts = null
34
+ let poll = null
35
+ let phrases = null
36
+ const put = new Set() // repositories folded away by hand, this visit only
37
+
38
+ // ── Painting ─────────────────────────────────────────────────────────────────
39
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
40
+
41
+ const day = (t) => new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'long' })
42
+
43
+ /** Which stretch of time is on screen, said the way you would say it. */
44
+ function whenLabel(data) {
45
+ const single = data.to - data.from <= DAY
46
+ if (!single) return `${day(data.from)} – ${day(data.to - 1)}`
47
+ const today = new Date()
48
+ today.setHours(0, 0, 0, 0)
49
+ if (data.from === today.getTime()) return 'Today'
50
+ if (data.from === today.getTime() - DAY) return 'Yesterday'
51
+ return day(data.from)
52
+ }
53
+
54
+ /**
55
+ * The written summary comes back as one piece of Markdown with a `## name` per repository.
56
+ * It is cut back apart here so each repository's words can sit with its own facts — and so
57
+ * that folding a repository away takes its paragraph with it.
58
+ *
59
+ * The headings themselves are dropped: the block already carries the name, and this way a
60
+ * repository is always called what k0 calls it. Anything that matches no repository is kept
61
+ * and shown at the top rather than quietly lost.
62
+ */
63
+ function sectionise(html) {
64
+ const holder = document.createElement('div')
65
+ holder.innerHTML = html
66
+ const loose = []
67
+ const map = new Map()
68
+ let current = null
69
+ for (const node of [...holder.childNodes]) {
70
+ if (node.nodeType === 1 && node.tagName === 'H2') {
71
+ current = node.textContent.trim().toLowerCase()
72
+ map.set(current, [])
73
+ continue
74
+ }
75
+ if (current) map.get(current).push(node)
76
+ else loose.push(node)
77
+ }
78
+ return { loose, map }
79
+ }
80
+
81
+ function commitList(r) {
82
+ const ul = document.createElement('ul')
83
+ ul.className = 'commits'
84
+ for (const c of r.commits) {
85
+ const li = document.createElement('li')
86
+ const dot = document.createElement('i')
87
+ dot.className = c.online === false ? 'local' : 'online'
88
+ dot.title = c.online === false ? 'Still on this machine' : 'Online'
89
+ li.append(dot, document.createTextNode(c.subject))
90
+ ul.append(li)
91
+ }
92
+ return ul
93
+ }
94
+
95
+ /** What is still outstanding here — and only for repositories you actually touched. */
96
+ function pending(r) {
97
+ const lines = []
98
+ if (r.unpushedTotal) lines.push(`${plural(r.unpushedTotal, 'commit')} still on this machine`)
99
+ if (r.dirtyTotal) lines.push(`${plural(r.dirtyTotal, 'file')} changed and never committed`)
100
+ if (r.unreleased) lines.push(`${plural(r.unreleased.filter((l) => l.startsWith('-')).length, 'changelog line')} not yet released`)
101
+ const open = r.cards.filter((c) => !c.done)
102
+ if (open.length) lines.push(`${plural(open.length, 'card')} still open`)
103
+ if (!lines.length) return null
104
+ const p = document.createElement('p')
105
+ p.className = 'pending'
106
+ p.textContent = lines.join(' · ')
107
+ return p
108
+ }
109
+
110
+ function factsBlock(r) {
111
+ const d = document.createElement('details')
112
+ d.className = 'facts'
113
+ const s = document.createElement('summary')
114
+ const local = r.commits.filter((c) => c.online === false).length
115
+ const bits = []
116
+ if (r.commits.length) bits.push(plural(r.commits.length, 'commit'))
117
+ if (local) bits.push(`${local} not out yet`)
118
+ if (r.dirty.length) bits.push(plural(r.dirty.length, 'file touched', 'files touched'))
119
+ if (r.cards.length) bits.push(plural(r.cards.length, 'card'))
120
+ s.textContent = bits.join(' · ') || 'nothing recorded'
121
+ d.append(s)
122
+ if (r.commits.length) d.append(commitList(r))
123
+ const p = pending(r)
124
+ if (p) d.append(p)
125
+ return d
126
+ }
127
+
128
+ function repoBlock(r, nodes) {
129
+ const sec = document.createElement('section')
130
+ sec.className = 'repo'
131
+ const h = document.createElement('h2')
132
+ h.textContent = r.name
133
+ sec.append(h)
134
+ if (nodes?.length) {
135
+ const prose = document.createElement('div')
136
+ prose.className = 'prose'
137
+ prose.append(...nodes)
138
+ sec.append(prose)
139
+ }
140
+ sec.append(factsBlock(r))
141
+ return sec
142
+ }
143
+
144
+ function paintChips(data) {
145
+ const box = $('#chips')
146
+ box.textContent = ''
147
+ box.hidden = data.repos.length < 2
148
+ for (const r of data.repos) {
149
+ const b = document.createElement('button')
150
+ b.type = 'button'
151
+ b.textContent = r.name
152
+ b.setAttribute('aria-pressed', String(!put.has(r.name)))
153
+ b.onclick = () => {
154
+ if (put.has(r.name)) put.delete(r.name)
155
+ else put.add(r.name)
156
+ paint()
157
+ }
158
+ box.append(b)
159
+ }
160
+ }
161
+
162
+ /** Draws whatever is known right now: the facts always, the words if they have arrived. */
163
+ function paint(text = null) {
164
+ const data = facts
165
+ if (!data) return
166
+ const { loose, map } = text ? sectionise(render(text)) : { loose: [], map: new Map() }
167
+
168
+ const intro = $('#intro')
169
+ intro.textContent = ''
170
+ if (loose.length) intro.append(...loose)
171
+
172
+ const box = $('#repos')
173
+ box.textContent = ''
174
+ const mine = new Set(data.repos.map((r) => r.name.toLowerCase()))
175
+ for (const r of data.repos) {
176
+ if (put.has(r.name)) continue
177
+ box.append(repoBlock(r, map.get(r.name.toLowerCase())))
178
+ }
179
+ // A heading that matches no repository — a name written differently, a paragraph the model
180
+ // decided to group under something of its own. It goes at the top rather than nowhere: losing
181
+ // a piece of the summary without telling anybody is the one failure that would not show.
182
+ for (const [name, nodes] of map) if (!mine.has(name)) intro.append(...nodes)
183
+ paintChips(data)
184
+ }
185
+
186
+ function say(message, kind = 'note') {
187
+ const intro = $('#intro')
188
+ intro.textContent = ''
189
+ const p = document.createElement('p')
190
+ p.className = kind
191
+ p.textContent = message
192
+ intro.append(p)
193
+ }
194
+
195
+ // ── Waiting ──────────────────────────────────────────────────────────────────
196
+ function waiting(on) {
197
+ $('#waiting').hidden = !on
198
+ clearInterval(phrases)
199
+ if (!on) return
200
+ let i = 0
201
+ $('#phrase').textContent = PHRASES[0]
202
+ phrases = setInterval(() => {
203
+ i = (i + 1) % PHRASES.length
204
+ $('#phrase').textContent = PHRASES[i]
205
+ }, 2600)
206
+ }
207
+
208
+ function stop() {
209
+ clearInterval(poll)
210
+ poll = null
211
+ waiting(false)
212
+ }
213
+
214
+ function arrived(state) {
215
+ if (state.text) {
216
+ stop()
217
+ paint(state.text)
218
+ return true
219
+ }
220
+ if (state.error) {
221
+ stop()
222
+ say(state.error, 'note')
223
+ return true
224
+ }
225
+ return false
226
+ }
227
+
228
+ async function writeUp() {
229
+ waiting(true)
230
+ try {
231
+ if (arrived(await api(`/api/changelog/write?period=${period}`, { method: 'POST' }))) return
232
+ } catch (err) {
233
+ stop()
234
+ say(String(err.message || err), 'note')
235
+ return
236
+ }
237
+ poll = setInterval(async () => {
238
+ try {
239
+ arrived(await api(`/api/changelog/write?period=${period}`))
240
+ } catch {
241
+ /* the server is restarting: the next round will find it */
242
+ }
243
+ }, 1000)
244
+ }
245
+
246
+ // ── Loading ──────────────────────────────────────────────────────────────────
247
+ /**
248
+ * Which of the four is lit. The page opens on none of them — it opens on the last day that had
249
+ * anything in it — but when that day turns out to be today or yesterday, lighting the button
250
+ * says so more clearly than the date does.
251
+ */
252
+ function markPeriod(data) {
253
+ let lit = period
254
+ if (period === 'last' && data && data.to - data.from <= DAY) {
255
+ const today = new Date()
256
+ today.setHours(0, 0, 0, 0)
257
+ if (data.from === today.getTime()) lit = 'today'
258
+ else if (data.from === today.getTime() - DAY) lit = 'yesterday'
259
+ }
260
+ for (const b of document.querySelectorAll('#periods button')) {
261
+ b.setAttribute('aria-checked', String(b.dataset.period === lit))
262
+ }
263
+ }
264
+
265
+ async function load(p) {
266
+ period = p
267
+ stop()
268
+ markPeriod(null)
269
+
270
+ facts = await api(`/api/changelog?period=${p}`)
271
+ markPeriod(facts)
272
+ $('#when').textContent = whenLabel(facts)
273
+ $('#blank').hidden = true
274
+ paint()
275
+
276
+ if (!facts.repos.length) {
277
+ // Nothing happened. Say it in a line and stop there — no model, nothing to pad out.
278
+ $('#intro').textContent = ''
279
+ $('#chips').hidden = true
280
+ $('#blank').hidden = false
281
+ $('#blank').textContent =
282
+ p === 'last' ? 'Nothing in the last thirty days.' : `Nothing in ${whenLabel(facts).toLowerCase()}.`
283
+ return
284
+ }
285
+
286
+ if (!facts.writer.can) {
287
+ say(`${facts.writer.why} The facts are all here.`)
288
+ return
289
+ }
290
+ writeUp()
291
+ }
292
+
293
+ $('#periods').onclick = (e) => {
294
+ const b = e.target.closest('button[data-period]')
295
+ if (b && b.dataset.period !== period) load(b.dataset.period)
296
+ }
297
+
298
+ load('last')
package/web/index.html CHANGED
@@ -13,6 +13,12 @@
13
13
  <button class="new" id="new-card" title="New card" aria-label="New card">
14
14
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
15
15
  </button>
16
+ <!-- Next to the plus, because they are the two ends of the same thing: one starts a piece
17
+ of work, the other tells you how the ones before it went. A plain link, and a quiet
18
+ one — no dot, no badge. It is there when you want it. -->
19
+ <a class="log" href="/changelog.html" title="What you have been doing" aria-label="What you have been doing">
20
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h11M4 17h7"/></svg>
21
+ </a>
16
22
  <div class="filters" id="filters"></div>
17
23
 
18
24
  <!-- How the computer is doing. Always visible; clicking it switches off the weights on the cards. -->
@@ -46,11 +52,11 @@
46
52
  <div class="edges" id="edges" aria-hidden="true"><i class="l"></i><i class="r"></i><i class="t"></i><i class="b"></i></div>
47
53
 
48
54
  <div class="zoom" id="zoom">
49
- <button type="button" id="z-out" title="Zoom out" aria-label="Zoom out">
55
+ <button type="button" id="z-out" title="Zoom out, ten per cent at a time" aria-label="Zoom out">
50
56
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
51
57
  </button>
52
58
  <button type="button" id="z-level" title="Back to 100%">100%</button>
53
- <button type="button" id="z-in" title="Zoom in" aria-label="Zoom in">
59
+ <button type="button" id="z-in" title="Zoom in, ten per cent at a time" aria-label="Zoom in">
54
60
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
55
61
  </button>
56
62
  <button type="button" id="z-fit" title="Fit the whole board on screen" aria-label="Fit to screen">
@@ -80,12 +86,8 @@
80
86
  <small>this is also the session name</small>
81
87
  </label>
82
88
 
83
- <label>Description
84
- <textarea id="f-desc" rows="2" placeholder="a line or two about this session"></textarea>
85
- </label>
86
-
87
89
  <label>What Claude should do when it starts
88
- <textarea id="f-prompt" rows="6" placeholder="you can leave this empty"></textarea>
90
+ <textarea id="f-prompt" rows="6" placeholder="you can leave this empty and type straight into Claude Code"></textarea>
89
91
  </label>
90
92
 
91
93
  <footer>
package/web/recency.js ADDED
@@ -0,0 +1,86 @@
1
+ // ── Which repositories are still warm ──────────────────────────────
2
+ // With a dozen repositories open the board is wider than the screen, and the three or four you
3
+ // are actually on today sit scattered among all the others. This decides which columns stay
4
+ // open and which fold away into `Old`, and it is here rather than in board.js for the reason
5
+ // docs/testing.md gives: what is worth proving comes out of the browser files into one of its
6
+ // own. Nothing in here touches the DOM.
7
+
8
+ export const DAY = 24 * 60 * 60 * 1000
9
+
10
+ /**
11
+ * The statuses that hold a column on the board whatever its age: something is happening in there
12
+ * right now, or something is on screen waiting for you. Hiding the one that is waiting for you is
13
+ * the exact opposite of what the board is for.
14
+ *
15
+ * `IDLE` is deliberately not one of them, and it is the only debatable line in this file. It
16
+ * means "your turn", so it looks like it belongs — but it is also the resting state of every
17
+ * terminal you have left open, and on a real board nearly every live card is sitting in it. Let
18
+ * it hold a column and nothing ever folds: a session you finished with three weeks ago and never
19
+ * closed would keep its column at full width for ever. So `IDLE` gets the same treatment as
20
+ * everything else — recent, and it stays; a fortnight old, and it goes to `Old`, where it is one
21
+ * click away. Nothing is closed either way: the terminal is still open, and the card is still
22
+ * exactly where you left it.
23
+ */
24
+ const DEMANDING = new Set(['ASK', 'PLANNED', 'WORKING', 'PLANNING'])
25
+
26
+ /** The last time each repository was touched: the freshest of its cards. */
27
+ export function lastTouched(cards) {
28
+ const map = new Map()
29
+ for (const c of cards) {
30
+ const t = Number(c.updated_at) || 0
31
+ if (t > (map.get(c.project_path) ?? 0)) map.set(c.project_path, t)
32
+ }
33
+ return map
34
+ }
35
+
36
+ /** The repositories with something going on in them right now. */
37
+ export function busy(cards) {
38
+ const set = new Set()
39
+ for (const c of cards) if (DEMANDING.has(c.status)) set.add(c.project_path)
40
+ return set
41
+ }
42
+
43
+ /**
44
+ * The line between recent and old. It is **not** "now minus a day": it is a day back from the
45
+ * most recent thing on the whole board.
46
+ *
47
+ * That one difference is what makes it survive a weekend. Anchored to the clock, a Monday
48
+ * morning would find every column older than a day and fold the lot — you would open k0 to an
49
+ * empty board on the one morning you most need to see where you left off. Anchored to the work,
50
+ * Monday shows you Friday, and the moment you start something the line moves with you.
51
+ */
52
+ export function cutoff(touched) {
53
+ let newest = 0
54
+ for (const t of touched.values()) if (t > newest) newest = t
55
+ return newest ? newest - DAY : 0
56
+ }
57
+
58
+ /**
59
+ * Which columns stay open, and which fold into `Old`. Four rules, in this order:
60
+ *
61
+ * 1. a repository with something going on in it is always open — see DEMANDING above, and note
62
+ * this beats a fold you did by hand: something in there woke up and wants you;
63
+ * 2. one you opened by hand during this visit stays open;
64
+ * 3. one you put away by hand stays away;
65
+ * 4. otherwise, open if it was touched after the cutoff.
66
+ *
67
+ * `held` carries the fifth rule, which lives in the caller: within one visit the set of open
68
+ * columns only ever grows. A column does not fold out from under you while you are working —
69
+ * it folds the next time the page loads. It is the same reason the columns only reorder
70
+ * themselves in driving mode.
71
+ *
72
+ * The order of `paths` is kept: whoever asked has already sorted them.
73
+ */
74
+ export function split({ paths, cards, folded = new Set(), held = new Set() }) {
75
+ const touched = lastTouched(cards)
76
+ const line = cutoff(touched)
77
+ const alive = busy(cards)
78
+ const open = []
79
+ const old = []
80
+ for (const p of paths) {
81
+ const stays =
82
+ alive.has(p) || held.has(p) || (!folded.has(p) && (touched.get(p) ?? 0) >= line)
83
+ ;(stays ? open : old).push(p)
84
+ }
85
+ return { open, old, touched, alive }
86
+ }
package/web/view.js CHANGED
@@ -9,7 +9,9 @@ const $ = (s) => document.querySelector(s)
9
9
 
10
10
  const EDGE = 96 // the sensitive band along each edge, in px
11
11
  const SPEED = 1200 // px per second right at the edge; inside the band it accelerates
12
- const STEPS = [0.3, 0.4, 0.5, 0.65, 0.8, 1, 1.25, 1.5, 2]
12
+ // The ladder is even on purpose: every click of + or − is ten points, so where the next one lands
13
+ // is something you can predict rather than discover.
14
+ const STEPS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2]
13
15
 
14
16
  let vp, board, edges, level
15
17