@cmgmyr/hive 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/claude-plugin/.claude-plugin/plugin.json +7 -0
- package/claude-plugin/README.md +47 -0
- package/claude-plugin/hooks/hooks.json +14 -0
- package/claude-plugin/kickoff.mjs +41 -0
- package/claude-plugin/skills/cleanup/SKILL.md +60 -0
- package/dist/abi.js +220 -0
- package/dist/abiProbe.js +9 -0
- package/dist/backgroundTasks.js +51 -0
- package/dist/backup.js +378 -0
- package/dist/brief.js +113 -0
- package/dist/build-info.json +1 -0
- package/dist/claudeDir.js +5 -0
- package/dist/cli.js +2901 -0
- package/dist/codexHome.js +297 -0
- package/dist/config.js +72 -0
- package/dist/context.js +256 -0
- package/dist/contextCheckpoint.js +55 -0
- package/dist/dashboard.js +1158 -0
- package/dist/dataDir.js +89 -0
- package/dist/db.js +421 -0
- package/dist/dispatcher.js +150 -0
- package/dist/firstPrompt.js +6 -0
- package/dist/harnesses.js +173 -0
- package/dist/help.js +353 -0
- package/dist/hook.js +197 -0
- package/dist/hooks.js +50 -0
- package/dist/index.js +34 -0
- package/dist/kickoff.js +191 -0
- package/dist/leadMessage.js +98 -0
- package/dist/mcpConfig.js +121 -0
- package/dist/processes.js +161 -0
- package/dist/profiles.js +278 -0
- package/dist/projectYml.js +254 -0
- package/dist/prompts.js +18 -0
- package/dist/ptys.js +146 -0
- package/dist/result.js +53 -0
- package/dist/scheduler.js +1622 -0
- package/dist/sessionProbe.js +131 -0
- package/dist/slug.js +29 -0
- package/dist/spawn.js +370 -0
- package/dist/stateProvenance.js +97 -0
- package/dist/statusline.js +82 -0
- package/dist/strictInput.js +52 -0
- package/dist/teardown.js +89 -0
- package/dist/tmux.js +1295 -0
- package/dist/tools/agents.js +1246 -0
- package/dist/tools/kv.js +89 -0
- package/dist/tools/leases.js +87 -0
- package/dist/tools/meta.js +183 -0
- package/dist/tools/pads.js +279 -0
- package/dist/tools/params.js +14 -0
- package/dist/tools/todos.js +382 -0
- package/dist/tools/wakes.js +466 -0
- package/dist/transcript.js +123 -0
- package/dist/version.js +59 -0
- package/package.json +62 -0
- package/profiles/orchestration/posture.md +27 -0
- package/profiles/orchestration/runbook.md +72 -0
- package/profiles/orchestration/worker.md +38 -0
- package/profiles/simple/posture.md +14 -0
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { getProject } from "./context.js";
|
|
4
|
+
export const describeVisibility = (visibility) => visibility === null ? "running" : visibility === "window" ? "own window" : visibility;
|
|
5
|
+
export function processCounts(procs) {
|
|
6
|
+
return {
|
|
7
|
+
running: procs.filter((p) => p.running).length,
|
|
8
|
+
hidden: procs.filter((p) => p.visibility === "hidden").length,
|
|
9
|
+
unlocated: procs.filter((p) => p.running && p.visibility === null).length,
|
|
10
|
+
notStarted: procs.filter((p) => !p.running).length,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
import { awaitingFirstPrompt } from "./firstPrompt.js";
|
|
14
|
+
import { SLUG_MAX_LEN, cutToUnitBudget } from "./slug.js";
|
|
15
|
+
export const TODO_CAP = 100;
|
|
16
|
+
export const AGENT_CAP = 50;
|
|
17
|
+
export const WAKE_CAP = 50;
|
|
18
|
+
export const ACTIVITY_SOURCE_LIMIT = 30;
|
|
19
|
+
export const ACTIVITY_DISPLAY_CAP = 40;
|
|
20
|
+
export function truncateWithEllipsis(text, maxLength) {
|
|
21
|
+
return text.length > maxLength ? `${cutToUnitBudget(text, maxLength)}…` : text;
|
|
22
|
+
}
|
|
23
|
+
function escapeHtml(input) {
|
|
24
|
+
return input
|
|
25
|
+
.replace(/&/g, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, """)
|
|
29
|
+
.replace(/'/g, "'");
|
|
30
|
+
}
|
|
31
|
+
function prose(text) {
|
|
32
|
+
return `<span class="prose">${escapeHtml(text)}</span>`;
|
|
33
|
+
}
|
|
34
|
+
function mono(text) {
|
|
35
|
+
return `<span class="mono">${escapeHtml(text)}</span>`;
|
|
36
|
+
}
|
|
37
|
+
function icon(name) {
|
|
38
|
+
return `<svg class="icon" aria-hidden="true"><use href="#i-${name}"></use></svg>`;
|
|
39
|
+
}
|
|
40
|
+
function statusBadge(level, text) {
|
|
41
|
+
return `<span class="status status-${level}">${escapeHtml(text)}</span>`;
|
|
42
|
+
}
|
|
43
|
+
function emptyState(message, hint) {
|
|
44
|
+
return (`<div class="empty">${icon("inbox")}<p class="empty-title">${escapeHtml(message)}</p>` +
|
|
45
|
+
`<p class="empty-hint">${escapeHtml(hint)}</p></div>`);
|
|
46
|
+
}
|
|
47
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
48
|
+
function formatDate(d) {
|
|
49
|
+
return (`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
|
|
50
|
+
`${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`);
|
|
51
|
+
}
|
|
52
|
+
function formatLocal(utc) {
|
|
53
|
+
if (!utc)
|
|
54
|
+
return "-";
|
|
55
|
+
const d = new Date(`${utc.replace(" ", "T")}Z`);
|
|
56
|
+
if (Number.isNaN(d.getTime()))
|
|
57
|
+
return utc;
|
|
58
|
+
return formatDate(d);
|
|
59
|
+
}
|
|
60
|
+
function timeEl(utc) {
|
|
61
|
+
if (!utc)
|
|
62
|
+
return '<span class="muted">-</span>';
|
|
63
|
+
return `<span class="time" title="${escapeHtml(utc)} UTC">${escapeHtml(formatLocal(utc))} local</span>`;
|
|
64
|
+
}
|
|
65
|
+
// The clock alone would read as today for a timestamp that is not, and state AGE
|
|
66
|
+
// is the whole point of the line this renders.
|
|
67
|
+
function shortWhenEl(utc) {
|
|
68
|
+
if (!utc)
|
|
69
|
+
return '<span class="muted">-</span>';
|
|
70
|
+
const d = new Date(`${utc.replace(" ", "T")}Z`);
|
|
71
|
+
if (Number.isNaN(d.getTime()))
|
|
72
|
+
return `<span class="time">${escapeHtml(utc)}</span>`;
|
|
73
|
+
const local = formatDate(d);
|
|
74
|
+
const isToday = local.slice(0, 10) === formatDate(new Date()).slice(0, 10);
|
|
75
|
+
const text = isToday ? local.slice(11) : local.slice(5);
|
|
76
|
+
return `<span class="time" title="${escapeHtml(utc)} UTC">${escapeHtml(text)}</span>`;
|
|
77
|
+
}
|
|
78
|
+
function sortKey(createdAt) {
|
|
79
|
+
return createdAt.includes(".") ? createdAt : `${createdAt}.000`;
|
|
80
|
+
}
|
|
81
|
+
function fetchProject(projectId) {
|
|
82
|
+
const row = getProject(projectId);
|
|
83
|
+
if (!row)
|
|
84
|
+
throw new Error(`No project ${projectId}.`);
|
|
85
|
+
return row;
|
|
86
|
+
}
|
|
87
|
+
const PENDING_WAKE_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
|
|
88
|
+
function agentStatusLevel(state) {
|
|
89
|
+
if (state === "working")
|
|
90
|
+
return "live";
|
|
91
|
+
if (state === "idle")
|
|
92
|
+
return "ok";
|
|
93
|
+
if (state === "waiting")
|
|
94
|
+
return "warn";
|
|
95
|
+
return "warn";
|
|
96
|
+
}
|
|
97
|
+
function agentStateBadge(a) {
|
|
98
|
+
return awaitingFirstPrompt(a) && a.agent_state === "idle"
|
|
99
|
+
? statusBadge("live", "idle (no assignment yet)")
|
|
100
|
+
: statusBadge(agentStatusLevel(a.agent_state), a.agent_state);
|
|
101
|
+
}
|
|
102
|
+
function hasStateChannel(kind) {
|
|
103
|
+
return kind === "agent";
|
|
104
|
+
}
|
|
105
|
+
function fetchLastLogEvent(actorId) {
|
|
106
|
+
return db
|
|
107
|
+
.prepare("SELECT event, created_at FROM agent_state_log WHERE actor_id = ? ORDER BY id DESC LIMIT 1")
|
|
108
|
+
.get(actorId);
|
|
109
|
+
}
|
|
110
|
+
// kind='command' is excluded here and rendered in its own section: a hive.yml process has no state
|
|
111
|
+
// channel, so in the workers card it showed as a worker with "no log event recorded" forever.
|
|
112
|
+
function fetchNowAgents(projectId) {
|
|
113
|
+
return db
|
|
114
|
+
.prepare(`SELECT id, name, kind, actor_id, agent_state, resumed_at, state_changed_at, created_at
|
|
115
|
+
FROM agents WHERE project_id = ? AND status = 'running' AND kind != 'command' ORDER BY created_at`)
|
|
116
|
+
.all(projectId);
|
|
117
|
+
}
|
|
118
|
+
function fetchNextWake(projectId) {
|
|
119
|
+
return db
|
|
120
|
+
.prepare(`SELECT kind, due_at, max_wait_at, deliver_actor FROM wakes
|
|
121
|
+
WHERE project_id = ? AND ${PENDING_WAKE_WHERE}
|
|
122
|
+
ORDER BY COALESCE(due_at, max_wait_at, created_at) LIMIT 1`)
|
|
123
|
+
.get(projectId);
|
|
124
|
+
}
|
|
125
|
+
function fetchTodoCounts(projectId) {
|
|
126
|
+
return db
|
|
127
|
+
.prepare(`SELECT
|
|
128
|
+
(SELECT COUNT(*) FROM todos WHERE project_id = ? AND archived_at IS NULL AND status != 'completed') AS open,
|
|
129
|
+
(SELECT COUNT(*) FROM todos t WHERE t.project_id = ? AND t.archived_at IS NULL AND t.status != 'completed'
|
|
130
|
+
AND EXISTS (
|
|
131
|
+
SELECT 1 FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
|
|
132
|
+
WHERE b.todo_id = t.id AND bt.status != 'completed'
|
|
133
|
+
)) AS blocked,
|
|
134
|
+
(SELECT COUNT(*) FROM todos
|
|
135
|
+
WHERE project_id = ? AND completed_at IS NOT NULL
|
|
136
|
+
AND date(completed_at, 'localtime') >= date('now', 'localtime', '-6 days')) AS completed7d`)
|
|
137
|
+
.get(projectId, projectId, projectId);
|
|
138
|
+
}
|
|
139
|
+
function kindEl(a) {
|
|
140
|
+
return a.kind === a.name ? "" : `<span class="kind">${escapeHtml(a.kind)}</span>`;
|
|
141
|
+
}
|
|
142
|
+
function renderWorkerRow(a) {
|
|
143
|
+
if (!hasStateChannel(a.kind)) {
|
|
144
|
+
const last = fetchLastLogEvent(a.actor_id);
|
|
145
|
+
const meta = last
|
|
146
|
+
? `last event: ${escapeHtml(last.event)} · ${shortWhenEl(last.created_at)}`
|
|
147
|
+
: "no log event recorded";
|
|
148
|
+
return (`<li class="worker"><span class="worker-head">${mono(a.name)}${kindEl(a)}</span>` +
|
|
149
|
+
`<span class="worker-meta">${meta}</span></li>`);
|
|
150
|
+
}
|
|
151
|
+
return (`<li class="worker"><span class="worker-head">${agentStateBadge(a)}${mono(a.name)}${kindEl(a)}</span>` +
|
|
152
|
+
`<span class="worker-meta">since ${shortWhenEl(a.state_changed_at ?? a.created_at)}</span></li>`);
|
|
153
|
+
}
|
|
154
|
+
function renderNowAgentsLine(agents) {
|
|
155
|
+
if (agents.length === 0)
|
|
156
|
+
return `<p class="stat-empty">no workers running</p>`;
|
|
157
|
+
const shown = agents.slice(0, AGENT_CAP);
|
|
158
|
+
return (capNote(shown.length, agents.length, "running agents") +
|
|
159
|
+
`<ul class="stat-workers">${shown.map(renderWorkerRow).join("")}</ul>`);
|
|
160
|
+
}
|
|
161
|
+
function renderNowWakeLine(next) {
|
|
162
|
+
if (!next)
|
|
163
|
+
return `<p class="stat-figure stat-none">—</p><p class="stat-empty">nothing scheduled</p>`;
|
|
164
|
+
const when = next.kind === "delay" ? next.due_at : next.max_wait_at;
|
|
165
|
+
const day = when ? formatLocal(when).slice(0, 10) : "-";
|
|
166
|
+
return (`<p class="stat-figure">${shortWhenEl(when)}</p>` +
|
|
167
|
+
`<p class="stat-sub">${escapeHtml(day)} local → ${mono(next.deliver_actor)}</p>`);
|
|
168
|
+
}
|
|
169
|
+
const PULSE_HOURS = 24;
|
|
170
|
+
export const PULSE_SQL = `
|
|
171
|
+
WITH RECURSIVE h(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM h WHERE i < ${PULSE_HOURS - 1}),
|
|
172
|
+
-- Hours are subtracted in UTC and only then converted for the label. Doing the
|
|
173
|
+
-- arithmetic in local time instead spans 23 or 25 real hours across a DST
|
|
174
|
+
-- change, which invents an hour that never happened and drops a real one.
|
|
175
|
+
buckets AS (
|
|
176
|
+
SELECT datetime('now', '-' || (${PULSE_HOURS - 1} - i) || ' hours') AS starts_at,
|
|
177
|
+
strftime('%Y-%m-%d %H', datetime('now', '-' || (${PULSE_HOURS - 1} - i) || ' hours'), 'localtime') AS bucket
|
|
178
|
+
FROM h
|
|
179
|
+
),
|
|
180
|
+
-- The leading edge of the window, and LOAD-BEARING: the oldest bucket starts
|
|
181
|
+
-- mid-hour, so an event earlier in that same local hour carries a label that
|
|
182
|
+
-- is in the set and must still be excluded. Labels alone would over-count it.
|
|
183
|
+
bound AS (SELECT MIN(starts_at) AS lo FROM buckets),
|
|
184
|
+
events AS (
|
|
185
|
+
SELECT strftime('%Y-%m-%d %H', c.created_at, 'localtime') AS bucket
|
|
186
|
+
FROM todo_comments c JOIN todos t ON t.id = c.todo_id
|
|
187
|
+
WHERE t.project_id = ? AND c.created_at >= (SELECT lo FROM bound)
|
|
188
|
+
UNION ALL
|
|
189
|
+
SELECT strftime('%Y-%m-%d %H', l.created_at, 'localtime') AS bucket
|
|
190
|
+
FROM agent_state_log l
|
|
191
|
+
WHERE l.created_at >= (SELECT lo FROM bound)
|
|
192
|
+
AND EXISTS (SELECT 1 FROM agents a WHERE a.actor_id = l.actor_id AND a.project_id = ?)
|
|
193
|
+
)
|
|
194
|
+
-- Grouped by LABEL, not by hour: a fall-back day maps two UTC hours onto one
|
|
195
|
+
-- local label, so the set is 23 bars that day rather than ${PULSE_HOURS}.
|
|
196
|
+
SELECT b.bucket, (SELECT COUNT(*) FROM events e WHERE e.bucket = b.bucket) AS n
|
|
197
|
+
FROM buckets b GROUP BY b.bucket ORDER BY MIN(b.starts_at)`;
|
|
198
|
+
export function fetchPulse(projectId) {
|
|
199
|
+
return db.prepare(PULSE_SQL).all(projectId, projectId);
|
|
200
|
+
}
|
|
201
|
+
const PULSE_WIDTH = 208;
|
|
202
|
+
const PULSE_HEIGHT = 44;
|
|
203
|
+
function buildPulse(buckets) {
|
|
204
|
+
const gap = 2;
|
|
205
|
+
const barWidth = (PULSE_WIDTH - gap * (buckets.length - 1)) / buckets.length;
|
|
206
|
+
const maxValue = Math.max(1, ...buckets.map((b) => b.n));
|
|
207
|
+
const baseline = PULSE_HEIGHT - 1;
|
|
208
|
+
const bars = buckets
|
|
209
|
+
.map((b, i) => {
|
|
210
|
+
const x = i * (barWidth + gap);
|
|
211
|
+
const hour = `${b.bucket.slice(11)}:00`;
|
|
212
|
+
const title = `<title>${escapeHtml(hour)} · ${b.n} event${b.n === 1 ? "" : "s"}</title>`;
|
|
213
|
+
if (b.n === 0)
|
|
214
|
+
return `<rect class="pulse-none" x="${x.toFixed(1)}" y="${baseline - 1}" width="${barWidth.toFixed(1)}" height="1">${title}</rect>`;
|
|
215
|
+
const height = Math.max(3, (b.n / maxValue) * (PULSE_HEIGHT - 4));
|
|
216
|
+
return (`<rect class="pulse-bar" x="${x.toFixed(1)}" y="${(baseline - height).toFixed(1)}" ` +
|
|
217
|
+
`width="${barWidth.toFixed(1)}" height="${height.toFixed(1)}" rx="2">${title}</rect>`);
|
|
218
|
+
})
|
|
219
|
+
.join("");
|
|
220
|
+
return `<svg viewBox="0 0 ${PULSE_WIDTH} ${PULSE_HEIGHT}" class="pulse" preserveAspectRatio="none" role="img" aria-label="comments and worker state changes per hour over the last ${PULSE_HOURS} hours">${bars}</svg>`;
|
|
221
|
+
}
|
|
222
|
+
function statCard(id, label, iconName, body) {
|
|
223
|
+
return (`<article class="card stat" id="stat-${id}">` +
|
|
224
|
+
`<h2 class="stat-label">${icon(iconName)}<span>${escapeHtml(label)}</span></h2>` +
|
|
225
|
+
body +
|
|
226
|
+
`</article>`);
|
|
227
|
+
}
|
|
228
|
+
function renderNowProcessesLine(procs) {
|
|
229
|
+
const { running, hidden, notStarted } = processCounts(procs);
|
|
230
|
+
return (`<p class="stat-figure">${running}<span class="stat-unit">running</span></p>` +
|
|
231
|
+
`<p class="stat-sub">${hidden} hidden · ${notStarted} not started</p>`);
|
|
232
|
+
}
|
|
233
|
+
function renderNowStrip(projectId, procs) {
|
|
234
|
+
const agents = fetchNowAgents(projectId);
|
|
235
|
+
const nextWake = fetchNextWake(projectId);
|
|
236
|
+
const todos = fetchTodoCounts(projectId);
|
|
237
|
+
const pulse = fetchPulse(projectId);
|
|
238
|
+
const pulseTotal = pulse.reduce((sum, b) => sum + b.n, 0);
|
|
239
|
+
return `<section class="now" aria-label="current status">
|
|
240
|
+
${statCard("workers", "workers", "users", `<p class="stat-figure">${agents.length}</p>${renderNowAgentsLine(agents)}`)}
|
|
241
|
+
${statCard("wake", "next wake", "clock", renderNowWakeLine(nextWake))}
|
|
242
|
+
${statCard("todos", "todos", "checklist", `<p class="stat-figure">${todos.open}<span class="stat-unit">open</span></p>` +
|
|
243
|
+
`<p class="stat-sub">${todos.blocked} blocked · ${todos.completed7d} completed in 7d</p>`)}
|
|
244
|
+
${procs.length > 0 ? statCard("processes", "processes", "inbox", renderNowProcessesLine(procs)) + "\n" : ""}${statCard("pulse", "pulse", "activity", `<p class="stat-figure">${pulseTotal}<span class="stat-unit">events in 24h</span></p>` +
|
|
245
|
+
(pulseTotal > 0
|
|
246
|
+
? buildPulse(pulse)
|
|
247
|
+
: `<p class="stat-empty">nothing in the last 24 hours</p>`))}
|
|
248
|
+
</section>`;
|
|
249
|
+
}
|
|
250
|
+
function renderBoardSection(index, projectId) {
|
|
251
|
+
const pad = db
|
|
252
|
+
.prepare("SELECT content, revision, updated_at FROM pads WHERE project_id = ? AND name = 'board' AND archived = 0")
|
|
253
|
+
.get(projectId);
|
|
254
|
+
const count = pad ? `rev ${pad.revision} · updated ${formatLocal(pad.updated_at)} local` : "no board pad";
|
|
255
|
+
const body = pad
|
|
256
|
+
? `<p class="meta">revision ${pad.revision}, updated ${timeEl(pad.updated_at)}</p>` +
|
|
257
|
+
`<pre class="board">${escapeHtml(pad.content)}</pre>`
|
|
258
|
+
: emptyState("This project has no board pad.", "The board pad is where a lead keeps the current state of play. Write one with pad_write.");
|
|
259
|
+
return section(index, "board", "Board", count, "board", null, body);
|
|
260
|
+
}
|
|
261
|
+
const OPEN_TODOS_SQL = `
|
|
262
|
+
SELECT t.id, t.title, t.body, t.slug, t.status, t.priority,
|
|
263
|
+
(SELECT COUNT(*) FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
|
|
264
|
+
WHERE b.todo_id = t.id AND bt.status != 'completed') AS open_blockers
|
|
265
|
+
FROM todos t
|
|
266
|
+
WHERE t.project_id = ? AND t.archived_at IS NULL AND t.status != 'completed'
|
|
267
|
+
ORDER BY CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, t.id`;
|
|
268
|
+
function fetchBlockersFor(todoIds) {
|
|
269
|
+
const byTodo = new Map();
|
|
270
|
+
if (todoIds.length === 0)
|
|
271
|
+
return byTodo;
|
|
272
|
+
const placeholders = todoIds.map(() => "?").join(",");
|
|
273
|
+
const rows = db
|
|
274
|
+
.prepare(`SELECT b.todo_id, t.id, t.title FROM todo_blockers b JOIN todos t ON t.id = b.blocker_id
|
|
275
|
+
WHERE b.todo_id IN (${placeholders}) AND t.status != 'completed'`)
|
|
276
|
+
.all(...todoIds);
|
|
277
|
+
for (const row of rows) {
|
|
278
|
+
const list = byTodo.get(row.todo_id) ?? [];
|
|
279
|
+
list.push({ id: row.id, title: row.title });
|
|
280
|
+
byTodo.set(row.todo_id, list);
|
|
281
|
+
}
|
|
282
|
+
return byTodo;
|
|
283
|
+
}
|
|
284
|
+
function todoBadgeLevel(status, blocked) {
|
|
285
|
+
if (blocked)
|
|
286
|
+
return "warn";
|
|
287
|
+
return status === "in_progress" ? "live" : "ok";
|
|
288
|
+
}
|
|
289
|
+
function renderTodosSection(index, projectId) {
|
|
290
|
+
const all = db.prepare(OPEN_TODOS_SQL).all(projectId);
|
|
291
|
+
const shown = all.slice(0, TODO_CAP);
|
|
292
|
+
const blockersByTodo = fetchBlockersFor(shown.filter((t) => t.open_blockers > 0).map((t) => t.id));
|
|
293
|
+
const blockedCount = all.filter((t) => t.open_blockers > 0).length;
|
|
294
|
+
const rows = shown
|
|
295
|
+
.map((t) => {
|
|
296
|
+
const blocked = t.open_blockers > 0;
|
|
297
|
+
const blockers = blocked
|
|
298
|
+
? (blockersByTodo.get(t.id) ?? []).map((b) => `${mono(`#${b.id}`)} ${prose(b.title)}`).join(", ")
|
|
299
|
+
: "";
|
|
300
|
+
const label = t.slug || truncateWithEllipsis(t.title, SLUG_MAX_LEN);
|
|
301
|
+
const body = `<div class="todo-body">${prose(t.title)}</div>` +
|
|
302
|
+
(t.body ? `<div class="todo-body">${prose(t.body)}</div>` : "");
|
|
303
|
+
return (`<li class="todo ${blocked ? "todo-blocked" : "todo-open"}">` +
|
|
304
|
+
`<details class="todo-item" id="todo-${t.id}">` +
|
|
305
|
+
`<summary class="row-summary"><span class="pb-label">` +
|
|
306
|
+
`<span class="priority priority-${escapeHtml(t.priority)}">${escapeHtml(t.priority)}</span> ` +
|
|
307
|
+
`${statusBadge(todoBadgeLevel(t.status, blocked), t.status)} ` +
|
|
308
|
+
`${mono(`#${t.id}`)} ${prose(label)}` +
|
|
309
|
+
(blocked ? ` <span class="chip chip-blocked">${icon("lock")}blocked</span>` : "") +
|
|
310
|
+
`</span>${chevron()}</summary>` +
|
|
311
|
+
`<div class="section-body">${body}</div>` +
|
|
312
|
+
`</details>` +
|
|
313
|
+
(blocked ? `<p class="blockers">blocked by ${blockers}</p>` : "") +
|
|
314
|
+
`</li>`);
|
|
315
|
+
})
|
|
316
|
+
.join("\n");
|
|
317
|
+
const filter = all.length > 0
|
|
318
|
+
? `<div class="filter"><label class="filter-field" for="todo-filter">${icon("search")}` +
|
|
319
|
+
`<span class="sr-only">Filter todos</span>` +
|
|
320
|
+
`<input type="search" id="todo-filter" placeholder="Filter todos by id, slug, title or body" autocomplete="off"></label>` +
|
|
321
|
+
`<p class="filter-count" id="todo-filter-count" aria-live="polite"></p></div>` +
|
|
322
|
+
`<p class="filter-none" id="todo-filter-none" hidden>No open todo matches that filter.</p>`
|
|
323
|
+
: "";
|
|
324
|
+
const body = filter +
|
|
325
|
+
renderCappedList(shown.length, all.length, "open todos", "todos", rows, emptyState("No open todos.", "Every todo in this project is completed or archived. New ones land here."), "todo-list");
|
|
326
|
+
return section(index, "todos", "Todos", `${all.length} open, ${blockedCount} blocked`, "checklist", String(shown.length), body);
|
|
327
|
+
}
|
|
328
|
+
export const CHART_DAYS = 7;
|
|
329
|
+
const DAY_STATS_SQL = `SELECT
|
|
330
|
+
date('now', 'localtime', '-' || ? || ' days') AS day,
|
|
331
|
+
(SELECT COUNT(*) FROM todos
|
|
332
|
+
WHERE project_id = ? AND completed_at IS NOT NULL
|
|
333
|
+
AND date(completed_at, 'localtime') = date('now', 'localtime', '-' || ? || ' days')) AS completed,
|
|
334
|
+
(SELECT COUNT(*) FROM todos
|
|
335
|
+
WHERE project_id = ? AND archived_at IS NULL
|
|
336
|
+
AND date(created_at, 'localtime') <= date('now', 'localtime', '-' || ? || ' days')
|
|
337
|
+
AND (completed_at IS NULL OR date(completed_at, 'localtime') > date('now', 'localtime', '-' || ? || ' days'))
|
|
338
|
+
) AS backlog`;
|
|
339
|
+
export function fetchDayStats(projectId) {
|
|
340
|
+
const stats = [];
|
|
341
|
+
for (let offset = CHART_DAYS - 1; offset >= 0; offset--) {
|
|
342
|
+
const row = db.prepare(DAY_STATS_SQL).get(offset, projectId, offset, projectId, offset, offset);
|
|
343
|
+
stats.push(row);
|
|
344
|
+
}
|
|
345
|
+
return stats;
|
|
346
|
+
}
|
|
347
|
+
const CHART_WIDTH = 960;
|
|
348
|
+
const CHART_HEIGHT = 244;
|
|
349
|
+
const CHART_PAD_LEFT = 40;
|
|
350
|
+
const CHART_PAD_RIGHT = 18;
|
|
351
|
+
const PANEL_HEIGHT = 78;
|
|
352
|
+
const PANEL_A_TOP = 22;
|
|
353
|
+
const PANEL_B_TOP = 138;
|
|
354
|
+
const CHART_LABEL_Y = 236;
|
|
355
|
+
function buildPanel(spec, stats) {
|
|
356
|
+
const plotWidth = CHART_WIDTH - CHART_PAD_LEFT - CHART_PAD_RIGHT;
|
|
357
|
+
const stepX = stats.length > 1 ? plotWidth / (stats.length - 1) : 0;
|
|
358
|
+
const baseline = spec.top + PANEL_HEIGHT;
|
|
359
|
+
const maxValue = Math.max(1, ...spec.values);
|
|
360
|
+
const xAt = (i) => CHART_PAD_LEFT + i * stepX;
|
|
361
|
+
const yAt = (v) => baseline - (v / maxValue) * (PANEL_HEIGHT - 8);
|
|
362
|
+
const points = spec.values.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`);
|
|
363
|
+
const last = spec.values.length - 1;
|
|
364
|
+
const dots = spec.values
|
|
365
|
+
.map((v, i) => `<circle class="dot dot-${spec.series}" cx="${xAt(i).toFixed(1)}" cy="${yAt(v).toFixed(1)}" ` +
|
|
366
|
+
`r="${i === last ? 4.5 : 3}" />`)
|
|
367
|
+
.join("");
|
|
368
|
+
return (`<text class="panel-title" x="${CHART_PAD_LEFT}" y="${spec.top - 8}">${escapeHtml(spec.title)}</text>` +
|
|
369
|
+
`<line class="grid" x1="${CHART_PAD_LEFT}" y1="${yAt(maxValue).toFixed(1)}" ` +
|
|
370
|
+
`x2="${CHART_PAD_LEFT + plotWidth}" y2="${yAt(maxValue).toFixed(1)}" />` +
|
|
371
|
+
`<line class="axis" x1="${CHART_PAD_LEFT}" y1="${baseline}" x2="${CHART_PAD_LEFT + plotWidth}" y2="${baseline}" />` +
|
|
372
|
+
`<text class="tick" x="${CHART_PAD_LEFT - 8}" y="${baseline + 3}" text-anchor="end">0</text>` +
|
|
373
|
+
`<text class="tick" x="${CHART_PAD_LEFT - 8}" y="${(yAt(maxValue) + 3).toFixed(1)}" text-anchor="end">${maxValue}</text>` +
|
|
374
|
+
`<path class="area area-${spec.series}" d="M${CHART_PAD_LEFT},${baseline} L${points.join(" L")} ` +
|
|
375
|
+
`L${xAt(last).toFixed(1)},${baseline} Z" />` +
|
|
376
|
+
`<polyline class="line line-${spec.series}" points="${points.join(" ")}" />` +
|
|
377
|
+
dots +
|
|
378
|
+
`<text class="point-label" x="${(xAt(last) - 8).toFixed(1)}" y="${(yAt(spec.values[last]) - 9).toFixed(1)}" ` +
|
|
379
|
+
`text-anchor="end">${spec.values[last]}</text>`);
|
|
380
|
+
}
|
|
381
|
+
function buildThroughputChart(stats) {
|
|
382
|
+
const plotWidth = CHART_WIDTH - CHART_PAD_LEFT - CHART_PAD_RIGHT;
|
|
383
|
+
const stepX = stats.length > 1 ? plotWidth / (stats.length - 1) : 0;
|
|
384
|
+
const xAt = (i) => CHART_PAD_LEFT + i * stepX;
|
|
385
|
+
const dayLabels = stats
|
|
386
|
+
.map((s, i) => {
|
|
387
|
+
const isToday = i === stats.length - 1;
|
|
388
|
+
const label = escapeHtml(s.day.slice(5)) + (isToday ? "*" : "");
|
|
389
|
+
const anchor = i === 0 ? "start" : isToday ? "end" : "middle";
|
|
390
|
+
return `<text class="tick" x="${xAt(i).toFixed(1)}" y="${CHART_LABEL_Y}" text-anchor="${anchor}">${label}</text>`;
|
|
391
|
+
})
|
|
392
|
+
.join("");
|
|
393
|
+
const hits = stats
|
|
394
|
+
.map((s, i) => {
|
|
395
|
+
const x = Math.max(CHART_PAD_LEFT, xAt(i) - stepX / 2);
|
|
396
|
+
const width = Math.min(stepX || plotWidth, CHART_PAD_LEFT + plotWidth - x);
|
|
397
|
+
return (`<rect class="hit" x="${x.toFixed(1)}" y="${PANEL_A_TOP - 6}" width="${width.toFixed(1)}" ` +
|
|
398
|
+
`height="${PANEL_B_TOP + PANEL_HEIGHT - PANEL_A_TOP + 12}">` +
|
|
399
|
+
`<title>${escapeHtml(s.day)} · ${s.completed} completed · ${s.backlog} backlog</title></rect>`);
|
|
400
|
+
})
|
|
401
|
+
.join("");
|
|
402
|
+
return `<svg viewBox="0 0 ${CHART_WIDTH} ${CHART_HEIGHT}" class="chart" role="img" aria-label="7-day todo throughput and backlog, bucketed by local day; the same numbers are in the table below">
|
|
403
|
+
${buildPanel({ top: PANEL_A_TOP, title: "completed that day", series: "1", values: stats.map((s) => s.completed) }, stats)}
|
|
404
|
+
${buildPanel({ top: PANEL_B_TOP, title: "open backlog at end of day", series: "2", values: stats.map((s) => s.backlog) }, stats)}
|
|
405
|
+
${dayLabels}
|
|
406
|
+
${hits}
|
|
407
|
+
</svg>`;
|
|
408
|
+
}
|
|
409
|
+
function buildThroughputTable(stats) {
|
|
410
|
+
const head = stats
|
|
411
|
+
.map((s, i) => `<th scope="col">${escapeHtml(s.day.slice(5))}${i === stats.length - 1 ? "*" : ""}</th>`)
|
|
412
|
+
.join("");
|
|
413
|
+
const completed = stats.map((s) => `<td>${s.completed}</td>`).join("");
|
|
414
|
+
const backlog = stats.map((s) => `<td>${s.backlog}</td>`).join("");
|
|
415
|
+
return (`<table class="chart-table"><caption class="sr-only">7-day completed and backlog counts by local day</caption>` +
|
|
416
|
+
`<thead><tr><th scope="col">day</th>${head}</tr></thead>` +
|
|
417
|
+
`<tbody><tr><th scope="row"><span class="swatch swatch-1"></span>completed</th>${completed}</tr>` +
|
|
418
|
+
`<tr><th scope="row"><span class="swatch swatch-2"></span>backlog</th>${backlog}</tr></tbody></table>`);
|
|
419
|
+
}
|
|
420
|
+
function renderThroughputSection(index, stats) {
|
|
421
|
+
const totalCompleted7d = stats.reduce((sum, s) => sum + s.completed, 0);
|
|
422
|
+
const currentBacklog = stats[stats.length - 1].backlog;
|
|
423
|
+
const body = `<p class="meta">Two measures on two scales, so each panel carries its own axis. ` +
|
|
424
|
+
`Bucketed by LOCAL calendar day. Today (*) is still in progress, not a full day.</p>` +
|
|
425
|
+
buildThroughputChart(stats) +
|
|
426
|
+
buildThroughputTable(stats);
|
|
427
|
+
return section(index, "throughput", "Throughput", `${totalCompleted7d} completed 7d · ${currentBacklog} backlog now`, "chart", null, body);
|
|
428
|
+
}
|
|
429
|
+
function renderWakesSection(index, projectId) {
|
|
430
|
+
const all = db
|
|
431
|
+
.prepare(`SELECT id, kind, body, owner, deliver_actor, due_at, max_wait_at, fire_count, repeat_every_ms, held_at
|
|
432
|
+
FROM wakes WHERE project_id = ? AND ${PENDING_WAKE_WHERE}
|
|
433
|
+
ORDER BY COALESCE(due_at, max_wait_at, created_at)`)
|
|
434
|
+
.all(projectId);
|
|
435
|
+
const shown = all.slice(0, WAKE_CAP);
|
|
436
|
+
const rows = shown
|
|
437
|
+
.map((w) => {
|
|
438
|
+
const truncated = truncateWithEllipsis(w.body, 160);
|
|
439
|
+
const when = w.kind === "delay"
|
|
440
|
+
? `fires ${timeEl(w.due_at)}${w.repeat_every_ms ? ` <span class="muted">(repeats)</span>` : ""}`
|
|
441
|
+
: `fires when watched agents go idle, by ${timeEl(w.max_wait_at)} at the latest`;
|
|
442
|
+
const status = statusBadge(w.held_at ? "warn" : "live", w.held_at ? "held" : "pending");
|
|
443
|
+
return (`<li class="wake"><span class="row-main">${status} ${mono(`#${w.id}`)} ` +
|
|
444
|
+
`<span class="kind">${escapeHtml(w.kind)}</span> <span class="muted">${when}</span></span>` +
|
|
445
|
+
`<span class="wake-body">${prose(truncated)}</span>` +
|
|
446
|
+
`<span class="muted row-meta">owner ${mono(w.owner)} → ${mono(w.deliver_actor)}</span></li>`);
|
|
447
|
+
})
|
|
448
|
+
.join("\n");
|
|
449
|
+
const body = renderCappedList(shown.length, all.length, "pending wakes", "wakes", rows, emptyState("No pending wakes.", "Scheduled and idle-triggered check-ins from wake_set show up here."));
|
|
450
|
+
return section(index, "wakes", "Wakes", `${all.length} pending`, "bell", String(shown.length), body);
|
|
451
|
+
}
|
|
452
|
+
function fetchCommentActivity(projectId) {
|
|
453
|
+
const rows = db
|
|
454
|
+
.prepare(`SELECT c.id, c.body, c.author, c.created_at, t.id AS todo_id, t.title AS todo_title,
|
|
455
|
+
COUNT(*) OVER () AS total_count
|
|
456
|
+
FROM todo_comments c JOIN todos t ON t.id = c.todo_id
|
|
457
|
+
WHERE t.project_id = ?
|
|
458
|
+
ORDER BY c.created_at DESC, c.id DESC LIMIT ${ACTIVITY_SOURCE_LIMIT}`)
|
|
459
|
+
.all(projectId);
|
|
460
|
+
const total = rows.length > 0 ? rows[0].total_count : 0;
|
|
461
|
+
const entries = rows.map((r) => {
|
|
462
|
+
const truncated = truncateWithEllipsis(r.body, 200);
|
|
463
|
+
return {
|
|
464
|
+
created_at: r.created_at,
|
|
465
|
+
html: `<li class="activity"><span class="row-main"><span class="tag tag-comment">comment</span> ` +
|
|
466
|
+
`${mono(r.author)} <span class="muted">on</span> ${mono(`#${r.todo_id}`)} ${prose(r.todo_title)}</span>` +
|
|
467
|
+
`<span class="muted row-meta">${timeEl(r.created_at)}</span>` +
|
|
468
|
+
`<div class="activity-body">${prose(truncated)}</div></li>`,
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
return { entries, total };
|
|
472
|
+
}
|
|
473
|
+
function fetchStateLogActivity(projectId) {
|
|
474
|
+
const rows = db
|
|
475
|
+
.prepare(`SELECT l.id, l.event, l.state, l.created_at, a.name AS agent_name,
|
|
476
|
+
COUNT(*) OVER () AS total_count
|
|
477
|
+
FROM agent_state_log l
|
|
478
|
+
JOIN agents a ON a.id = (
|
|
479
|
+
SELECT ag.id FROM agents ag WHERE ag.actor_id = l.actor_id
|
|
480
|
+
ORDER BY (ag.status = 'running') DESC, ag.id DESC LIMIT 1
|
|
481
|
+
)
|
|
482
|
+
WHERE a.project_id = ?
|
|
483
|
+
ORDER BY l.created_at DESC, l.id DESC LIMIT ${ACTIVITY_SOURCE_LIMIT}`)
|
|
484
|
+
.all(projectId);
|
|
485
|
+
const total = rows.length > 0 ? rows[0].total_count : 0;
|
|
486
|
+
const entries = rows.map((r) => ({
|
|
487
|
+
created_at: r.created_at,
|
|
488
|
+
html: `<li class="activity"><span class="row-main"><span class="tag tag-state">state</span> ` +
|
|
489
|
+
`${mono(r.agent_name)} → ${mono(r.state)} <span class="muted">(${escapeHtml(r.event)})</span></span>` +
|
|
490
|
+
`<span class="muted row-meta">${timeEl(r.created_at)}</span></li>`,
|
|
491
|
+
}));
|
|
492
|
+
return { entries, total };
|
|
493
|
+
}
|
|
494
|
+
function renderActivitySection(index, projectId) {
|
|
495
|
+
const comments = fetchCommentActivity(projectId);
|
|
496
|
+
const stateLog = fetchStateLogActivity(projectId);
|
|
497
|
+
const total = comments.total + stateLog.total;
|
|
498
|
+
const merged = [...comments.entries, ...stateLog.entries].sort((a, b) => sortKey(b.created_at).localeCompare(sortKey(a.created_at)));
|
|
499
|
+
const shown = merged.slice(0, ACTIVITY_DISPLAY_CAP);
|
|
500
|
+
const body = renderCappedList(shown.length, total, "recent activity entries", "activities", shown.map((e) => e.html).join("\n"), emptyState("No recent activity.", "Todo comments and worker state transitions stream into this list."));
|
|
501
|
+
return section(index, "activity", "Activity", `${total} recent`, "activity", String(shown.length), body);
|
|
502
|
+
}
|
|
503
|
+
function formatBytes(bytes) {
|
|
504
|
+
if (bytes < 1024)
|
|
505
|
+
return `${bytes} B`;
|
|
506
|
+
if (bytes < 1024 * 1024)
|
|
507
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
508
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
509
|
+
}
|
|
510
|
+
function slugForId(name) {
|
|
511
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
512
|
+
}
|
|
513
|
+
const PADS_LIST_SQL = `
|
|
514
|
+
SELECT name, content, revision, updated_at FROM pads
|
|
515
|
+
WHERE project_id = ? AND archived = 0 AND name != 'board'
|
|
516
|
+
ORDER BY name`;
|
|
517
|
+
function renderPadsSection(index, projectId) {
|
|
518
|
+
const pads = db.prepare(PADS_LIST_SQL).all(projectId);
|
|
519
|
+
const intro = `<p class="meta">Active pads other than "board" (shown in its own section above).</p>`;
|
|
520
|
+
const list = pads.length > 0
|
|
521
|
+
? pads
|
|
522
|
+
.map((p) => {
|
|
523
|
+
const size = formatBytes(Buffer.byteLength(p.content, "utf8"));
|
|
524
|
+
const count = `rev ${p.revision} · updated ${formatLocal(p.updated_at)} local · ${size}`;
|
|
525
|
+
return (`<details class="pad-item" id="pad-${slugForId(p.name)}">` +
|
|
526
|
+
`<summary class="row-summary"><span class="pb-label">${icon("file")}${prose(p.name)}</span>` +
|
|
527
|
+
`<span class="pb-count">${escapeHtml(count)}</span>${chevron()}</summary>` +
|
|
528
|
+
`<div class="section-body"><pre class="board">${escapeHtml(p.content)}</pre></div>` +
|
|
529
|
+
`</details>`);
|
|
530
|
+
})
|
|
531
|
+
.join("\n")
|
|
532
|
+
: emptyState("No other active pads.", "Plans, findings and lessons written with pad_write collect here.");
|
|
533
|
+
return section(index, "pads", "Pads", `${pads.length} pad${pads.length === 1 ? "" : "s"}`, "file", String(pads.length), intro + list);
|
|
534
|
+
}
|
|
535
|
+
function processStateEl(p) {
|
|
536
|
+
return p.running
|
|
537
|
+
? statusBadge("live", describeVisibility(p.visibility))
|
|
538
|
+
: statusBadge("warn", "not started");
|
|
539
|
+
}
|
|
540
|
+
function renderProcessesSection(index, procs) {
|
|
541
|
+
const { running, notStarted } = processCounts(procs);
|
|
542
|
+
const rows = procs
|
|
543
|
+
.map((p) => {
|
|
544
|
+
const when = p.startedAt ? `started ${timeEl(p.startedAt)}` : `<span class="muted">start with hive start</span>`;
|
|
545
|
+
return (`<li class="wake"><span class="row-main">${processStateEl(p)} ${mono(p.name)} ` +
|
|
546
|
+
`<span class="muted">${when}</span></span></li>`);
|
|
547
|
+
})
|
|
548
|
+
.join("\n");
|
|
549
|
+
return section(index, "processes", "Processes", `${running} running, ${notStarted} not started`, "inbox", String(running), `<ul class="rows wakes">${rows}</ul>`);
|
|
550
|
+
}
|
|
551
|
+
function capNote(shown, total, pluralNoun) {
|
|
552
|
+
if (shown >= total)
|
|
553
|
+
return "";
|
|
554
|
+
return `<p class="cap-note">${icon("info")}Showing ${shown} of ${total} ${pluralNoun} (capped).</p>`;
|
|
555
|
+
}
|
|
556
|
+
function renderCappedList(shownCount, total, pluralNoun, listClass, rowsHtml, emptyHtml, listId) {
|
|
557
|
+
return (capNote(shownCount, total, pluralNoun) +
|
|
558
|
+
(shownCount > 0
|
|
559
|
+
? `<ul class="rows ${listClass}"${listId ? ` id="${listId}"` : ""}>${rowsHtml}</ul>`
|
|
560
|
+
: emptyHtml));
|
|
561
|
+
}
|
|
562
|
+
function chevron() {
|
|
563
|
+
return `<span class="chev">${icon("chevron")}</span>`;
|
|
564
|
+
}
|
|
565
|
+
const DEFAULT_OPEN_SECTIONS = new Set(["throughput", "board", "todos"]);
|
|
566
|
+
function section(index, id, title, countText, iconName, navCount, body) {
|
|
567
|
+
index.push({ id, title, iconName, navCount });
|
|
568
|
+
const open = DEFAULT_OPEN_SECTIONS.has(id) ? " open" : "";
|
|
569
|
+
return (`<details class="section" id="section-${id}"${open}>` +
|
|
570
|
+
`<summary><span class="sec-icon">${icon(iconName)}</span>` +
|
|
571
|
+
`<span class="pb-label">${escapeHtml(title)}</span>` +
|
|
572
|
+
`<span class="pb-count">${escapeHtml(countText)}</span>${chevron()}</summary>` +
|
|
573
|
+
`<div class="section-body">${body}</div></details>`);
|
|
574
|
+
}
|
|
575
|
+
function renderSectionNav(metas) {
|
|
576
|
+
const links = metas
|
|
577
|
+
.map((m) => `<li><a class="navchip" href="#section-${m.id}" data-section="${m.id}">${icon(m.iconName)}` +
|
|
578
|
+
`<span class="navchip-label">${escapeHtml(m.title)}</span>` +
|
|
579
|
+
(m.navCount === null
|
|
580
|
+
? ""
|
|
581
|
+
: `<span class="navchip-count" id="navcount-${m.id}">${escapeHtml(m.navCount)}</span>`) +
|
|
582
|
+
`</a></li>`)
|
|
583
|
+
.join("");
|
|
584
|
+
return `<nav class="secnav" aria-label="dashboard sections"><ul>${links}</ul></nav>`;
|
|
585
|
+
}
|
|
586
|
+
const SPRITE = `<svg class="sprite" aria-hidden="true" focusable="false"><defs>
|
|
587
|
+
<symbol id="i-chevron" viewBox="0 0 16 16"><path d="M4 6.5 8 10.5l4-4"/></symbol>
|
|
588
|
+
<symbol id="i-users" viewBox="0 0 16 16"><circle cx="6" cy="5.2" r="2.4"/><path d="M1.9 13.4a4.1 4.1 0 0 1 8.2 0"/><path d="M11 3.2a2.4 2.4 0 0 1 0 4.4"/><path d="M12.1 9.6a4.1 4.1 0 0 1 2 3.8"/></symbol>
|
|
589
|
+
<symbol id="i-clock" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.2"/><path d="M8 4.4V8l2.6 1.7"/></symbol>
|
|
590
|
+
<symbol id="i-checklist" viewBox="0 0 16 16"><path d="M2.2 4.6 3.6 6l2.4-2.6"/><path d="M2.2 11.4 3.6 12.8l2.4-2.6"/><path d="M8.4 4.8h5.4M8.4 11.6h5.4"/></symbol>
|
|
591
|
+
<symbol id="i-chart" viewBox="0 0 16 16"><path d="M2.4 2.2v11.6h11.4"/><path d="M4.8 11V7.6M7.6 11V4.6M10.4 11V8.8M13.2 11V6"/></symbol>
|
|
592
|
+
<symbol id="i-bell" viewBox="0 0 16 16"><path d="M4 6.8a4 4 0 0 1 8 0c0 3 .9 4.2 1.5 4.8H2.5C3.1 11 4 9.8 4 6.8Z"/><path d="M6.6 13.6a1.6 1.6 0 0 0 2.8 0"/></symbol>
|
|
593
|
+
<symbol id="i-activity" viewBox="0 0 16 16"><path d="M1.6 8h3l1.7-4.6L9.6 12l1.5-4h3.3"/></symbol>
|
|
594
|
+
<symbol id="i-board" viewBox="0 0 16 16"><rect x="2.1" y="2.6" width="11.8" height="10.8" rx="1.6"/><path d="M2.1 6.1h11.8M6.4 6.1v7.3"/></symbol>
|
|
595
|
+
<symbol id="i-file" viewBox="0 0 16 16"><path d="M9 1.9H4.6a1.6 1.6 0 0 0-1.6 1.6v9a1.6 1.6 0 0 0 1.6 1.6h6.8a1.6 1.6 0 0 0 1.6-1.6V5.6Z"/><path d="M9 1.9v3.7h4"/></symbol>
|
|
596
|
+
<symbol id="i-search" viewBox="0 0 16 16"><circle cx="7.1" cy="7.1" r="4.6"/><path d="m10.5 10.5 3.2 3.2"/></symbol>
|
|
597
|
+
<symbol id="i-lock" viewBox="0 0 16 16"><rect x="3.2" y="7" width="9.6" height="6.6" rx="1.4"/><path d="M5.5 7V5.2a2.5 2.5 0 0 1 5 0V7"/></symbol>
|
|
598
|
+
<symbol id="i-inbox" viewBox="0 0 16 16"><path d="M1.9 9.2h3.3l1 2h3.6l1-2h3.3"/><path d="M3.6 2.9h8.8l1.7 6.3v3.3a1.6 1.6 0 0 1-1.6 1.6H3.5a1.6 1.6 0 0 1-1.6-1.6V9.2Z"/></symbol>
|
|
599
|
+
<symbol id="i-info" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.2"/><path d="M8 7.4v3.6M8 5.2v.1"/></symbol>
|
|
600
|
+
</defs></svg>`;
|
|
601
|
+
const STYLE = `
|
|
602
|
+
:root {
|
|
603
|
+
color-scheme: light dark;
|
|
604
|
+
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
605
|
+
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
606
|
+
--bg: #f5f7fa;
|
|
607
|
+
--panel: #ffffff;
|
|
608
|
+
--panel-sunken: #f1f4f8;
|
|
609
|
+
--fg: #171a1f;
|
|
610
|
+
--fg-muted: #5c6672;
|
|
611
|
+
--fg-subtle: #6e7885;
|
|
612
|
+
--border: #e4e8ee;
|
|
613
|
+
--border-strong: #d2d8e0;
|
|
614
|
+
--ok: #0f7a4d;
|
|
615
|
+
--warn: #8a5300;
|
|
616
|
+
--fail: #b4271c;
|
|
617
|
+
--live: #1f5fd0;
|
|
618
|
+
--ok-bg: #e6f4ec;
|
|
619
|
+
--warn-bg: #fbf0dd;
|
|
620
|
+
--fail-bg: #fceceb;
|
|
621
|
+
--live-bg: #e8f0fd;
|
|
622
|
+
--accent: #1f5fd0;
|
|
623
|
+
--focus: #1f5fd0;
|
|
624
|
+
--series-1: #2a78d6;
|
|
625
|
+
--series-2: #eb6834;
|
|
626
|
+
--grid: #e4e8ee;
|
|
627
|
+
--hover: #f1f4f8;
|
|
628
|
+
--hit: #171a1f;
|
|
629
|
+
--shadow: 0 1px 2px rgba(19, 26, 38, 0.06), 0 2px 8px rgba(19, 26, 38, 0.05);
|
|
630
|
+
--card-border: transparent;
|
|
631
|
+
--r-card: 14px;
|
|
632
|
+
--r-ctl: 9px;
|
|
633
|
+
--r-pill: 999px;
|
|
634
|
+
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
|
635
|
+
--header-offset: 7.5rem;
|
|
636
|
+
}
|
|
637
|
+
@media (prefers-color-scheme: dark) {
|
|
638
|
+
:root {
|
|
639
|
+
--bg: #101317;
|
|
640
|
+
--panel: #171b21;
|
|
641
|
+
--panel-sunken: #1c2129;
|
|
642
|
+
--fg: #e8ebef;
|
|
643
|
+
--fg-muted: #9aa5b1;
|
|
644
|
+
--fg-subtle: #8b95a1;
|
|
645
|
+
--border: #262c35;
|
|
646
|
+
--border-strong: #333b46;
|
|
647
|
+
--ok: #4cc98d;
|
|
648
|
+
--warn: #e0a548;
|
|
649
|
+
--fail: #f0736a;
|
|
650
|
+
--live: #6aa6ee;
|
|
651
|
+
--ok-bg: #14291f;
|
|
652
|
+
--warn-bg: #2c2213;
|
|
653
|
+
--fail-bg: #2e1a19;
|
|
654
|
+
--live-bg: #16233a;
|
|
655
|
+
--accent: #6aa6ee;
|
|
656
|
+
--focus: #6aa6ee;
|
|
657
|
+
--series-1: #3987e5;
|
|
658
|
+
--series-2: #d95926;
|
|
659
|
+
--grid: #262c35;
|
|
660
|
+
--hover: #1c2129;
|
|
661
|
+
--hit: #e8ebef;
|
|
662
|
+
--shadow: none;
|
|
663
|
+
--card-border: #262c35;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
668
|
+
[hidden] { display: none !important; }
|
|
669
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
670
|
+
body {
|
|
671
|
+
font-family: var(--font-sans);
|
|
672
|
+
font-size: 0.9375rem;
|
|
673
|
+
line-height: 1.5;
|
|
674
|
+
margin: 0;
|
|
675
|
+
padding: 0 0 4rem;
|
|
676
|
+
background: var(--bg);
|
|
677
|
+
color: var(--fg);
|
|
678
|
+
accent-color: var(--accent);
|
|
679
|
+
}
|
|
680
|
+
::selection { background: var(--live-bg); color: var(--fg); }
|
|
681
|
+
svg.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
|
682
|
+
.icon { width: 1em; height: 1em; flex: 0 0 auto; fill: none; stroke: currentColor;
|
|
683
|
+
stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
|
|
684
|
+
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
685
|
+
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
686
|
+
.mono { font-family: var(--font-mono); font-size: 0.9em; font-variant-numeric: tabular-nums; }
|
|
687
|
+
.prose { overflow-wrap: anywhere; }
|
|
688
|
+
|
|
689
|
+
.skip { position: absolute; left: -999px; top: 0; z-index: 10;
|
|
690
|
+
background: var(--panel); color: var(--fg); padding: 0.6rem 0.9rem; border-radius: var(--r-ctl); }
|
|
691
|
+
.skip:focus { left: 0.75rem; top: 0.75rem; outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
692
|
+
|
|
693
|
+
.wrap { max-width: 76rem; margin: 0 auto; padding: 0 1.25rem; }
|
|
694
|
+
|
|
695
|
+
.topbar { position: sticky; top: 0; z-index: 5; background: var(--bg);
|
|
696
|
+
border-bottom: 1px solid var(--border); }
|
|
697
|
+
.topbar-inner { display: flex; align-items: center; justify-content: space-between;
|
|
698
|
+
gap: 1rem; flex-wrap: wrap; padding: 0.85rem 0 0.7rem; }
|
|
699
|
+
.brand { display: flex; align-items: center; gap: 0.7rem; min-width: 0; }
|
|
700
|
+
.mark { width: 1.75rem; height: 1.75rem; flex: 0 0 auto; color: var(--accent); }
|
|
701
|
+
h1 { font-size: 1.0625rem; font-weight: 650; letter-spacing: -0.011em; margin: 0; }
|
|
702
|
+
.generated { color: var(--fg-muted); font-size: 0.78125rem; margin: 0.1rem 0 0; }
|
|
703
|
+
|
|
704
|
+
.secnav { overflow-x: auto; scrollbar-width: none; }
|
|
705
|
+
.secnav::-webkit-scrollbar { display: none; }
|
|
706
|
+
.secnav ul { display: flex; gap: 0.3rem; list-style: none; margin: 0; padding: 0 0 0.6rem; }
|
|
707
|
+
.secnav li { border: 0; padding: 0; }
|
|
708
|
+
.navchip { display: inline-flex; align-items: center; gap: 0.35rem; white-space: nowrap;
|
|
709
|
+
text-decoration: none; color: var(--fg-muted); font-size: 0.8125rem; font-weight: 500;
|
|
710
|
+
padding: 0.32rem 0.62rem; border-radius: var(--r-pill); background: transparent;
|
|
711
|
+
transition: background-color 130ms ease, color 130ms ease; }
|
|
712
|
+
.navchip .icon { font-size: 0.9375rem; opacity: 0.8; }
|
|
713
|
+
.navchip-count { font-family: var(--font-mono); font-size: 0.75rem; font-variant-numeric: tabular-nums;
|
|
714
|
+
color: var(--fg-subtle); background: var(--panel-sunken); border-radius: var(--r-pill);
|
|
715
|
+
padding: 0 0.36rem; min-width: 1.35rem; text-align: center; }
|
|
716
|
+
.navchip[aria-current="true"] { background: var(--live-bg); color: var(--accent); }
|
|
717
|
+
.navchip[aria-current="true"] .navchip-count { background: var(--panel); color: var(--accent); }
|
|
718
|
+
.navchip:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
719
|
+
|
|
720
|
+
.live-toggle { display: inline-flex; align-items: center; gap: 0.45rem; cursor: pointer;
|
|
721
|
+
font-size: 0.8125rem; font-weight: 500; color: var(--fg-muted); flex: 0 0 auto; user-select: none; }
|
|
722
|
+
.live-toggle input[type="checkbox"] { appearance: none; -webkit-appearance: none; width: 2.15rem;
|
|
723
|
+
height: 1.2rem; background: var(--border-strong); border-radius: var(--r-pill); position: relative;
|
|
724
|
+
margin: 0; cursor: pointer; transition: background-color 160ms var(--ease-out); }
|
|
725
|
+
.live-toggle input[type="checkbox"]::before { content: ""; position: absolute; top: 2px; left: 2px;
|
|
726
|
+
width: 1rem; height: 1rem; border-radius: 50%; background: #ffffff;
|
|
727
|
+
box-shadow: 0 1px 2px rgba(19, 26, 38, 0.28);
|
|
728
|
+
transition: transform 160ms var(--ease-out); }
|
|
729
|
+
.live-toggle input[type="checkbox"]:checked { background: var(--accent); }
|
|
730
|
+
.live-toggle input[type="checkbox"]:checked::before { transform: translateX(0.95rem); }
|
|
731
|
+
.live-toggle input[type="checkbox"]:active::before { width: 1.15rem; }
|
|
732
|
+
.live-toggle input[type="checkbox"]:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
733
|
+
.live-note { display: flex; align-items: baseline; gap: 0.4rem; color: var(--fg-subtle);
|
|
734
|
+
font-size: 0.78125rem; margin: 0.9rem 0 0; }
|
|
735
|
+
|
|
736
|
+
.card { background: var(--panel); border: 1px solid var(--card-border); border-radius: var(--r-card);
|
|
737
|
+
box-shadow: var(--shadow); }
|
|
738
|
+
|
|
739
|
+
.now { display: grid; gap: 0.75rem; margin: 0.9rem 0 1rem; align-items: stretch;
|
|
740
|
+
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); }
|
|
741
|
+
.stat { padding: 0.9rem 1rem 1rem; display: flex; flex-direction: column; min-width: 0; }
|
|
742
|
+
.stat-label { display: flex; align-items: center; gap: 0.4rem; margin: 0 0 0.5rem;
|
|
743
|
+
font-size: 0.71875rem; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase;
|
|
744
|
+
color: var(--fg-subtle); }
|
|
745
|
+
.stat-figure { font-size: 1.75rem; line-height: 1.1; font-weight: 620; letter-spacing: -0.022em;
|
|
746
|
+
margin: 0; font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 0.4rem; }
|
|
747
|
+
.stat-figure .time { font-size: 1.75rem; }
|
|
748
|
+
.stat-unit { font-size: 0.78125rem; font-weight: 500; letter-spacing: 0; color: var(--fg-subtle);
|
|
749
|
+
text-transform: none; }
|
|
750
|
+
.stat-sub { color: var(--fg-muted); font-size: 0.8125rem; margin: 0.35rem 0 0; overflow-wrap: anywhere; }
|
|
751
|
+
.stat-empty { color: var(--fg-subtle); font-size: 0.8125rem; margin: 0.35rem 0 0; }
|
|
752
|
+
.stat-none { color: var(--fg-subtle); }
|
|
753
|
+
.stat-workers { list-style: none; margin: 0.5rem 0 0; padding: 0; display: flex;
|
|
754
|
+
flex-direction: column; gap: 0.5rem; max-height: 13rem; overflow: auto;
|
|
755
|
+
overscroll-behavior: contain; }
|
|
756
|
+
.worker { display: flex; flex-direction: column; gap: 0.1rem; padding: 0; border: 0;
|
|
757
|
+
font-size: 0.8125rem; min-width: 0; }
|
|
758
|
+
.worker + .worker { border-top: 1px solid var(--border); padding-top: 0.5rem; }
|
|
759
|
+
.worker-head { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; min-width: 0; }
|
|
760
|
+
.worker-head .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
761
|
+
.worker-meta { color: var(--fg-subtle); font-size: 0.75rem; }
|
|
762
|
+
svg.pulse { width: 100%; height: 2.75rem; margin-top: auto; padding-top: 0.6rem; display: block; }
|
|
763
|
+
.pulse-bar { fill: var(--series-1); }
|
|
764
|
+
.pulse-none { fill: var(--border-strong); }
|
|
765
|
+
|
|
766
|
+
.section { margin: 0 0 0.6rem; background: var(--panel); border: 1px solid var(--card-border);
|
|
767
|
+
border-radius: var(--r-card); box-shadow: var(--shadow);
|
|
768
|
+
scroll-margin-top: calc(var(--header-offset) + 0.75rem); }
|
|
769
|
+
summary { cursor: pointer; list-style: none; }
|
|
770
|
+
summary::-webkit-details-marker { display: none; }
|
|
771
|
+
.section > summary { display: flex; align-items: center; gap: 0.55rem; padding: 0.85rem 1rem;
|
|
772
|
+
border-radius: var(--r-card); font-size: 0.9375rem;
|
|
773
|
+
transition: background-color 130ms ease; }
|
|
774
|
+
.sec-icon { display: inline-flex; color: var(--accent); font-size: 1.0625rem; }
|
|
775
|
+
.section > summary .pb-label { font-weight: 600; letter-spacing: -0.006em; }
|
|
776
|
+
.pb-count { color: var(--fg-subtle); font-size: 0.78125rem; font-variant-numeric: tabular-nums;
|
|
777
|
+
margin-left: auto; text-align: right; }
|
|
778
|
+
.chev { display: inline-flex; color: var(--fg-subtle); font-size: 1rem; margin-left: 0.15rem;
|
|
779
|
+
transition: transform 180ms var(--ease-out); }
|
|
780
|
+
details[open] > summary .chev { transform: rotate(180deg); }
|
|
781
|
+
summary:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; border-radius: var(--r-ctl); }
|
|
782
|
+
.section-body { padding: 0 1rem 1rem; }
|
|
783
|
+
.section > .section-body { border-top: 1px solid var(--border); padding-top: 0.9rem; margin-top: -0.15rem; }
|
|
784
|
+
|
|
785
|
+
.row-summary { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.6rem;
|
|
786
|
+
border-radius: var(--r-ctl); font-size: 0.875rem;
|
|
787
|
+
transition: background-color 130ms ease; }
|
|
788
|
+
.row-summary .pb-label { display: flex; align-items: center; gap: 0.42rem; flex-wrap: wrap;
|
|
789
|
+
min-width: 0; }
|
|
790
|
+
.row-summary .icon { color: var(--fg-subtle); }
|
|
791
|
+
details.pad-item, details.todo-item { margin: 0; }
|
|
792
|
+
details.pad-item > .section-body, details.todo-item > .section-body { padding: 0.1rem 0.6rem 0.7rem 0.6rem; }
|
|
793
|
+
.todo-body { overflow-wrap: anywhere; white-space: pre-wrap; color: var(--fg-muted);
|
|
794
|
+
font-size: 0.875rem; }
|
|
795
|
+
.todo-body + .todo-body { margin-top: 0.55rem; }
|
|
796
|
+
|
|
797
|
+
.meta, .muted { color: var(--fg-muted); }
|
|
798
|
+
.meta { font-size: 0.8125rem; margin: 0 0 0.75rem; max-width: 72ch; }
|
|
799
|
+
.muted { font-size: 0.8125rem; }
|
|
800
|
+
.cap-note { display: flex; align-items: center; gap: 0.4rem; color: var(--fg-subtle);
|
|
801
|
+
font-size: 0.78125rem; margin: 0 0 0.6rem; }
|
|
802
|
+
pre.board { white-space: pre; overflow: auto; max-height: 60vh; background: var(--panel-sunken);
|
|
803
|
+
border: 1px solid var(--border); border-radius: var(--r-ctl); padding: 0.85rem;
|
|
804
|
+
font-family: var(--font-mono); font-size: 0.8125rem; line-height: 1.5; margin: 0;
|
|
805
|
+
overscroll-behavior: contain; }
|
|
806
|
+
|
|
807
|
+
ul.rows { list-style: none; margin: 0; padding: 0; }
|
|
808
|
+
ul.rows > li { padding: 0.35rem 0; border-bottom: 1px solid var(--border);
|
|
809
|
+
display: flex; flex-direction: column; gap: 0.15rem; }
|
|
810
|
+
ul.rows > li:last-child { border-bottom: none; }
|
|
811
|
+
ul.rows > li.todo, ul.rows > li:has(> details) { padding: 0.1rem 0; }
|
|
812
|
+
.row-main { display: flex; align-items: center; gap: 0.42rem; flex-wrap: wrap; }
|
|
813
|
+
.row-meta { font-size: 0.78125rem; color: var(--fg-subtle); }
|
|
814
|
+
.kind { color: var(--fg-subtle); font-size: 0.78125rem; }
|
|
815
|
+
|
|
816
|
+
.status { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.71875rem;
|
|
817
|
+
font-weight: 600; letter-spacing: 0.01em; padding: 0.12rem 0.5rem; border-radius: var(--r-pill);
|
|
818
|
+
white-space: nowrap; }
|
|
819
|
+
.status::before { content: ""; width: 0.4rem; height: 0.4rem; border-radius: 50%;
|
|
820
|
+
background: currentColor; flex: 0 0 auto; }
|
|
821
|
+
.status-ok { color: var(--ok); background: var(--ok-bg); }
|
|
822
|
+
.status-warn { color: var(--warn); background: var(--warn-bg); }
|
|
823
|
+
.status-fail { color: var(--fail); background: var(--fail-bg); }
|
|
824
|
+
.status-live { color: var(--live); background: var(--live-bg); }
|
|
825
|
+
|
|
826
|
+
.chip { display: inline-flex; align-items: center; gap: 0.28rem; font-size: 0.71875rem;
|
|
827
|
+
font-weight: 600; padding: 0.12rem 0.48rem; border-radius: var(--r-pill); white-space: nowrap; }
|
|
828
|
+
.chip-blocked { color: var(--warn); background: var(--warn-bg); }
|
|
829
|
+
.chip-blocked .icon { stroke-width: 1.6; }
|
|
830
|
+
|
|
831
|
+
.tag { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase;
|
|
832
|
+
padding: 0.1rem 0.4rem; border-radius: 5px; background: var(--panel-sunken); color: var(--fg-subtle); }
|
|
833
|
+
|
|
834
|
+
.priority { font-size: 0.6875rem; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
835
|
+
.priority-high { color: var(--fail); }
|
|
836
|
+
.priority-medium { color: var(--fg-muted); }
|
|
837
|
+
.priority-low { color: var(--fg-subtle); }
|
|
838
|
+
.blockers { font-size: 0.8125rem; color: var(--fg-muted); margin: 0 0 0.45rem 0.6rem; }
|
|
839
|
+
.wake-body { color: var(--fg); font-size: 0.875rem; overflow-wrap: anywhere; }
|
|
840
|
+
.activity-body { font-size: 0.8125rem; color: var(--fg-muted); white-space: pre-wrap;
|
|
841
|
+
overflow-wrap: anywhere; margin-top: 0.1rem; }
|
|
842
|
+
|
|
843
|
+
.filter { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
|
844
|
+
.filter-field { display: flex; align-items: center; gap: 0.45rem; flex: 1 1 18rem;
|
|
845
|
+
background: var(--panel-sunken); border: 1px solid var(--border); border-radius: var(--r-ctl);
|
|
846
|
+
padding: 0.4rem 0.65rem; color: var(--fg-subtle);
|
|
847
|
+
transition: border-color 130ms ease, background-color 130ms ease; }
|
|
848
|
+
.filter-field:focus-within { border-color: var(--accent); background: var(--panel); }
|
|
849
|
+
.filter-field input { flex: 1 1 auto; min-width: 0; border: 0; background: transparent;
|
|
850
|
+
font: inherit; font-size: 0.875rem; color: var(--fg); outline: none; padding: 0; }
|
|
851
|
+
.filter-field input::placeholder { color: var(--fg-subtle); }
|
|
852
|
+
.filter-field input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
|
|
853
|
+
.filter-count { color: var(--fg-subtle); font-size: 0.78125rem; font-variant-numeric: tabular-nums;
|
|
854
|
+
margin: 0; flex: 0 0 auto; }
|
|
855
|
+
.filter-none { color: var(--fg-muted); font-size: 0.875rem; margin: 0.5rem 0; }
|
|
856
|
+
|
|
857
|
+
.empty { text-align: center; padding: 1.75rem 1rem 1.85rem; color: var(--fg-muted); }
|
|
858
|
+
.empty .icon { font-size: 1.5rem; color: var(--fg-subtle); stroke-width: 1.2; }
|
|
859
|
+
.empty-title { margin: 0.55rem 0 0.2rem; font-weight: 600; color: var(--fg); font-size: 0.9375rem; }
|
|
860
|
+
.empty-hint { margin: 0; font-size: 0.8125rem; color: var(--fg-subtle); max-width: 42ch;
|
|
861
|
+
margin-inline: auto; }
|
|
862
|
+
|
|
863
|
+
svg.chart { width: 100%; height: auto; display: block; margin: 0 0 0.9rem;
|
|
864
|
+
color: var(--fg-muted); }
|
|
865
|
+
.chart .panel-title { font-size: 13px; font-weight: 600; fill: var(--fg-muted);
|
|
866
|
+
font-family: var(--font-sans); letter-spacing: 0.01em; }
|
|
867
|
+
.chart .tick { font-size: 12px; fill: var(--fg-subtle); font-family: var(--font-sans);
|
|
868
|
+
font-variant-numeric: tabular-nums; }
|
|
869
|
+
.chart .point-label { font-size: 13px; font-weight: 600; fill: var(--fg-muted);
|
|
870
|
+
font-family: var(--font-sans); font-variant-numeric: tabular-nums; }
|
|
871
|
+
.chart .grid { stroke: var(--grid); stroke-dasharray: 3 4; }
|
|
872
|
+
.chart .axis { stroke: var(--border-strong); }
|
|
873
|
+
.line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
874
|
+
.line-1 { stroke: var(--series-1); }
|
|
875
|
+
.line-2 { stroke: var(--series-2); }
|
|
876
|
+
.area { stroke: none; }
|
|
877
|
+
.area-1 { fill: var(--series-1); fill-opacity: 0.13; }
|
|
878
|
+
.area-2 { fill: var(--series-2); fill-opacity: 0.13; }
|
|
879
|
+
.dot { stroke: var(--panel); stroke-width: 2; }
|
|
880
|
+
.dot-1 { fill: var(--series-1); }
|
|
881
|
+
.dot-2 { fill: var(--series-2); }
|
|
882
|
+
.hit { fill: var(--hit); fill-opacity: 0; }
|
|
883
|
+
.hit:hover { fill-opacity: 0.045; }
|
|
884
|
+
|
|
885
|
+
.chart-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem;
|
|
886
|
+
font-variant-numeric: tabular-nums; }
|
|
887
|
+
.chart-table th, .chart-table td { padding: 0.35rem 0.4rem; text-align: right;
|
|
888
|
+
border-bottom: 1px solid var(--border); }
|
|
889
|
+
.chart-table thead th { color: var(--fg-subtle); font-weight: 600; font-size: 0.75rem; }
|
|
890
|
+
.chart-table thead th:first-child { text-align: left; }
|
|
891
|
+
.chart-table tbody th { text-align: left; font-weight: 500; color: var(--fg-muted); }
|
|
892
|
+
.chart-table tr:last-child th, .chart-table tr:last-child td { border-bottom: 0; }
|
|
893
|
+
.swatch { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 3px;
|
|
894
|
+
margin-right: 0.4rem; vertical-align: baseline; }
|
|
895
|
+
.swatch-1 { background: var(--series-1); }
|
|
896
|
+
.swatch-2 { background: var(--series-2); }
|
|
897
|
+
|
|
898
|
+
@media (hover: hover) and (pointer: fine) {
|
|
899
|
+
.section > summary:hover { background: var(--hover); }
|
|
900
|
+
.row-summary:hover { background: var(--hover); }
|
|
901
|
+
.navchip:hover { background: var(--hover); color: var(--fg); }
|
|
902
|
+
}
|
|
903
|
+
.section > summary:active, .row-summary:active { transform: scale(0.995); }
|
|
904
|
+
.navchip:active { transform: scale(0.97); }
|
|
905
|
+
|
|
906
|
+
@media (max-width: 40rem) {
|
|
907
|
+
.wrap { padding: 0 0.9rem; }
|
|
908
|
+
.now { grid-template-columns: 1fr; }
|
|
909
|
+
.pb-count { font-size: 0.75rem; }
|
|
910
|
+
.navchip-label { position: absolute; width: 1px; height: 1px; padding: 0;
|
|
911
|
+
margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
@media (prefers-reduced-motion: reduce) {
|
|
915
|
+
* { transition: none !important; animation: none !important; }
|
|
916
|
+
.section > summary:active, .row-summary:active, .navchip:active { transform: none; }
|
|
917
|
+
}
|
|
918
|
+
`;
|
|
919
|
+
const SCRIPT = `
|
|
920
|
+
(function () {
|
|
921
|
+
var KEY = "hive-dashboard-state";
|
|
922
|
+
function load() {
|
|
923
|
+
try { return JSON.parse(sessionStorage.getItem(KEY) || "{}"); } catch (e) { return {}; }
|
|
924
|
+
}
|
|
925
|
+
function save(state) {
|
|
926
|
+
try { sessionStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {}
|
|
927
|
+
}
|
|
928
|
+
var state = load();
|
|
929
|
+
// A toggle the SCRIPT caused is not the reader's choice. The flag is
|
|
930
|
+
// one-shot and set only when the forced value differs, so it cannot leak.
|
|
931
|
+
var suppressPersist = {};
|
|
932
|
+
document.querySelectorAll("details[id]").forEach(function (el) {
|
|
933
|
+
var stored = state.sections && state.sections[el.id];
|
|
934
|
+
if (stored !== undefined) el.open = stored;
|
|
935
|
+
el.addEventListener("toggle", function () {
|
|
936
|
+
if (suppressPersist[el.id]) { delete suppressPersist[el.id]; return; }
|
|
937
|
+
var s = load();
|
|
938
|
+
s.sections = s.sections || {};
|
|
939
|
+
s.sections[el.id] = el.open;
|
|
940
|
+
save(s);
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// Client-side todo filter. sessionStorage, never localStorage: Chrome treats
|
|
945
|
+
// file:// as an opaque origin and localStorage is unreliable there.
|
|
946
|
+
var filterInput = document.getElementById("todo-filter");
|
|
947
|
+
var filterCount = document.getElementById("todo-filter-count");
|
|
948
|
+
var filterNone = document.getElementById("todo-filter-none");
|
|
949
|
+
var todoList = document.getElementById("todo-list");
|
|
950
|
+
var todoNavCount = document.getElementById("navcount-todos");
|
|
951
|
+
var todoRows = todoList ? todoList.querySelectorAll("li.todo") : [];
|
|
952
|
+
var todoText = [];
|
|
953
|
+
for (var t = 0; t < todoRows.length; t++) {
|
|
954
|
+
todoText.push((todoRows[t].textContent || "").toLowerCase().replace(/\\s+/g, " "));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function applyFilter(query) {
|
|
958
|
+
var needle = String(query || "").trim().toLowerCase();
|
|
959
|
+
var shown = 0;
|
|
960
|
+
for (var i = 0; i < todoRows.length; i++) {
|
|
961
|
+
var hit = needle === "" || todoText[i].indexOf(needle) !== -1;
|
|
962
|
+
todoRows[i].hidden = !hit;
|
|
963
|
+
if (hit) shown++;
|
|
964
|
+
}
|
|
965
|
+
if (filterCount) {
|
|
966
|
+
filterCount.textContent = needle === ""
|
|
967
|
+
? todoRows.length + " shown"
|
|
968
|
+
: shown + " of " + todoRows.length + " shown";
|
|
969
|
+
}
|
|
970
|
+
if (filterNone) filterNone.hidden = !(needle !== "" && shown === 0);
|
|
971
|
+
if (todoNavCount) todoNavCount.textContent = String(shown);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (filterInput) {
|
|
975
|
+
var storedFilter = typeof state.filter === "string" ? state.filter : "";
|
|
976
|
+
filterInput.value = storedFilter;
|
|
977
|
+
applyFilter(storedFilter);
|
|
978
|
+
if (storedFilter !== "") {
|
|
979
|
+
var todosSection = document.getElementById("section-todos");
|
|
980
|
+
if (todosSection && !todosSection.open) {
|
|
981
|
+
suppressPersist["section-todos"] = true;
|
|
982
|
+
todosSection.open = true;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// Restore the caret only if the box actually had focus when the page went
|
|
986
|
+
// away, so a reload never steals focus from wherever the reader is.
|
|
987
|
+
if (state.filterFocused && filterInput.focus) {
|
|
988
|
+
try {
|
|
989
|
+
filterInput.focus({ preventScroll: true });
|
|
990
|
+
if (filterInput.setSelectionRange) {
|
|
991
|
+
var caret = typeof state.filterCaret === "number" ? state.filterCaret : storedFilter.length;
|
|
992
|
+
filterInput.setSelectionRange(caret, caret);
|
|
993
|
+
}
|
|
994
|
+
} catch (e) {}
|
|
995
|
+
}
|
|
996
|
+
filterInput.addEventListener("input", function () {
|
|
997
|
+
var s = load();
|
|
998
|
+
s.filter = filterInput.value;
|
|
999
|
+
s.filterCaret = filterInput.selectionStart;
|
|
1000
|
+
save(s);
|
|
1001
|
+
applyFilter(filterInput.value);
|
|
1002
|
+
deferReload();
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// The sticky header wraps at narrow widths, so its height is measured, not
|
|
1007
|
+
// guessed. One number, read by scroll-margin-top and by paintNav alike.
|
|
1008
|
+
var headerOffset = 120;
|
|
1009
|
+
function measureHeader() {
|
|
1010
|
+
var bar = document.getElementById("topbar");
|
|
1011
|
+
if (!bar || !bar.getBoundingClientRect) return;
|
|
1012
|
+
headerOffset = Math.round(bar.getBoundingClientRect().height);
|
|
1013
|
+
var root = document.documentElement;
|
|
1014
|
+
if (root && root.style) root.style.setProperty("--header-offset", headerOffset + "px");
|
|
1015
|
+
}
|
|
1016
|
+
measureHeader();
|
|
1017
|
+
|
|
1018
|
+
// Section nav: mark the section the reader is currently in.
|
|
1019
|
+
var navLinks = document.querySelectorAll(".navchip");
|
|
1020
|
+
function paintNav() {
|
|
1021
|
+
var best = null;
|
|
1022
|
+
for (var i = 0; i < navLinks.length; i++) {
|
|
1023
|
+
var target = document.getElementById("section-" + navLinks[i].getAttribute("data-section"));
|
|
1024
|
+
if (target && target.getBoundingClientRect().top <= headerOffset + 20) best = navLinks[i];
|
|
1025
|
+
}
|
|
1026
|
+
for (var j = 0; j < navLinks.length; j++) {
|
|
1027
|
+
if (navLinks[j] === best) navLinks[j].setAttribute("aria-current", "true");
|
|
1028
|
+
else navLinks[j].removeAttribute("aria-current");
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (navLinks.length > 0) {
|
|
1032
|
+
paintNav();
|
|
1033
|
+
var ticking = false;
|
|
1034
|
+
window.addEventListener("scroll", function () {
|
|
1035
|
+
if (ticking) return;
|
|
1036
|
+
ticking = true;
|
|
1037
|
+
requestAnimationFrame(function () { paintNav(); ticking = false; });
|
|
1038
|
+
}, { passive: true });
|
|
1039
|
+
window.addEventListener("resize", function () { measureHeader(); paintNav(); });
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (typeof state.scrollY === "number") window.scrollTo(0, state.scrollY);
|
|
1043
|
+
|
|
1044
|
+
// Live toggle. Replaces <meta http-equiv="refresh">, which the browser
|
|
1045
|
+
// schedules at PARSE TIME - removing the tag afterward does not cancel it,
|
|
1046
|
+
// so a toggle could never turn it off. A setTimeout can be cleared, so this
|
|
1047
|
+
// is a timer instead, stored in a variable for exactly that.
|
|
1048
|
+
// location.reload() is a NAVIGATION, not a fetch(), so it still works from
|
|
1049
|
+
// file:// - the fetch() restriction that forced meta refresh in the first
|
|
1050
|
+
// place does not apply to it.
|
|
1051
|
+
var reloadTimer = null;
|
|
1052
|
+
function armReload() {
|
|
1053
|
+
if (reloadTimer !== null) return;
|
|
1054
|
+
reloadTimer = setTimeout(function () { location.reload(); }, 10000);
|
|
1055
|
+
}
|
|
1056
|
+
function clearReload() {
|
|
1057
|
+
if (reloadTimer !== null) { clearTimeout(reloadTimer); reloadTimer = null; }
|
|
1058
|
+
}
|
|
1059
|
+
// Typing restarts the 10s rather than suspending it, so "refreshes every
|
|
1060
|
+
// 10s" stays true - it is measured from the last keystroke, not from load.
|
|
1061
|
+
function deferReload() {
|
|
1062
|
+
if (!live) return;
|
|
1063
|
+
clearReload();
|
|
1064
|
+
armReload();
|
|
1065
|
+
}
|
|
1066
|
+
var toggle = document.getElementById("live-toggle");
|
|
1067
|
+
var stamp = document.getElementById("generated-stamp");
|
|
1068
|
+
var stampTime = stamp ? stamp.getAttribute("data-time") : "";
|
|
1069
|
+
function paintStamp(live) {
|
|
1070
|
+
if (!stamp) return;
|
|
1071
|
+
stamp.textContent = live
|
|
1072
|
+
? "generated " + stampTime + " local, refreshes every 10s. Read-only: nothing here writes back to the store."
|
|
1073
|
+
: "paused, generated " + stampTime + " local. Read-only: nothing here writes back to the store.";
|
|
1074
|
+
}
|
|
1075
|
+
var live = typeof state.live === "boolean" ? state.live : true;
|
|
1076
|
+
if (toggle) toggle.checked = live;
|
|
1077
|
+
paintStamp(live);
|
|
1078
|
+
if (live) armReload(); else clearReload();
|
|
1079
|
+
if (toggle) {
|
|
1080
|
+
toggle.addEventListener("change", function () {
|
|
1081
|
+
var s = load();
|
|
1082
|
+
s.live = toggle.checked;
|
|
1083
|
+
save(s);
|
|
1084
|
+
paintStamp(toggle.checked);
|
|
1085
|
+
if (toggle.checked) armReload(); else clearReload();
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
window.addEventListener("beforeunload", function () {
|
|
1090
|
+
var s = load();
|
|
1091
|
+
s.scrollY = window.scrollY;
|
|
1092
|
+
if (filterInput) {
|
|
1093
|
+
s.filterFocused = document.activeElement === filterInput;
|
|
1094
|
+
s.filterCaret = filterInput.selectionStart;
|
|
1095
|
+
}
|
|
1096
|
+
save(s);
|
|
1097
|
+
});
|
|
1098
|
+
})();
|
|
1099
|
+
`;
|
|
1100
|
+
const BRAND_MARK = `<svg class="mark" viewBox="0 0 32 32" role="img" aria-label="hive">
|
|
1101
|
+
<path d="M16 3.2 27 9.6v12.8L16 28.8 5 22.4V9.6Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
|
|
1102
|
+
<path d="M16 11 21 14v6l-5 3-5-3v-6Z" fill="currentColor" fill-opacity="0.22" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
|
|
1103
|
+
</svg>`;
|
|
1104
|
+
function buildDashboard(projectId, procs) {
|
|
1105
|
+
const project = fetchProject(projectId);
|
|
1106
|
+
const stats = fetchDayStats(projectId);
|
|
1107
|
+
const index = [];
|
|
1108
|
+
const cards = [
|
|
1109
|
+
renderThroughputSection(index, stats),
|
|
1110
|
+
renderBoardSection(index, projectId),
|
|
1111
|
+
renderTodosSection(index, projectId),
|
|
1112
|
+
...(procs.length > 0 ? [renderProcessesSection(index, procs)] : []),
|
|
1113
|
+
renderPadsSection(index, projectId),
|
|
1114
|
+
renderWakesSection(index, projectId),
|
|
1115
|
+
renderActivitySection(index, projectId),
|
|
1116
|
+
];
|
|
1117
|
+
const nav = renderSectionNav(index);
|
|
1118
|
+
const sections = [renderNowStrip(projectId, procs), ...cards].join("\n");
|
|
1119
|
+
const generatedLocal = formatDate(new Date());
|
|
1120
|
+
const html = `<!doctype html>
|
|
1121
|
+
<html lang="en">
|
|
1122
|
+
<head>
|
|
1123
|
+
<meta charset="utf-8">
|
|
1124
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1125
|
+
<title>hive dashboard - ${escapeHtml(project.name)}</title>
|
|
1126
|
+
<style>${STYLE}</style>
|
|
1127
|
+
</head>
|
|
1128
|
+
<body>
|
|
1129
|
+
${SPRITE}
|
|
1130
|
+
<a class="skip" href="#main">Skip to content</a>
|
|
1131
|
+
<header class="topbar" id="topbar">
|
|
1132
|
+
<div class="wrap">
|
|
1133
|
+
<div class="topbar-inner">
|
|
1134
|
+
<div class="brand">${BRAND_MARK}<div><h1>${escapeHtml(project.name)}</h1>
|
|
1135
|
+
<p class="generated" id="generated-stamp" data-time="${escapeHtml(generatedLocal)}">generated ${escapeHtml(generatedLocal)} local, refreshes every 10s. Read-only: nothing here writes back to the store.</p></div></div>
|
|
1136
|
+
<label class="live-toggle"><input type="checkbox" id="live-toggle" role="switch" checked><span>Live</span></label>
|
|
1137
|
+
</div>
|
|
1138
|
+
${nav}
|
|
1139
|
+
</div>
|
|
1140
|
+
</header>
|
|
1141
|
+
<main id="main" class="wrap">
|
|
1142
|
+
<p class="live-note">${icon("info")}Turning Live off stops this page from reloading; the scheduler keeps the file itself up to date either way - a browser page cannot signal it back without a server.</p>
|
|
1143
|
+
${sections}
|
|
1144
|
+
</main>
|
|
1145
|
+
<script>${SCRIPT}</script>
|
|
1146
|
+
</body>
|
|
1147
|
+
</html>
|
|
1148
|
+
`;
|
|
1149
|
+
return { projectName: project.name, sections, html };
|
|
1150
|
+
}
|
|
1151
|
+
export function renderDashboard(projectId, procs = []) {
|
|
1152
|
+
return buildDashboard(projectId, procs).html;
|
|
1153
|
+
}
|
|
1154
|
+
export function renderDashboardForWrite(projectId, procs = []) {
|
|
1155
|
+
const { projectName, sections, html } = buildDashboard(projectId, procs);
|
|
1156
|
+
const contentHash = createHash("sha256").update(projectName).update("\0").update(sections).digest("hex");
|
|
1157
|
+
return { html, contentHash };
|
|
1158
|
+
}
|