k0-board 0.1.2 → 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.
- package/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +134 -0
- package/README.md +206 -16
- package/bin/k0-board.js +37 -3
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/server/changelog.js +242 -0
- package/server/db.js +58 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/index.js +122 -35
- package/server/machine.js +9 -0
- package/server/servers.js +481 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +133 -7
- package/web/board.js +263 -6
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +8 -2
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* The ChangeLog: k0's third page. Colours and fonts live in base.css.
|
|
2
|
+
|
|
3
|
+
This one is a sheet of paper, not a board — it is the only page in k0 you read top to bottom
|
|
4
|
+
rather than look at. So it scrolls, it has a measure narrow enough to read comfortably, and
|
|
5
|
+
nothing on it moves. */
|
|
6
|
+
@import url('/base.css');
|
|
7
|
+
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
background: var(--bg);
|
|
11
|
+
color: var(--ink);
|
|
12
|
+
font-family: var(--sans);
|
|
13
|
+
font-size: 1rem;
|
|
14
|
+
line-height: 1.55;
|
|
15
|
+
-webkit-font-smoothing: antialiased;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
height: 100%;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* ── The top bar ─────────────────────────────────────────────────────── */
|
|
22
|
+
.topbar {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 14px;
|
|
26
|
+
padding: 10px 18px;
|
|
27
|
+
background: var(--paper);
|
|
28
|
+
border-bottom: 1px solid var(--line);
|
|
29
|
+
flex: none;
|
|
30
|
+
}
|
|
31
|
+
.topbar .home {
|
|
32
|
+
font: 700 1.2rem/1 var(--mono);
|
|
33
|
+
letter-spacing: 1px;
|
|
34
|
+
color: var(--ink);
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
}
|
|
37
|
+
.topbar .home:hover { color: var(--muted); }
|
|
38
|
+
.topbar h1 {
|
|
39
|
+
margin: 0;
|
|
40
|
+
font: 600 1rem/1 var(--sans);
|
|
41
|
+
color: var(--muted);
|
|
42
|
+
flex: 1;
|
|
43
|
+
}
|
|
44
|
+
.topbar .when {
|
|
45
|
+
font-size: .82rem;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
color: var(--muted);
|
|
48
|
+
flex: none;
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* The four windows, as one connected control rather than four loose buttons: they are a scale
|
|
53
|
+
of how far back to look, and exactly one of them is always the answer. */
|
|
54
|
+
.periods { display: flex; flex: none; }
|
|
55
|
+
.periods button {
|
|
56
|
+
border-radius: 0;
|
|
57
|
+
border-left-width: 0;
|
|
58
|
+
padding: 5px 13px;
|
|
59
|
+
font-size: .82rem;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
color: var(--muted);
|
|
62
|
+
}
|
|
63
|
+
.periods button:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
|
|
64
|
+
.periods button:last-child { border-radius: 0 8px 8px 0; }
|
|
65
|
+
.periods button[aria-checked='true'] {
|
|
66
|
+
background: var(--ink);
|
|
67
|
+
border-color: var(--ink);
|
|
68
|
+
color: var(--paper);
|
|
69
|
+
}
|
|
70
|
+
.periods button[aria-checked='true'] + button { border-left-color: var(--ink); }
|
|
71
|
+
|
|
72
|
+
/* ── The sheet ───────────────────────────────────────────────────────── */
|
|
73
|
+
.sheet {
|
|
74
|
+
flex: 1;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
padding: 30px 18px 80px;
|
|
77
|
+
}
|
|
78
|
+
.sheet > * { max-width: 44rem; margin-inline: auto; }
|
|
79
|
+
|
|
80
|
+
.intro { font-size: 1.08rem; }
|
|
81
|
+
.intro p { margin: 0 0 14px; }
|
|
82
|
+
.intro p.note { color: var(--muted); font-size: .95rem; }
|
|
83
|
+
.blank { color: var(--muted); text-align: center; padding: 40px 0; }
|
|
84
|
+
|
|
85
|
+
/* The repositories you have put away for this visit. Not a preference and not remembered: the
|
|
86
|
+
page is written afresh every time it is opened, so a filter that outlived it would be a
|
|
87
|
+
filter you had forgotten setting. */
|
|
88
|
+
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 6px auto 22px; }
|
|
89
|
+
.chips button {
|
|
90
|
+
border-radius: 999px;
|
|
91
|
+
padding: 4px 12px;
|
|
92
|
+
font-size: .78rem;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
font-family: var(--mono);
|
|
95
|
+
color: var(--ink);
|
|
96
|
+
}
|
|
97
|
+
.chips button[aria-pressed='false'] { color: var(--muted); background: transparent; opacity: .6; }
|
|
98
|
+
|
|
99
|
+
/* ── One repository ──────────────────────────────────────────────────── */
|
|
100
|
+
.repo {
|
|
101
|
+
background: var(--paper);
|
|
102
|
+
border: 1px solid var(--line);
|
|
103
|
+
border-radius: 12px;
|
|
104
|
+
padding: 20px 24px;
|
|
105
|
+
margin-bottom: 16px;
|
|
106
|
+
}
|
|
107
|
+
.repo h2 {
|
|
108
|
+
margin: 0 0 12px;
|
|
109
|
+
font: 700 .95rem/1 var(--mono);
|
|
110
|
+
letter-spacing: .5px;
|
|
111
|
+
}
|
|
112
|
+
.prose > :first-child { margin-top: 0; }
|
|
113
|
+
.prose > :last-child { margin-bottom: 0; }
|
|
114
|
+
.prose p { margin: 0 0 12px; }
|
|
115
|
+
/* The bold opening line of a block is the whole point of it: it says what is different now. */
|
|
116
|
+
.prose p > strong:only-child { font-size: 1.05rem; }
|
|
117
|
+
.prose ul, .prose ol { margin: 0 0 12px; padding-left: 20px; }
|
|
118
|
+
.prose li { margin-bottom: 3px; }
|
|
119
|
+
.prose code {
|
|
120
|
+
font: .85em var(--mono);
|
|
121
|
+
background: var(--bg);
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
padding: 1px 5px;
|
|
124
|
+
}
|
|
125
|
+
.prose h3 { margin: 16px 0 8px; font-size: .95rem; }
|
|
126
|
+
.prose a { color: inherit; }
|
|
127
|
+
|
|
128
|
+
/* ── The facts underneath ────────────────────────────────────────────── */
|
|
129
|
+
/* Folded away by default. The written summary is what you came for; this is what to open when
|
|
130
|
+
you do not believe it. */
|
|
131
|
+
.facts { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
132
|
+
.facts summary {
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
font-size: .78rem;
|
|
135
|
+
color: var(--muted);
|
|
136
|
+
list-style: none;
|
|
137
|
+
}
|
|
138
|
+
.facts summary::-webkit-details-marker { display: none; }
|
|
139
|
+
.facts summary::before { content: '▸ '; }
|
|
140
|
+
.facts[open] summary::before { content: '▾ '; }
|
|
141
|
+
|
|
142
|
+
.commits { list-style: none; margin: 12px 0 0; padding: 0; font-size: .88rem; }
|
|
143
|
+
.commits li { display: flex; align-items: baseline; gap: 9px; margin-bottom: 5px; }
|
|
144
|
+
.commits i {
|
|
145
|
+
flex: none;
|
|
146
|
+
width: 7px;
|
|
147
|
+
height: 7px;
|
|
148
|
+
border-radius: 999px;
|
|
149
|
+
background: var(--calm);
|
|
150
|
+
}
|
|
151
|
+
/* The one distinction the whole page is built on: out of here, or still only here. */
|
|
152
|
+
.commits i.local { background: var(--warn); }
|
|
153
|
+
|
|
154
|
+
.pending { margin: 12px 0 0; font-size: .82rem; color: var(--warn); }
|
|
155
|
+
|
|
156
|
+
/* ── While the words are being written ───────────────────────────────── */
|
|
157
|
+
.waiting { margin-bottom: 26px; }
|
|
158
|
+
.waiting .bar {
|
|
159
|
+
height: 3px;
|
|
160
|
+
background: var(--line);
|
|
161
|
+
border-radius: 2px;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
}
|
|
164
|
+
.waiting .bar i {
|
|
165
|
+
display: block;
|
|
166
|
+
height: 100%;
|
|
167
|
+
width: 32%;
|
|
168
|
+
border-radius: 2px;
|
|
169
|
+
background: var(--ink);
|
|
170
|
+
animation: sweep 1.5s ease-in-out infinite;
|
|
171
|
+
}
|
|
172
|
+
@keyframes sweep {
|
|
173
|
+
from { transform: translateX(-105%); }
|
|
174
|
+
to { transform: translateX(420%); }
|
|
175
|
+
}
|
|
176
|
+
/* Handwriting, like the other places where k0 speaks rather than reports. */
|
|
177
|
+
.waiting .phrase {
|
|
178
|
+
margin: 10px 0 0;
|
|
179
|
+
font: 600 1.25rem/1 var(--hand);
|
|
180
|
+
color: var(--muted);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@media (prefers-reduced-motion: reduce) {
|
|
184
|
+
.waiting .bar i { animation: none; width: 100%; opacity: .35; }
|
|
185
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>k0 — ChangeLog</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
8
|
+
<link rel="stylesheet" href="/changelog.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<a class="home" href="/" title="Back to the dashboard">k0</a>
|
|
13
|
+
<h1>ChangeLog</h1>
|
|
14
|
+
|
|
15
|
+
<!-- Four windows and no date picker. A summary is something you glance at, and every extra
|
|
16
|
+
control is one more decision to make before reading a word. -->
|
|
17
|
+
<div class="periods" id="periods" role="radiogroup" aria-label="Period">
|
|
18
|
+
<button type="button" role="radio" data-period="today" aria-checked="false">Today</button>
|
|
19
|
+
<button type="button" role="radio" data-period="yesterday" aria-checked="false">Yesterday</button>
|
|
20
|
+
<button type="button" role="radio" data-period="week" aria-checked="false">Week</button>
|
|
21
|
+
<button type="button" role="radio" data-period="month" aria-checked="false">Month</button>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<span class="when" id="when"></span>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<main class="sheet" id="sheet">
|
|
28
|
+
<!-- While the words are being written the facts are already here, so the page is never a
|
|
29
|
+
spinner on an empty screen: it is the summary, filling in. -->
|
|
30
|
+
<div class="waiting" id="waiting" hidden>
|
|
31
|
+
<div class="bar"><i></i></div>
|
|
32
|
+
<p class="phrase" id="phrase"></p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="intro" id="intro"></div>
|
|
36
|
+
<div class="chips" id="chips" hidden></div>
|
|
37
|
+
<div class="repos" id="repos"></div>
|
|
38
|
+
<p class="blank" id="blank" hidden></p>
|
|
39
|
+
</main>
|
|
40
|
+
|
|
41
|
+
<script type="module" src="/changelog.js"></script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
package/web/changelog.js
ADDED
|
@@ -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">
|
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
|
-
|
|
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
|
|