@caddis/cli 0.4.59 → 0.4.61
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +4 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +446 -143
- package/bundles/antigravity-plugin/scripts/roadmap_assets/OFL-JetBrainsMono.txt +93 -0
- package/bundles/antigravity-plugin/scripts/roadmap_assets/OFL-MapleMono.txt +93 -0
- package/bundles/antigravity-plugin/scripts/roadmap_assets/fonts.css +5 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
`caddis_roadmap.py render --roadmap <slug> [--out <file>]` writes it (default `~/.caddis/roadmap-pages/<repo>/<slug>.html`,
|
|
4
4
|
outside every checkout). Plan: `.caddis/plans/roadmap-page.md` (owner decisions, 17 Oct).
|
|
5
5
|
|
|
6
|
-
The hub: each default render
|
|
7
|
-
`~/.caddis/roadmap-pages/index.html` from every summary on this machine
|
|
8
|
-
the
|
|
6
|
+
The hub: each default render writes `<slug>.body.html` (the page body) and `<slug>.json` (one summary row), and
|
|
7
|
+
rebuilds `~/.caddis/roadmap-pages/index.html` from every summary on this machine. `index.html` is the whole app: the
|
|
8
|
+
inbox, the gates, sessions and runners views, and one view per roadmap (its body). CSS shows one view at a time from
|
|
9
|
+
the URL's `#view`, so there is one nav and it is never stale. `<slug>.html` is a one-line redirect to its view. It
|
|
10
|
+
works as a local file and behind a static web server.
|
|
9
11
|
|
|
10
12
|
Sections, in order: Waiting for you (sessions that wait for input, and open owner-gate blockers), Plan on a page (a
|
|
11
13
|
swimlane: plans as lanes, milestones as columns, one tile per phase; open by default), then collapsed sections:
|
|
@@ -13,7 +15,8 @@ summary, now / next / later, gates and blockers, every milestone with its phase
|
|
|
13
15
|
|
|
14
16
|
The data is `status()` (roadmap.md and each plan's Tracker on origin), the plan headers (`### Phase <n> - <name>`,
|
|
15
17
|
`**Model:**`, `**Lane:**`, `**Risk:**`), the runner's local lock and last stop, and the local session-state folder
|
|
16
|
-
(`<sid>.waiting` markers from the Notification hook). No
|
|
18
|
+
(`<sid>.waiting` markers from the Notification hook). No network, and one script only (id theme-toggle: the light/dark
|
|
19
|
+
toggle, the hub's inbox reload, the phone menu and `aria-current`): collapsing uses `<details>`. Every text
|
|
17
20
|
from the files is escaped: a plan, a relay or a hook message can hold any text.
|
|
18
21
|
"""
|
|
19
22
|
from __future__ import annotations
|
|
@@ -28,7 +31,6 @@ import re
|
|
|
28
31
|
import subprocess
|
|
29
32
|
import sys
|
|
30
33
|
import time
|
|
31
|
-
import urllib.parse
|
|
32
34
|
from pathlib import Path
|
|
33
35
|
|
|
34
36
|
PHASE_HEADER = re.compile(r"^###\s+Phase\s+(\d+)[a-z]?\s*[-–—:]\s*(.+?)\s*(?:[✅⏳\U0001F532].*)?$",
|
|
@@ -213,23 +215,22 @@ def build(root: Path, slug: str, roadmap_mod, now: datetime.datetime | None = No
|
|
|
213
215
|
|
|
214
216
|
# ---- rendering -------------------------------------------------------------------------------------------------
|
|
215
217
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
--accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;
|
|
219
|
-
--done:#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
--block-fg:#fcd34d;--high:#f87171;--wait-bg:#450a0a;--wait-fg:#fca5a5}}
|
|
218
|
+
# One colour set for every page: light = slate + blue, dark = Dracula (owner, 17 Oct): https://draculatheme.com/contribute
|
|
219
|
+
# Dark applies when the OS asks for it, unless the reader picked light; the toggle sets data-theme on <html>.
|
|
220
|
+
_PALETTE_LIGHT = '--bg:#f4f6f9;--panel:#fff;--panel2:#f8fafc;--fg:#0f172a;--muted:#475569;--line:#e2e8f0;--line2:#cbd5e1;--accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;--done:#16a34a;--done-bg:#dcfce7;--done-fg:#15803d;--block:#d97706;--block-bg:#fef3c7;--block-fg:#92400e;--high:#dc2626;--wait-bg:#fee2e2;--wait-fg:#b91c1c;--nav:#f8fafc;--nav-fg:#0f172a;--on-accent:#fff;--shadow:0 1px 3px rgba(15,23,42,.06);--title:var(--fg);--summary:var(--fg);--code:var(--fg);--c0:#2563eb;--c1:#7c3aed;--c2:#0d9488;--c3:#ea580c;--c4:#db2777;--c5:#475569;--sans:"Maple Mono","Segoe UI Variable","Segoe UI",system-ui,sans-serif;--mono:"JetBrains Mono","Cascadia Mono",Consolas,ui-monospace,monospace;color-scheme:light;'
|
|
221
|
+
_PALETTE_DARK = '--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#6272a4;--line:#44475a;--line2:#6272a4;--accent:#8be9fd;--todo-bg:#44475a;--todo-fg:#f8f8f2;--prog:#bd93f9;--prog-bg:#3c3352;--prog-fg:#bd93f9;--done:#50fa7b;--done-bg:#2b4a36;--done-fg:#50fa7b;--block:#ffb86c;--block-bg:#4a3a2b;--block-fg:#ffb86c;--high:#ff5555;--wait-bg:#4a2b33;--wait-fg:#ff5555;--nav:#21222c;--nav-fg:#f8f8f2;--on-accent:#282a36;--shadow:none;--title:#bd93f9;--summary:#ff79c6;--code:#f1fa8c;--c0:#8be9fd;--c1:#bd93f9;--c2:#50fa7b;--c3:#ffb86c;--c4:#ff79c6;--c5:#6272a4;color-scheme:dark;'
|
|
222
|
+
_THEME_CSS = (":root{" + _PALETTE_LIGHT + "}" + '@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){'
|
|
223
|
+
+ _PALETTE_DARK + "}}" + ':root[data-theme="dark"]{' + _PALETTE_DARK + "}")
|
|
224
|
+
|
|
225
|
+
_BASE_CSS = """
|
|
225
226
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
|
|
226
|
-
font:14px/1.5
|
|
227
|
+
font:14px/1.5 var(--sans)}code{font:12px var(--mono);color:var(--code)}
|
|
227
228
|
main{max-width:1360px;margin:0 auto;padding:20px 16px 48px;display:grid;gap:16px}main>*{min-width:0}
|
|
228
|
-
h1{font-size:24px;margin:0}h2{font-size:16px;margin:0}.sub{color:var(--muted);margin:4px 0 0}
|
|
229
|
+
h1{font-size:24px;margin:0;color:var(--title)}h2{font-size:16px;margin:0}.sub{color:var(--muted);margin:4px 0 0}
|
|
229
230
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
|
|
230
|
-
details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none}
|
|
231
|
+
details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none;color:var(--summary)}
|
|
231
232
|
details.card>summary::-webkit-details-marker{display:none}details.card>summary::before{content:"\\25B8 ";color:var(--muted)}
|
|
232
|
-
details.card[open]>summary::before{content:"\\25BE "}details.card
|
|
233
|
+
details.card[open]>summary::before{content:"\\25BE "}details.card+details.card{margin-top:8px}
|
|
233
234
|
.wait{border-color:var(--high)}.wait h2{color:var(--wait-fg)}.empty{color:var(--muted)}
|
|
234
235
|
table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:7px 9px;border-top:1px solid var(--line);
|
|
235
236
|
vertical-align:top}th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border-top:0}
|
|
@@ -238,20 +239,72 @@ padding:1px 8px;border-radius:999px;white-space:nowrap}
|
|
|
238
239
|
.s-todo{background:var(--todo-bg);color:var(--todo-fg)}.s-in-progress{background:var(--prog-bg);color:var(--prog-fg)}
|
|
239
240
|
.s-done,.s-moved,.s-not-needed{background:var(--done-bg);color:var(--done-fg)}.s-blocked{background:var(--block-bg);color:var(--block-fg)}
|
|
240
241
|
.s-code-done\\/data-pending{background:var(--block-bg);color:var(--block-fg)}
|
|
241
|
-
.lanes{display:grid
|
|
242
|
+
.lanes{display:grid}.lanes>div{border-top:1px solid var(--line);border-left:1px dashed var(--line);
|
|
242
243
|
padding:6px}.lanes .hd{background:var(--panel2);border-top:0;font-weight:700}.lanes .ln{position:sticky;left:0;z-index:1;
|
|
243
|
-
background:var(--panel);border-left:
|
|
244
|
-
.tile{display:flex;gap:
|
|
245
|
-
border:1px solid var(--line2);background:var(--panel);max-width:100%}.tile b{font:600 11px
|
|
246
|
-
.tile span{
|
|
244
|
+
background:var(--panel);border-left:4px solid var(--lc,var(--line2));border-right:1px solid var(--line2);font-weight:600;overflow-wrap:anywhere}
|
|
245
|
+
.tile{display:flex;gap:5px;align-items:center;font-size:12px;padding:3px 5px;position:relative;margin:0 0 4px;border-radius:5px;
|
|
246
|
+
border:1px solid var(--line2);background:var(--panel);max-width:100%}.tile b{font:600 11px var(--mono)}
|
|
247
|
+
.tile span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal;overflow-wrap:anywhere}.tile.in-progress{background:var(--prog-bg);
|
|
247
248
|
border-color:var(--prog);color:var(--prog-fg)}.tile.done{background:var(--done-bg);border-color:var(--done);
|
|
248
249
|
color:var(--done-fg)}.tile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);
|
|
249
250
|
border-color:var(--done)}.tile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
|
|
251
|
+
.tile.hi::after{position:absolute;top:3px;right:3px;margin:0}
|
|
252
|
+
.ltile{display:flex;gap:8px;align-items:baseline;font-size:13px;padding:5px 8px;margin:0 0 4px;border-radius:6px;border:1px solid var(--line2);background:var(--panel)}.ltile b{font:600 11px var(--mono);white-space:nowrap}
|
|
253
|
+
.ltile.in-progress{background:var(--prog-bg);border-color:var(--prog);color:var(--prog-fg)}.ltile.done{background:var(--done-bg);border-color:var(--done);color:var(--done-fg)}
|
|
254
|
+
.ltile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);border-color:var(--done)}.ltile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
|
|
255
|
+
.bcount{display:inline-block;font-size:11px;font-weight:700;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;padding:0 6px;margin-top:3px}
|
|
256
|
+
.lane-list{display:none}.lane-list h3{margin:10px 0 6px}@media (max-width:900px){.timeline .scroll{display:none}.lane-list{display:block}}
|
|
250
257
|
.hi::after{content:"";width:7px;height:7px;border-radius:50%;background:var(--high);flex:none;margin-left:auto}
|
|
251
258
|
.gate{font-size:11px;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;
|
|
252
259
|
padding:3px 6px;margin:2px 0;white-space:normal;overflow-wrap:anywhere}.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--muted);margin-top:8px}
|
|
253
260
|
.cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}@media (max-width:820px){.cols{grid-template-columns:1fr}}
|
|
254
261
|
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.kpi b{font-size:22px;display:block}
|
|
262
|
+
.bar{height:8px;border-radius:99px;background:var(--todo-bg);overflow:hidden;min-width:120px;margin:4px 0}
|
|
263
|
+
.bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
|
|
264
|
+
.theme-btn{font:600 12px var(--sans);padding:6px 11px;border-radius:999px;border:1px solid var(--line);background:var(--panel2);color:var(--fg);cursor:pointer}
|
|
265
|
+
.hdr{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
|
266
|
+
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.chip{border:1px solid var(--line);border-left:4px solid var(--lc);border-radius:8px;padding:5px 10px;background:var(--panel2);font-size:12.5px}.chip span{color:var(--muted);font-family:var(--mono)}
|
|
267
|
+
.flow{display:flex;gap:6px;align-items:stretch;min-width:max-content}.fnode{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);text-decoration:none;color:inherit;min-width:120px}.fnode span{font:11px var(--mono);color:var(--muted)}.fnode span.pill{font:700 11px var(--sans);justify-self:start}.arrow{align-self:center;color:var(--muted)}
|
|
268
|
+
.kind{font-size:10.5px;font-weight:700;text-transform:uppercase;padding:2px 8px;border-radius:6px;background:var(--block-bg);color:var(--block-fg)}.k-owner-gate{background:var(--wait-bg);color:var(--wait-fg)}
|
|
269
|
+
.note{color:var(--muted);font-weight:400;font-size:13px}.sec-h{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline;margin-bottom:8px}h3{font-size:14px;margin:0 0 8px}
|
|
270
|
+
.cols .rcard{margin-bottom:8px}.cols .rcard .bar{margin:0}
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
_CSS = _THEME_CSS + _BASE_CSS
|
|
274
|
+
|
|
275
|
+
# The one script on a page: the reader's theme choice; on the hub only, a reload every 60 s while the inbox shows
|
|
276
|
+
# (an open roadmap view is never reset); the phone menu closes when the view changes; and the nav item of the
|
|
277
|
+
# shown view gets aria-current. No network.
|
|
278
|
+
_THEME_SCRIPT = ('<script id="theme-toggle">(function(){var k="caddis-roadmap-theme",d=document.documentElement;try{var t=localStorage.getItem(k);if(t==="light"||t==="dark")d.dataset.theme=t}catch(e){}window.caddisTheme=function(){var c=d.dataset.theme||(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),n=c==="dark"?"light":"dark";d.dataset.theme=n;try{localStorage.setItem(k,n)}catch(e){}};function shown(){var h=location.hash.slice(1),e=h&&document.getElementById(h),v=e&&e.closest(".view");return v?v.id:"inbox"}function cur(){var a=document.querySelectorAll(".nav a.nav-i");for(var i=0;i<a.length;i++){if(a[i].getAttribute("href")==="#"+shown())a[i].setAttribute("aria-current","page");else a[i].removeAttribute("aria-current")}}addEventListener("DOMContentLoaded",cur);setInterval(function(){if(document.getElementById("inbox")&&document.visibilityState==="visible"&&shown()==="inbox")location.reload()},60000);addEventListener("hashchange",function(){var b=document.getElementById("nav-cb");if(b)b.checked=false;cur()})})();</script>')
|
|
279
|
+
_THEME_BUTTON = '<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark theme">◐ Theme</button>'
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _fonts_link(base: str) -> str:
|
|
283
|
+
"""The one allowed <link>: bundled fonts. If it does not load, the font stacks fall back to system fonts."""
|
|
284
|
+
return f'<link rel="stylesheet" href="{_e(base)}_assets/fonts.css">'
|
|
285
|
+
|
|
286
|
+
_SHELL_CSS = """
|
|
287
|
+
.app{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:100vh}
|
|
288
|
+
.nav{background:var(--nav);color:var(--nav-fg);border-right:1px solid var(--line);padding:14px 10px;position:sticky;top:0;height:100vh;overflow-y:auto}
|
|
289
|
+
.brand{display:block;font-weight:700;font-size:15px;color:var(--title);text-decoration:none;padding:4px 8px 12px}
|
|
290
|
+
.nav-h{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:14px 8px 4px}
|
|
291
|
+
.nav-repo{font-size:12px;font-weight:600;color:var(--muted);margin:10px 8px 2px}
|
|
292
|
+
.nav-i{display:flex;justify-content:space-between;gap:8px;padding:6px 8px;border-radius:7px;color:inherit;text-decoration:none;font-size:13px}
|
|
293
|
+
.nav-i:hover,.nav-i:focus-visible{background:var(--panel2);outline:none}
|
|
294
|
+
.nav-i .n{font-family:var(--mono);font-size:11px;color:var(--muted)}.nav-rm{padding-left:16px}
|
|
295
|
+
.nav-sub{display:grid;padding:2px 0 6px 28px}.nav-sub a{font-size:12px;color:var(--muted);text-decoration:none;padding:3px 0}
|
|
296
|
+
.main{min-width:0}.top{display:flex;align-items:center;gap:12px;padding:10px 20px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:5}
|
|
297
|
+
.crumbs{flex:1;min-width:0;font-size:13px;color:var(--muted)}.crumbs a{color:var(--accent);text-decoration:none}
|
|
298
|
+
main [id]{scroll-margin-top:60px}main>.view{scroll-margin-top:200px}
|
|
299
|
+
.view>*+*{margin-top:16px}.view{display:none}.view:target,.view:has(:target){display:block}main:not(:has(.view:target,.view :target))>#inbox{display:block}
|
|
300
|
+
.nav-sub{display:none}.view-crumbs{font-size:13px;color:var(--muted);margin:0 0 8px}
|
|
301
|
+
.nav-cb{display:none}.nav-btn,.nav-close,.nav-scrim{display:none}
|
|
302
|
+
.nav-cb:focus-visible~.app .nav-btn{outline:2px solid var(--accent);outline-offset:2px}
|
|
303
|
+
@media (max-width:900px){.app{grid-template-columns:1fr}.nav-cb{display:block;position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}.nav{position:fixed;left:0;top:0;z-index:20;width:260px;transform:translateX(-100%);transition:transform .2s}.nav-cb:checked~.app .nav{transform:none}.nav-btn{display:inline-block;cursor:pointer;font-size:18px;padding:0 6px}.nav-close{display:block;text-align:right;cursor:pointer;font-size:18px;padding:2px 8px}.nav-cb:checked~.app .nav-scrim{display:block;position:fixed;inset:0;z-index:15;background:rgba(0,0,0,.4)}}
|
|
304
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
|
|
305
|
+
.rcard{display:grid;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;color:inherit;text-decoration:none;box-shadow:var(--shadow)}.rcard:hover,.rcard:focus-visible{border-color:var(--accent);outline:none}
|
|
306
|
+
.dots{display:flex;flex-wrap:wrap;gap:4px}.dot{font:600 10.5px var(--mono);padding:1px 6px;border-radius:5px}
|
|
307
|
+
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:var(--shadow)}
|
|
255
308
|
"""
|
|
256
309
|
|
|
257
310
|
_LABEL = {"todo": "to do", "in-progress": "in progress", "done": "done", "moved": "moved", "blocked": "blocked"}
|
|
@@ -265,8 +318,100 @@ def _pill(status: str) -> str:
|
|
|
265
318
|
return f'<span class="pill s-{_e(status)}">{_e(_LABEL.get(status, status))}</span>'
|
|
266
319
|
|
|
267
320
|
|
|
321
|
+
def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = "") -> str:
|
|
322
|
+
"""The app frame around every page: head, left nav, top bar (crumbs and the theme button) and the body.
|
|
323
|
+
`nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title` is plain text."""
|
|
324
|
+
return "".join([
|
|
325
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
326
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
327
|
+
f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{_THEME_SCRIPT}</head>",
|
|
328
|
+
f'<body><input type="checkbox" id="nav-cb" class="nav-cb" aria-label="Toggle the navigation menu"><div class="app">{nav}<label for="nav-cb" class="nav-scrim" aria-hidden="true"></label><div class="main">',
|
|
329
|
+
'<header class="top"><label for="nav-cb" class="nav-btn" aria-label="Open the menu">☰</label>',
|
|
330
|
+
f'<div class="crumbs">{crumbs}</div>{_THEME_BUTTON}</header>',
|
|
331
|
+
f"<main>{body}</main></div></div></body></html>\n"])
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _crumbs(folder: str, slug: str) -> str:
|
|
335
|
+
"""`Roadmaps › <repo> › <slug>` for a roadmap page; the repo is the folder name without its `-<6hex>` tag."""
|
|
336
|
+
repo = re.sub(r"-[0-9a-f]{6}$", "", folder or "")
|
|
337
|
+
mid = f"{_e(repo)} › " if repo else ""
|
|
338
|
+
return f'<a href="../index.html">Roadmaps</a> › {mid}<strong>{_e(slug)}</strong>'
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _sessions_of(group: list[dict]) -> list[dict]:
|
|
342
|
+
"""The newest summary of a checkout has its newest session marks."""
|
|
343
|
+
return max(group, key=lambda r: str(r.get("generated_utc", ""))).get("sessions") or []
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _group_rows(rows: list[dict]) -> tuple[list[tuple[str, list[dict]]], list[tuple[str, list, list]], int]:
|
|
347
|
+
"""(order, need, count): the summaries per repo folder in repo-name order; per repo the asking sessions and the
|
|
348
|
+
owner gates (repo, asking, gates), only for repos that have any; and how many items need the owner."""
|
|
349
|
+
by_repo: dict[str, list[dict]] = {}
|
|
350
|
+
for r in rows:
|
|
351
|
+
by_repo.setdefault(r["folder"], []).append(r)
|
|
352
|
+
order = sorted(by_repo.items(), key=lambda kv: (str(kv[1][0].get("repo", "")).lower(), kv[0]))
|
|
353
|
+
need = []
|
|
354
|
+
for _folder, group in order:
|
|
355
|
+
asking = [s for s in _sessions_of(group) if s.get("asking")]
|
|
356
|
+
gates = [(r, g) for r in group for g in r.get("owner_gates") or []]
|
|
357
|
+
if asking or gates:
|
|
358
|
+
need.append((group[0].get("repo"), asking, gates))
|
|
359
|
+
count = sum(len(a) + len(g) for _, a, g in need)
|
|
360
|
+
return order, need, count
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _nav(rows: list[dict], base: str) -> str:
|
|
364
|
+
"""The left nav: Needs you (inbox, gates, sessions, runners), then every repo with its roadmaps. Every link opens a
|
|
365
|
+
view of `index.html` (`#inbox`, `#<rid>`, ...); on the hub itself (`base` "") the link is the bare `#view`. The
|
|
366
|
+
active item and its sub-links are shown by `_view_css`, so the nav needs no per-page copy."""
|
|
367
|
+
order, _need, count = _group_rows(rows)
|
|
368
|
+
counts = {"inbox": count, "gates": sum(len(r.get("blockers") or r.get("owner_gates") or []) for r in rows),
|
|
369
|
+
"sessions": sum(len(_sessions_of(group)) for _f, group in order),
|
|
370
|
+
"runners": sum(1 for r in rows if r.get("runner") == "running")}
|
|
371
|
+
hub = f"{base}index.html" if base else ""
|
|
372
|
+
out = ['<nav class="nav" aria-label="Roadmaps">'
|
|
373
|
+
'<label for="nav-cb" class="nav-close" aria-label="Close the menu">✕</label>',
|
|
374
|
+
f'<a class="brand" href="{_e(hub or "#inbox")}">caddis · Roadmaps</a>', '<p class="nav-h">Needs you</p>']
|
|
375
|
+
for key, label in (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")):
|
|
376
|
+
out.append(f'<a class="nav-i" href="{_e(hub)}#{key}">{_e(label)}<span class="n">{_e(counts[key])}</span></a>')
|
|
377
|
+
out.append('<p class="nav-h">Repos</p>')
|
|
378
|
+
for folder, group in order:
|
|
379
|
+
idle = sum(1 for s in _sessions_of(group) if not s.get("asking"))
|
|
380
|
+
idle_txt = f" · {idle} idle session(s)" if idle else ""
|
|
381
|
+
out.append(f'<p class="nav-repo">{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
|
|
382
|
+
for r in sorted(group, key=lambda r: str(r["slug"])):
|
|
383
|
+
rid = _rid(folder, r["slug"])
|
|
384
|
+
if rid is None:
|
|
385
|
+
continue
|
|
386
|
+
total = _int(r.get("phases_total"))
|
|
387
|
+
pct = int(round(100 * _int(r.get("phases_done")) / total)) if total else 0
|
|
388
|
+
out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}">{_e(r["slug"])}<span class="n">{pct}%</span></a>'
|
|
389
|
+
f'<div class="nav-sub" data-rid="{rid}">'
|
|
390
|
+
+ "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS)
|
|
391
|
+
+ "</div>")
|
|
392
|
+
out.append("</nav>")
|
|
393
|
+
return "".join(out)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
_ON = "{background:var(--prog-bg);color:var(--prog-fg);font-weight:600}"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _view_css(rids: list[str]) -> str:
|
|
400
|
+
"""Marks the nav item of the view that shows, and opens its sub-links. One rule per roadmap: CSS cannot take
|
|
401
|
+
the target's id into a selector. Every rid comes from `_rid` (hex and slug characters only), so it is safe
|
|
402
|
+
in a selector."""
|
|
403
|
+
shown = ".view:target,.view :target"
|
|
404
|
+
out = [f'body:not(:has({shown})) .nav a[href="#inbox"]{_ON}']
|
|
405
|
+
for key in ("inbox", "gates", "sessions", "runners"):
|
|
406
|
+
out.append(f'body:has(#{key}:target,#{key} :target) .nav a[href="#{key}"]{_ON}')
|
|
407
|
+
for rid in rids:
|
|
408
|
+
on = f"body:has(#{rid}:target,#{rid} :target)"
|
|
409
|
+
out.append(f'{on} .nav a[href="#{rid}"]{_ON}{on} .nav-sub[data-rid="{rid}"]{{display:grid}}')
|
|
410
|
+
return "".join(out)
|
|
411
|
+
|
|
412
|
+
|
|
268
413
|
def _waiting_section(model: dict) -> str:
|
|
269
|
-
out = ['<section class="card wait"><h2>Waiting for you</h2>']
|
|
414
|
+
out = ['<section id="waiting" class="card wait"><h2>Waiting for you</h2>']
|
|
270
415
|
sessions, gates = model["sessions"], model["waiting"]
|
|
271
416
|
if not sessions and not gates:
|
|
272
417
|
out.append('<p class="empty">Nothing is waiting for you.</p></section>')
|
|
@@ -296,20 +441,24 @@ def _waiting_section(model: dict) -> str:
|
|
|
296
441
|
def _swimlane(model: dict) -> str:
|
|
297
442
|
milestones, lanes = model["milestones"], model["lanes"]
|
|
298
443
|
cols = len(milestones)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
444
|
+
# The section has the class `timeline` as well as the id: the hub prefixes every id, so the phone CSS selects by class.
|
|
445
|
+
out = ['<section class="card timeline" id="timeline"><div class="sec-h"><h2>Timeline</h2><span class="note">plans as '
|
|
446
|
+
'lanes, milestones as columns in roadmap order, one tile per phase</span></div><div class="scroll">',
|
|
447
|
+
f'<div class="lanes" style="grid-template-columns:150px repeat({cols},minmax(110px,1fr));width:100%;'
|
|
448
|
+
f'min-width:{150 + cols * 110}px">',
|
|
302
449
|
'<div class="hd ln">Plan / milestone</div>']
|
|
303
450
|
for ms in milestones:
|
|
304
451
|
dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
|
|
305
|
-
|
|
452
|
+
n = len(ms["blockers"])
|
|
453
|
+
count = f' <span class="bcount">{n} gate(s)</span>' if n else ""
|
|
306
454
|
out.append(f'<div class="hd">{_e(ms["id"])} {_pill(ms["status"])}<div class="empty" style="font-weight:400;'
|
|
307
|
-
f'font-size:11px">{_e(dep)}
|
|
308
|
-
|
|
455
|
+
f'font-size:11px">{_e(dep)}</div>{count}</div>')
|
|
456
|
+
plan_lanes = lanes or ["-"]
|
|
457
|
+
for i, lane in enumerate(plan_lanes):
|
|
309
458
|
done = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane and ph["tile"] in ("done", "moved"))
|
|
310
459
|
total = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane)
|
|
311
|
-
out.append(f'<div class="ln">{_e(lane)}<div class="empty"
|
|
312
|
-
f'{done}/{total} done</div></div>')
|
|
460
|
+
out.append(f'<div class="ln" style="--lc:var(--c{i % 6})">{_e(lane)}<div class="empty" '
|
|
461
|
+
f'style="font-weight:400;font-size:11px">{done}/{total} done</div></div>')
|
|
313
462
|
for ms in milestones:
|
|
314
463
|
tiles = []
|
|
315
464
|
for ph in ms["phases"]:
|
|
@@ -320,8 +469,24 @@ def _swimlane(model: dict) -> str:
|
|
|
320
469
|
tiles.append(f'<div class="tile {_e(ph["tile"])}{hi}" title="{_e(title)}"><b>{_e(ph["num"])}</b>'
|
|
321
470
|
f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
|
|
322
471
|
out.append(f"<div>{''.join(tiles)}</div>")
|
|
323
|
-
|
|
324
|
-
|
|
472
|
+
if any(ms["blockers"] for ms in milestones): # the blockers get their own lane, so the column headers stay short
|
|
473
|
+
out.append('<div class="ln">Owner and outside</div>')
|
|
474
|
+
for ms in milestones:
|
|
475
|
+
cells = []
|
|
476
|
+
for b in ms["blockers"]:
|
|
477
|
+
reason = str(b["reason"])
|
|
478
|
+
short = reason if len(reason) <= 60 else reason[:60] + "…"
|
|
479
|
+
cells.append(f'<div class="gate" title="{_e(reason)}">{_e(b["type"])}: {_e(short)}</div>')
|
|
480
|
+
out.append(f"<div>{''.join(cells)}</div>")
|
|
481
|
+
out.append('</div></div><div class="lane-list">')
|
|
482
|
+
for ms in milestones: # the phone list: the grid is hidden on a narrow screen
|
|
483
|
+
out.append(f'<h3>{_e(ms["id"])} {_pill(ms["status"])}</h3>')
|
|
484
|
+
for ph in ms["phases"]:
|
|
485
|
+
hi = " hi" if "high" in ph.get("risk", "").lower() else ""
|
|
486
|
+
out.append(f'<div class="ltile {_e(ph["tile"])}{hi}"><b>{_e(ph["plan"])} {_e(ph["num"])}</b>'
|
|
487
|
+
f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
|
|
488
|
+
out.append('</div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
|
|
489
|
+
'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></section>')
|
|
325
490
|
return "".join(out)
|
|
326
491
|
|
|
327
492
|
|
|
@@ -332,14 +497,29 @@ def _summary(model: dict) -> str:
|
|
|
332
497
|
high = sum(1 for ph in phases if "high" in ph.get("risk", "").lower())
|
|
333
498
|
nxt = model.get("next") or {}
|
|
334
499
|
nxt_txt = f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "none ready"
|
|
335
|
-
return ('<
|
|
336
|
-
'<div class="kpis">'
|
|
500
|
+
return ('<section class="kpis" id="overview" aria-label="Summary">'
|
|
337
501
|
f'<div class="kpi"><b>{_e(done)} / {_e(len(phases))}</b>phases done</div>'
|
|
338
502
|
f'<div class="kpi"><b>{_e(model["done"])} / {_e(model["total"])}</b>milestones done</div>'
|
|
339
503
|
f'<div class="kpi"><b>{_e(len(building))}</b>in progress: {_e(", ".join(building) or "none")}</div>'
|
|
340
504
|
f'<div class="kpi"><b>{_e(high)}</b>high-risk phases</div>'
|
|
341
505
|
f'<div class="kpi"><b>{_e(nxt_txt)}</b>next batch for the runner</div>'
|
|
342
|
-
"</
|
|
506
|
+
"</section>")
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _done_phases(ms: dict) -> int:
|
|
510
|
+
return sum(1 for ph in ms["phases"] if ph["tile"] in ("done", "moved"))
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _flow(model: dict) -> str:
|
|
514
|
+
"""The milestones as a left-to-right chain in roadmap order; each node links to its milestone below."""
|
|
515
|
+
nodes = []
|
|
516
|
+
for ms in model["milestones"]:
|
|
517
|
+
dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
|
|
518
|
+
nodes.append(f'<a class="fnode s-{_e(ms["status"])}" href="#ms-{_e(ms["id"])}"><b>{_e(ms["id"])}</b> '
|
|
519
|
+
f'{_pill(ms["status"])}<span>{_e(dep)} · {_e(_done_phases(ms))}/{_e(len(ms["phases"]))} ph</span></a>')
|
|
520
|
+
chain = '<span class="arrow" aria-hidden="true">→</span>'.join(nodes)
|
|
521
|
+
return (f'<section id="flow"><div class="sec-h"><h2>Flow</h2><span class="note">milestones in roadmap order</span></div>'
|
|
522
|
+
f'<div class="card scroll"><div class="flow">{chain}</div></div></section>')
|
|
343
523
|
|
|
344
524
|
|
|
345
525
|
def _now_next_later(model: dict) -> str:
|
|
@@ -358,26 +538,30 @@ def _now_next_later(model: dict) -> str:
|
|
|
358
538
|
groups["Later"].append(ms)
|
|
359
539
|
cols = []
|
|
360
540
|
for title, items in groups.items():
|
|
361
|
-
body = "".join(f'<
|
|
362
|
-
f'{
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
541
|
+
body = "".join(f'<a class="rcard" href="#ms-{_e(m["id"])}"><span><strong>{_e(m["id"])}</strong> {_pill(m["status"])}</span>'
|
|
542
|
+
f'{_bar(_done_phases(m), len(m["phases"]))}'
|
|
543
|
+
+ "".join(f'<span class="note">{_e(b["type"])}</span>' for b in m["blockers"])
|
|
544
|
+
+ "</a>" for m in items)
|
|
545
|
+
cols.append(f'<div><h3>{_e(title)}</h3>{body or "<p class=empty>none</p>"}</div>')
|
|
546
|
+
return ('<section id="board"><div class="sec-h"><h2>Now, next, later</h2>'
|
|
547
|
+
'<span class="note">which milestones can start</span></div>'
|
|
548
|
+
f'<div class="cols">{"".join(cols)}</div></section>')
|
|
367
549
|
|
|
368
550
|
|
|
369
551
|
def _gates(model: dict) -> str:
|
|
370
552
|
rows = [(ms["id"], b) for ms in model["milestones"] for b in ms["blockers"]]
|
|
371
|
-
body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td
|
|
553
|
+
body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td>"
|
|
554
|
+
f"<td><span class=\"kind k-{_e(b['type'])}\">{_e(b['type'])}</span></td><td>{_e(b['reason'])}</td>"
|
|
372
555
|
f"<td><code>{_e(b['check'])}</code></td></tr>" for mid, b in rows)
|
|
373
|
-
return (f'<
|
|
556
|
+
return (f'<section class="card" id="gates"><div class="sec-h"><h2>Gates and blockers</h2>'
|
|
557
|
+
f'<span class="note">{_e(len(rows))} open</span></div>'
|
|
374
558
|
+ (f'<div class="scroll"><table><tr><th>Milestone</th><th>Type</th><th>Reason</th><th>Check</th></tr>{body}'
|
|
375
|
-
"</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</
|
|
559
|
+
"</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</section>")
|
|
376
560
|
|
|
377
561
|
|
|
378
562
|
def _milestones(model: dict) -> str:
|
|
379
|
-
out = ['<
|
|
380
|
-
'Tracker status</span></
|
|
563
|
+
out = ['<section id="milestones"><div class="sec-h"><h2>Milestones and phases</h2><span class="note">every phase with '
|
|
564
|
+
'model, lane, risk and Tracker status</span></div>']
|
|
381
565
|
for ms in model["milestones"]:
|
|
382
566
|
rows = "".join(
|
|
383
567
|
f"<tr><td><code>{_e(ph['ref'])}</code></td><td>{_e(ph['name'])}</td><td>{_e(_short_model(ph['model']))}</td>"
|
|
@@ -385,13 +569,13 @@ def _milestones(model: dict) -> str:
|
|
|
385
569
|
f"<td>{_e(ph['risk'] or '-')}</td><td>{_pill(ph['tile'])} {_e(ph['tracker'])}</td></tr>"
|
|
386
570
|
for ph in ms["phases"])
|
|
387
571
|
target = f" · target {_e(ms['target'])}" if ms.get("target") else ""
|
|
388
|
-
out.append(f
|
|
389
|
-
f"<span class
|
|
390
|
-
f
|
|
391
|
-
|
|
572
|
+
out.append(f'<details class="card" id="ms-{_e(ms["id"])}"><summary>{_e(ms["id"])} '
|
|
573
|
+
f'{_pill(ms["status"])} <span class="note">owner {_e(ms["owner"])}{target} · '
|
|
574
|
+
f'after {_e(", ".join(ms["depends"]) or "-")}</span></summary>'
|
|
575
|
+
'<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
|
|
392
576
|
f'<th>Tracker</th></tr>{rows or "<tr><td colspan=6 class=empty>no build phases</td></tr>"}'
|
|
393
|
-
"</table></div>")
|
|
394
|
-
out.append("</
|
|
577
|
+
"</table></div></details>")
|
|
578
|
+
out.append("</section>")
|
|
395
579
|
return "".join(out)
|
|
396
580
|
|
|
397
581
|
|
|
@@ -420,28 +604,68 @@ def _runner(model: dict) -> str:
|
|
|
420
604
|
if nxt:
|
|
421
605
|
parts.append(f"<p>Next batch: <code>{_e(nxt.get('milestone'))} {_e(nxt.get('plan'))}#{_e(nxt.get('phase'))}"
|
|
422
606
|
"</code></p>")
|
|
423
|
-
return ('<
|
|
424
|
-
+ "".join(parts) + "</
|
|
607
|
+
return ('<section class="card" id="runner"><div class="sec-h"><h2>Runner</h2>'
|
|
608
|
+
'<span class="note">roadmap-run state on this machine</span></div>' + "".join(parts) + "</section>")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# The sections of a roadmap view, in page order: the nav sub-links. `render_body` writes them in this order.
|
|
612
|
+
_PAGE_ANCHORS = [("waiting", "Waiting"), ("overview", "Overview"), ("flow", "Flow"), ("board", "Board"),
|
|
613
|
+
("timeline", "Timeline"), ("gates", "Gates"), ("milestones", "Milestones"), ("runner", "Runner")]
|
|
614
|
+
|
|
615
|
+
def _rid(folder, slug) -> str | None:
|
|
616
|
+
"""The view id of a roadmap in `index.html`: `rm-<the folder's 6 hex>-<slug>` (the 6 hex keep two clones with
|
|
617
|
+
one repo name apart), or None when the folder has no `-<6hex>` tag or the slug breaks the
|
|
618
|
+
slug rule (such a row gets no view: its name is not safe in an id or a selector)."""
|
|
619
|
+
m = re.fullmatch(r".+-([0-9a-f]{6})", str(folder or ""))
|
|
620
|
+
if not m or not isinstance(slug, str) or not _SLUG_RE.fullmatch(slug):
|
|
621
|
+
return None
|
|
622
|
+
return f"rm-{m.group(1)}-{slug}"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _prefix_ids(body: str, rid: str) -> str:
|
|
626
|
+
"""Every `id="x"` and `href="#x"` in a roadmap body becomes `<rid>-x`, so two roadmaps in one file never share an
|
|
627
|
+
id. Safe on rendered HTML: every value from the files is escaped, so a `"` in it is `"`."""
|
|
628
|
+
body = re.sub(r' id="([A-Za-z][\w-]*)"', lambda m: f' id="{rid}-{m.group(1)}"', body)
|
|
629
|
+
return re.sub(r' href="#([A-Za-z][\w-]*)"', lambda m: f' href="#{rid}-{m.group(1)}"', body)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _stub(rid: str, slug: str) -> str:
|
|
633
|
+
"""`<slug>.html`: one line that opens the roadmap's view in the hub, so an old link or bookmark still works."""
|
|
634
|
+
target = f"../index.html#{rid}"
|
|
635
|
+
return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
636
|
+
f'<meta http-equiv="refresh" content="0;url={_e(target)}"><title>Roadmap {_e(slug)}</title></head>'
|
|
637
|
+
f'<body><p><a href="{_e(target)}">Open roadmap {_e(slug)}</a></p></body></html>\n')
|
|
425
638
|
|
|
426
639
|
|
|
427
640
|
def render_html(model: dict) -> str:
|
|
428
|
-
"""
|
|
641
|
+
"""One self-contained roadmap page (an `--out` copy): the app shell with an empty repo tree, one theme script,
|
|
642
|
+
one local font link, no network. Every value from the files is escaped."""
|
|
643
|
+
slug = model["roadmap"]
|
|
644
|
+
return _shell(title=f"Roadmap {slug}", base="../", nav=_nav([], "../"), crumbs=_crumbs("", slug),
|
|
645
|
+
body=render_body(model))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def render_body(model: dict) -> str:
|
|
649
|
+
"""Everything inside `<main>`: the header and the sections. `write_page` keeps it as `<slug>.body.html`; the
|
|
650
|
+
hub puts it, with its ids prefixed, in `index.html` as the roadmap's view."""
|
|
429
651
|
title = f"Roadmap {model['roadmap']}"
|
|
430
652
|
phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
|
|
431
653
|
done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
|
|
432
654
|
commit = f" · origin <code>{_e(model.get('origin_commit'))}</code>" if model.get("origin_commit") else ""
|
|
655
|
+
chips = "".join(
|
|
656
|
+
f'<span class="chip" style="--lc:var(--c{i % 6})"><b>{_e(plan)}</b> <span>'
|
|
657
|
+
f'{_e(sum(1 for ph in phases if ph["plan"] == plan and ph["tile"] in ("done", "moved")))}/'
|
|
658
|
+
f'{_e(sum(1 for ph in phases if ph["plan"] == plan))} phases</span></span>'
|
|
659
|
+
for i, plan in enumerate(model["lanes"]))
|
|
433
660
|
return "".join([
|
|
434
|
-
'
|
|
435
|
-
'
|
|
436
|
-
f
|
|
437
|
-
f'<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
_waiting_section(model), _swimlane(model), _summary(model), _now_next_later(model), _gates(model),
|
|
441
|
-
_milestones(model), _runner(model),
|
|
661
|
+
f'<header class="hdr"><div><h1>{_e(title)}</h1><p class="sub">{_e(model["done"])} of {_e(model["total"])} '
|
|
662
|
+
f'milestones · {_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>'
|
|
663
|
+
f'{commit} · generated {_e(model["generated"])}</p>'
|
|
664
|
+
+ (f'<div class="chips">{chips}</div>' if chips else "") + "</div></header>",
|
|
665
|
+
_waiting_section(model), _summary(model), _flow(model), _now_next_later(model), _swimlane(model),
|
|
666
|
+
_gates(model), _milestones(model), _runner(model),
|
|
442
667
|
'<p class="empty">Built from roadmap.md, the plan Trackers and headers on origin, the runner state and the '
|
|
443
|
-
"session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"
|
|
444
|
-
"</main></body></html>\n"])
|
|
668
|
+
"session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"])
|
|
445
669
|
|
|
446
670
|
|
|
447
671
|
def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
|
|
@@ -454,20 +678,26 @@ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
|
|
|
454
678
|
|
|
455
679
|
|
|
456
680
|
def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
|
|
457
|
-
"""
|
|
458
|
-
|
|
681
|
+
"""At the default path: `<slug>.body.html` (the page body, which the hub shows as a view of `index.html`), a
|
|
682
|
+
one-line `<slug>.html` that opens that view, and `<slug>.json` (the one-row summary the hub is built from). An
|
|
683
|
+
`--out` page is a one-off self-contained copy and stays out of the hub. None: a newer caddis wrote this page,
|
|
684
|
+
so it is kept."""
|
|
459
685
|
path = Path(out) if out else default_page_path(root, slug)
|
|
460
686
|
if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
|
|
461
687
|
return None
|
|
462
688
|
model = build(root, slug, roadmap_mod)
|
|
463
689
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
690
|
+
if out is not None:
|
|
691
|
+
_write_atomic(path, render_html(model))
|
|
692
|
+
return path
|
|
693
|
+
_write_atomic(path.with_suffix(".body.html"), render_body(model))
|
|
694
|
+
rid = _rid(path.parent.name, model["roadmap"])
|
|
695
|
+
_write_atomic(path, _stub(rid, model["roadmap"]) if rid else render_html(model))
|
|
696
|
+
try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
|
|
697
|
+
_write_atomic(path.with_suffix(".json"),
|
|
698
|
+
json.dumps(page_summary(model, main_checkout(root), path), indent=1))
|
|
699
|
+
except Exception as exc:
|
|
700
|
+
print(f"[ROADMAP] hub summary not written: {exc}", file=sys.stderr)
|
|
471
701
|
return path
|
|
472
702
|
|
|
473
703
|
|
|
@@ -556,6 +786,33 @@ def _write_atomic(path: Path, text: str) -> None:
|
|
|
556
786
|
pass
|
|
557
787
|
|
|
558
788
|
|
|
789
|
+
ASSET_DIR = Path(__file__).resolve().parent / "roadmap_assets"
|
|
790
|
+
ASSET_FILES = ("fonts.css", "OFL-MapleMono.txt", "OFL-JetBrainsMono.txt")
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def install_assets(root_dir: Path) -> int:
|
|
794
|
+
"""Copy the bundled fonts (and their licences) to `<root_dir>/_assets/` when missing or changed. Text is compared
|
|
795
|
+
as text, not bytes: `_write_atomic` writes CRLF on Windows, so a byte compare would rewrite every time. A source
|
|
796
|
+
file that is not shipped is skipped. Returns the number of files written."""
|
|
797
|
+
written = 0
|
|
798
|
+
target_dir = root_dir / "_assets"
|
|
799
|
+
for name in ASSET_FILES:
|
|
800
|
+
src = ASSET_DIR / name
|
|
801
|
+
if not src.exists():
|
|
802
|
+
continue
|
|
803
|
+
text = src.read_text(encoding="utf-8")
|
|
804
|
+
dst = target_dir / name
|
|
805
|
+
try:
|
|
806
|
+
if dst.read_text(encoding="utf-8") == text:
|
|
807
|
+
continue
|
|
808
|
+
except FileNotFoundError:
|
|
809
|
+
pass
|
|
810
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
811
|
+
_write_atomic(dst, text)
|
|
812
|
+
written += 1
|
|
813
|
+
return written
|
|
814
|
+
|
|
815
|
+
|
|
559
816
|
def pages_root(home: Path | None = None) -> Path:
|
|
560
817
|
return Path(home or Path.home()) / ".caddis" / "roadmap-pages"
|
|
561
818
|
|
|
@@ -570,7 +827,7 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
|
|
|
570
827
|
else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
|
|
571
828
|
nxt = model.get("next") or {}
|
|
572
829
|
return {
|
|
573
|
-
"version":
|
|
830
|
+
"version": 2, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
|
|
574
831
|
"branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
|
|
575
832
|
"generated_utc": model["generated"],
|
|
576
833
|
"milestones_done": model["done"], "milestones_total": model["total"],
|
|
@@ -583,19 +840,32 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
|
|
|
583
840
|
for s in model["sessions"]],
|
|
584
841
|
"runner": runner,
|
|
585
842
|
"next": f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "",
|
|
843
|
+
"milestones": [{"id": ms["id"], "status": ms["status"]} for ms in model["milestones"]],
|
|
844
|
+
"blockers": [{"milestone": ms["id"], "type": b["type"], "reason": b["reason"], "check": b["check"]}
|
|
845
|
+
for ms in model["milestones"] for b in ms["blockers"]
|
|
846
|
+
if ms["status"] not in ("done", "not-needed")],
|
|
847
|
+
"runner_detail": {"lock": r.get("lock"), "stale_lock": bool(r.get("stale_lock")),
|
|
848
|
+
"last_stop": r.get("last_stop"), "errors": r.get("errors", [])},
|
|
586
849
|
}
|
|
587
850
|
|
|
588
851
|
|
|
852
|
+
# The roadmap slug rule, copied from caddis_roadmap.SLUG_RE_TEXT (a test keeps the two equal): this module stays
|
|
853
|
+
# importable on its own, without the roadmap module and its git helpers. A slug never holds a dot, so `<slug>.body.html` can never be another
|
|
854
|
+
# roadmap's `<slug>.html`; files whose name breaks the rule are never pruned or shown as a view.
|
|
855
|
+
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
|
856
|
+
|
|
857
|
+
|
|
589
858
|
def prune_folder(folder: Path, keep: set[str]) -> int:
|
|
590
|
-
"""Remove the page and summary of each roadmap that is no longer on origin. Only `<slug>.json` files and
|
|
591
|
-
`<slug>.html`
|
|
859
|
+
"""Remove the page, body and summary of each roadmap that is no longer on origin. Only `<slug>.json` files and
|
|
860
|
+
their `<slug>.html` and `<slug>.body.html` files are touched. Returns how many roadmaps were removed."""
|
|
592
861
|
removed = 0
|
|
593
862
|
if not folder.is_dir():
|
|
594
863
|
return removed
|
|
595
864
|
for js in folder.glob("*.json"):
|
|
596
|
-
|
|
865
|
+
# a live newer caddis may know a roadmap this one cannot list
|
|
866
|
+
if js.stem in keep or not _SLUG_RE.fullmatch(js.stem) or _newer_owner(js)[1]:
|
|
597
867
|
continue
|
|
598
|
-
for f in (js, js.with_suffix(".html")):
|
|
868
|
+
for f in (js, js.with_suffix(".html"), js.with_suffix(".body.html")):
|
|
599
869
|
try:
|
|
600
870
|
f.unlink()
|
|
601
871
|
except FileNotFoundError:
|
|
@@ -619,15 +889,39 @@ def read_summaries(root_dir: Path) -> tuple[list[dict], list[str]]:
|
|
|
619
889
|
data["page"] = f"{js.parent.name}/{js.with_suffix('.html').name}" # the link comes from the disk, not the file
|
|
620
890
|
data["folder"] = js.parent.name
|
|
621
891
|
data.setdefault("repo", js.parent.name.rsplit("-", 1)[0])
|
|
622
|
-
for key in ("sessions", "owner_gates"): # a hand-edited summary must not stop the hub for every repo
|
|
892
|
+
for key in ("sessions", "owner_gates", "milestones", "blockers"): # a hand-edited summary must not stop the hub for every repo
|
|
623
893
|
items = data.get(key)
|
|
624
894
|
data[key] = [i for i in items if isinstance(i, dict)] if isinstance(items, list) else []
|
|
625
895
|
if not isinstance(data.get("in_progress"), list):
|
|
626
896
|
data["in_progress"] = []
|
|
897
|
+
if not isinstance(data.get("runner_detail"), dict):
|
|
898
|
+
data["runner_detail"] = {}
|
|
627
899
|
rows.append(data)
|
|
628
900
|
return rows, errors
|
|
629
901
|
|
|
630
902
|
|
|
903
|
+
def read_bodies(root_dir: Path, rows: list[dict]) -> dict[str, str]:
|
|
904
|
+
"""{rid: body} for every row with a view id and a `<slug>.body.html`, read only. The path comes from
|
|
905
|
+
`row["page"]` (set from the disk by `read_summaries`), never from the summary's own text, and only a file
|
|
906
|
+
directly in a `<root_dir>/<folder>/` is read. One row that fails never stops the others."""
|
|
907
|
+
root = Path(root_dir).resolve()
|
|
908
|
+
bodies: dict[str, str] = {}
|
|
909
|
+
for row in rows:
|
|
910
|
+
rel, slug = row.get("page"), row.get("slug")
|
|
911
|
+
rid = _rid(row.get("folder"), slug)
|
|
912
|
+
if rid is None or not isinstance(rel, str) or slug != Path(rel).stem:
|
|
913
|
+
continue
|
|
914
|
+
try:
|
|
915
|
+
page = (root / rel).resolve()
|
|
916
|
+
body_file = page.with_suffix(".body.html")
|
|
917
|
+
if page.parent.parent != root or page.suffix != ".html" or not body_file.is_file():
|
|
918
|
+
continue
|
|
919
|
+
bodies[rid] = body_file.read_text(encoding="utf-8")
|
|
920
|
+
except Exception as exc: # noqa: BLE001 - one bad row never stops the others (docstring)
|
|
921
|
+
print(f"[ROADMAP] {Path(rel).with_suffix('').as_posix()}: body not read: {exc}", file=sys.stderr)
|
|
922
|
+
return bodies
|
|
923
|
+
|
|
924
|
+
|
|
631
925
|
def _age_h(utc_text: str, now: datetime.datetime) -> float | None:
|
|
632
926
|
try:
|
|
633
927
|
when = datetime.datetime.strptime(utc_text, "%Y-%m-%d %H:%M UTC").replace(tzinfo=datetime.timezone.utc)
|
|
@@ -642,19 +936,6 @@ def _bar(done: int, total: int) -> str:
|
|
|
642
936
|
f'<span class="empty">{_e(done)}/{_e(total)} phases · {pct}%</span>')
|
|
643
937
|
|
|
644
938
|
|
|
645
|
-
# The hub's dark theme is Dracula (owner, 17 Oct): https://draculatheme.com/contribute - background #282a36,
|
|
646
|
-
# current line #44475a, foreground #f8f8f2, comment #6272a4, cyan, green, orange, pink, purple, red, yellow.
|
|
647
|
-
_HUB_CSS = _CSS + """
|
|
648
|
-
@media (prefers-color-scheme:dark){:root{--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#6272a4;
|
|
649
|
-
--line:#44475a;--line2:#6272a4;--accent:#8be9fd;--todo-bg:#44475a;--todo-fg:#f8f8f2;--prog:#bd93f9;--prog-bg:#3c3352;
|
|
650
|
-
--prog-fg:#bd93f9;--done:#50fa7b;--done-bg:#2b4a36;--done-fg:#50fa7b;--block:#ffb86c;--block-bg:#4a3a2b;
|
|
651
|
-
--block-fg:#ffb86c;--high:#ff5555;--wait-bg:#4a2b33;--wait-fg:#ff5555}
|
|
652
|
-
h1{color:#bd93f9}.card summary{color:#ff79c6}code{color:#f1fa8c}}
|
|
653
|
-
.bar{height:8px;border-radius:99px;background:var(--todo-bg);overflow:hidden;min-width:120px;margin:4px 0}
|
|
654
|
-
.bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
|
|
655
|
-
"""
|
|
656
|
-
|
|
657
|
-
|
|
658
939
|
def _int(value) -> int:
|
|
659
940
|
try:
|
|
660
941
|
return int(value)
|
|
@@ -662,30 +943,27 @@ def _int(value) -> int:
|
|
|
662
943
|
return 0
|
|
663
944
|
|
|
664
945
|
|
|
665
|
-
def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
for
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
for
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
'<
|
|
684
|
-
f"
|
|
685
|
-
|
|
686
|
-
f'{_e(count)} item(s) need you · generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))} · '
|
|
687
|
-
"reloads every minute</p></header>",
|
|
688
|
-
'<section class="card wait"><h2>Needs you</h2>']
|
|
946
|
+
def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
947
|
+
bodies: dict[str, str] | None = None) -> str:
|
|
948
|
+
"""The whole app in one file: the inbox and overview view, then one view per roadmap (its body from `bodies`,
|
|
949
|
+
ids prefixed). CSS shows one view at a time, so the nav is the same for every view and is never stale. While the
|
|
950
|
+
inbox shows, the page reloads itself every 60 s (the script), so an open tab follows the renders."""
|
|
951
|
+
bodies = bodies or {}
|
|
952
|
+
errors = list(errors)
|
|
953
|
+
order, need, count = _group_rows(rows)
|
|
954
|
+
phases_done = sum(_int(r.get("phases_done")) for r in rows)
|
|
955
|
+
phases_total = sum(_int(r.get("phases_total")) for r in rows)
|
|
956
|
+
live = sum(1 for r in rows if r.get("runner") == "running")
|
|
957
|
+
blocked = sum(_int(r.get("blocked")) for r in rows)
|
|
958
|
+
out = ['<section class="view" id="inbox"><section class="kpis">',
|
|
959
|
+
f'<div class="kpi"><b>{_e(count)}</b>Needs you</div>',
|
|
960
|
+
f'<div class="kpi"><b>{_e(phases_done)} / {_e(phases_total)}</b>Phases done'
|
|
961
|
+
f'{_bar(phases_done, phases_total)}</div>',
|
|
962
|
+
f'<div class="kpi"><b>{_e(blocked)}</b>Blocked phases</div>',
|
|
963
|
+
f'<div class="kpi"><b>{_e(live)}</b>Runners live</div></section>',
|
|
964
|
+
f'<p class="sub">{_e(len(order))} repos · {_e(len(rows))} roadmaps · {_e(count)} item(s) need you · '
|
|
965
|
+
f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))} · the inbox reloads every minute</p>',
|
|
966
|
+
'<section id="needs" class="card wait"><h2>Needs you</h2>']
|
|
689
967
|
if not need:
|
|
690
968
|
out.append('<p class="empty">Nothing is waiting for you.</p>')
|
|
691
969
|
else:
|
|
@@ -699,35 +977,51 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> s
|
|
|
699
977
|
out.append(f"<tr><td><strong>{_e(repo)}</strong></td><td>owner gate {_e(r.get('slug'))} "
|
|
700
978
|
f"{_e(g.get('milestone'))}</td><td>{_e(g.get('reason'))}</td></tr>")
|
|
701
979
|
out.append("</table></div>")
|
|
702
|
-
out.append("</section>")
|
|
980
|
+
out.append("</section><h2>Roadmaps</h2>")
|
|
703
981
|
if not rows:
|
|
704
982
|
out.append('<p class="card empty">No roadmap page has been rendered on this machine yet. Run '
|
|
705
983
|
"<code>caddis_roadmap.py render --all</code> in a repo that has <code>.caddis/roadmaps/</code>.</p>")
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
out.append("</table></div></details>")
|
|
984
|
+
out.append('<div class="cards">')
|
|
985
|
+
for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
|
|
986
|
+
age = _age_h(str(r.get("generated_utc", "")), now)
|
|
987
|
+
old = " (not refreshed)" if age is not None and age > HUB_STALE_H else ""
|
|
988
|
+
dots = "".join(f'<span class="dot s-{_e(m.get("status"))}">{_e(m.get("id"))}</span>'
|
|
989
|
+
for m in r.get("milestones") or [])
|
|
990
|
+
rid = _rid(r.get("folder"), r.get("slug"))
|
|
991
|
+
if rid is None:
|
|
992
|
+
errors.append(f"{r.get('folder')}/{r.get('slug')}: the folder or slug name breaks the naming rule")
|
|
993
|
+
continue
|
|
994
|
+
out.append(f'<a class="rcard" href="#{rid}">'
|
|
995
|
+
f'<div><strong>{_e(r["slug"])}</strong> <span class="empty">{_e(r.get("repo"))} · '
|
|
996
|
+
f'{_e(r.get("branch"))} · {_e(r.get("origin_commit"))}</span></div>'
|
|
997
|
+
f'<div>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))} '
|
|
998
|
+
f'{_e(r.get("milestones_done"))}/{_e(r.get("milestones_total"))} milestones</div>'
|
|
999
|
+
f'<div class="dots">{dots}</div>'
|
|
1000
|
+
f'<div class="empty">next: {_e(r.get("next") or "-")} · runner: {_e(r.get("runner") or "idle")}'
|
|
1001
|
+
f'{_e(old)}</div></a>')
|
|
1002
|
+
out.append("</div>")
|
|
726
1003
|
for err in errors:
|
|
727
1004
|
out.append(f'<p class="empty">Could not read {_e(err)}.</p>')
|
|
728
1005
|
out.append('<p class="empty">Built from the page summaries in this folder. Every roadmap page render rebuilds it.'
|
|
729
|
-
"</p></
|
|
730
|
-
|
|
1006
|
+
"</p></section>")
|
|
1007
|
+
rids = []
|
|
1008
|
+
for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
|
|
1009
|
+
rid = _rid(r.get("folder"), r.get("slug"))
|
|
1010
|
+
if rid is None:
|
|
1011
|
+
continue
|
|
1012
|
+
rids.append(rid)
|
|
1013
|
+
body = bodies.get(rid)
|
|
1014
|
+
inner = (_prefix_ids(body, rid) if body is not None else
|
|
1015
|
+
f'<p class="card empty">No page body yet for {_e(r["slug"])}: run <code>caddis_roadmap.py render</code> '
|
|
1016
|
+
"in its repo.</p>")
|
|
1017
|
+
out.append(f'<section class="view" id="{rid}"><p class="view-crumbs"><a href="#inbox">Roadmaps</a> › '
|
|
1018
|
+
f'{_e(r.get("repo"))} › <strong>{_e(r["slug"])}</strong></p>{inner}</section>')
|
|
1019
|
+
for key, label in (("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")): # Phase 6 fills these
|
|
1020
|
+
out.append(f'<section class="view" id="{key}"><h2>{label}</h2><p class="card empty">The cross-repo {key} '
|
|
1021
|
+
'view comes in a later update. For now, see <a href="#needs">Needs you</a> on the inbox and each '
|
|
1022
|
+
"roadmap's own page.</p></section>")
|
|
1023
|
+
return _shell(title="Roadmaps", base="", nav=_nav(rows, ""), crumbs="Roadmaps", body="".join(out),
|
|
1024
|
+
css=_view_css(rids))
|
|
731
1025
|
|
|
732
1026
|
|
|
733
1027
|
def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
|
|
@@ -743,8 +1037,17 @@ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) ->
|
|
|
743
1037
|
theirs, live = "", False
|
|
744
1038
|
if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
|
|
745
1039
|
return None
|
|
1040
|
+
try:
|
|
1041
|
+
install_assets(root_dir)
|
|
1042
|
+
except Exception as exc: # a missing font file must never stop the hub
|
|
1043
|
+
print(f"[ROADMAP] assets not installed: {exc}", file=sys.stderr)
|
|
746
1044
|
rows, errors = read_summaries(root_dir)
|
|
1045
|
+
try: # a hub without the roadmap bodies is better than no hub
|
|
1046
|
+
bodies = read_bodies(root_dir, rows)
|
|
1047
|
+
except Exception as exc:
|
|
1048
|
+
print(f"[ROADMAP] page bodies not read: {exc}", file=sys.stderr)
|
|
1049
|
+
bodies = {}
|
|
747
1050
|
path = root_dir / "index.html"
|
|
748
|
-
_write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
|
|
1051
|
+
_write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc), bodies))
|
|
749
1052
|
_write_atomic(stamp, generator_version() + "\n")
|
|
750
1053
|
return path
|