feat(skills): pql-board — clide-style ticket board as a stable claude.ai Artifact
tooling/pql-board-html: self-contained interactive board snapshot from pql-native JSON (ticket list --full + batched --with-blockers for non-terminal tickets + plan status) — status-grouped rail with filter/type chips, ticket dossier with status pills, dep/children chips, deep links, keyboard nav; clide's visual identity (amber on warm near-black, mono data type). The /pql-board skill regenerates and redeploys to the canonical artifact URL so the user's open tab survives refreshes across sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: pql-board
|
||||
description: >
|
||||
Refresh (or first-create) the pql ticket-board Artifact — the clide-style
|
||||
two-column board snapshot hosted at a stable claude.ai URL. Use when the user
|
||||
says "refresh the board", "update the pql board", "/pql-board", or asks to see
|
||||
the ticket board in the desktop app.
|
||||
---
|
||||
|
||||
# pql Board Artifact
|
||||
|
||||
The board is a self-contained interactive HTML snapshot of the pql ticket store
|
||||
(status-grouped rail, ticket dossier with dependencies/children, filter,
|
||||
deep-links), deployed as a claude.ai Artifact. Artifacts are network-sandboxed,
|
||||
so the page is a **snapshot** — this skill regenerates and redeploys it.
|
||||
|
||||
**Canonical URL (redeploy target — keep stable so the user's open tab survives):**
|
||||
|
||||
https://claude.ai/code/artifact/0064e59d-84a9-4a31-a830-f7d677df9d92
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Generate the fragment (from the repo root; pql must resolve to this vault —
|
||||
run from the MAIN checkout, not a worktree):
|
||||
|
||||
```bash
|
||||
tooling/pql-board-html <scratchpad>/pql-board.html
|
||||
```
|
||||
|
||||
The script pulls `pql ticket list --full`, batch-fetches blocker edges for
|
||||
non-terminal tickets, and `pql plan status` for the header. Output is an
|
||||
Artifact-ready fragment (no doctype/html/head/body).
|
||||
|
||||
2. Deploy with the **Artifact tool**, always passing the canonical URL so the
|
||||
existing artifact updates in place (a fresh session without `url` would mint
|
||||
a new address and orphan the user's tab):
|
||||
|
||||
- `file_path`: the generated file
|
||||
- `url`: the canonical URL above
|
||||
- `favicon`: `🗂️` (keep stable — the user finds the tab by it)
|
||||
- `label`: short, e.g. `board-YYYY-MM-DD`
|
||||
|
||||
3. Reply with one line: ticket/edge counts from the generator's stdout + the
|
||||
URL. The user reloads their tab.
|
||||
|
||||
## Notes
|
||||
|
||||
- If the Artifact call 409s (another session touched it), reconcile then retry
|
||||
with `force` only if intentionally replacing.
|
||||
- If the canonical artifact was deleted and a new URL gets minted, **update the
|
||||
URL in this skill file** in the same turn and commit it.
|
||||
- The generator is `tooling/pql-board-html` (python, pql-native JSON only).
|
||||
Known scope limits vs clide: no decisions view, no kanban columns, no live
|
||||
editing. Dep edges cover non-terminal tickets only — if pql grows
|
||||
`ticket list --with-blockers`, simplify the script (candidate upstream FR
|
||||
per the pql-gaps-go-upstream rule).
|
||||
- Companion interaction surface: the visualize widget (`show_widget`) supports
|
||||
`sendPrompt()` — for in-chat quick actions (refresh button, WIP strip); see
|
||||
the session pattern, not this skill.
|
||||
Executable
+347
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the pql ticket board as a self-contained interactive HTML snapshot.
|
||||
|
||||
Companion to clide's two-column board view, for sessions running in the Claude
|
||||
desktop app: the output is deployed as a claude.ai Artifact (stable URL across
|
||||
redeploys), giving always-visible pql board + ticket-detail reference without
|
||||
clide. The page is fully client-side over embedded JSON — artifacts are
|
||||
network-sandboxed, so this is a *snapshot*; regenerate + redeploy to refresh.
|
||||
|
||||
Data comes from pql's native JSON output only (no output parsing):
|
||||
- `pql ticket list --full` → every ticket, whole rows
|
||||
- `pql ticket show <ids> --with-blockers` → dep edges, batched, for
|
||||
non-terminal tickets only (native list output carries no dep edges — if
|
||||
pql ever grows `list --with-blockers`, drop the batching here; candidate
|
||||
upstream feature request per the no-ad-hoc-wrappers rule)
|
||||
- `pql plan status` → header counts
|
||||
|
||||
Usage: tooling/pql-board-html [OUTPUT.html]
|
||||
(default output: /tmp/pql-board.html)
|
||||
|
||||
The emitted file is an Artifact-ready fragment: no doctype/html/head/body
|
||||
wrapper (the Artifact pipeline adds those); it starts with <title> + <style>.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
NON_TERMINAL = ["in_progress", "review", "ready", "backlog"]
|
||||
SHOW_CHUNK = 40
|
||||
|
||||
|
||||
def pql(*args):
|
||||
out = subprocess.run(
|
||||
["pql", *args], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def fetch():
|
||||
tickets = pql("ticket", "list", "--full")
|
||||
plan = pql("plan", "status")
|
||||
|
||||
ids = [t["id"] for t in tickets if t.get("status") in NON_TERMINAL]
|
||||
blockers = {}
|
||||
for i in range(0, len(ids), SHOW_CHUNK):
|
||||
chunk = ids[i : i + SHOW_CHUNK]
|
||||
shown = pql("ticket", "show", ",".join(chunk), "--with-blockers")
|
||||
if isinstance(shown, dict):
|
||||
shown = [shown]
|
||||
for row in shown:
|
||||
edges = row.get("blockers") or row.get("blocked_by") or []
|
||||
blockers[row["id"]] = [
|
||||
e["id"] if isinstance(e, dict) else e for e in edges
|
||||
]
|
||||
return tickets, blockers, plan
|
||||
|
||||
|
||||
PAGE = """<title>Settled Reach — pql board</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #131110; --panel: #1b1815; --panel-2: #211d19; --line: #2a2521;
|
||||
--text: #e8e2d9; --dim: #8a8178; --faint: #5c554e;
|
||||
--accent: #e8963c; --accent-dim: #8a5a26;
|
||||
--t-initiative: #b07cc6; --t-epic: #e8963c; --t-story: #7fb069;
|
||||
--t-task: #6fa8dc; --t-bug: #d9534f;
|
||||
--s-progress: #e8963c; --s-review: #c9a227; --s-ready: #5fb3a1;
|
||||
--s-backlog: #8a8178; --s-done: #5e7d5e; --s-cancelled: #5c554e;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--text); font-family: var(--sans);
|
||||
margin: 0; height: 100vh; display: flex; flex-direction: column;
|
||||
overflow: hidden; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
header { display: flex; align-items: baseline; gap: 16px; padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--line); background: var(--panel);
|
||||
flex: 0 0 auto; flex-wrap: wrap; }
|
||||
header h1 { font: 600 14px/1 var(--mono); letter-spacing: .06em;
|
||||
text-transform: uppercase; color: var(--accent); margin: 0; }
|
||||
header .counts { font: 11px/1 var(--mono); color: var(--dim); }
|
||||
header .stamp { margin-left: auto; font: 11px/1 var(--mono); color: var(--faint); }
|
||||
|
||||
main { flex: 1 1 auto; display: flex; min-height: 0; }
|
||||
|
||||
/* ---- left rail ---- */
|
||||
#rail { flex: 0 0 372px; border-right: 1px solid var(--line); display: flex;
|
||||
flex-direction: column; min-height: 0; background: var(--bg); }
|
||||
#tools { padding: 10px 12px 8px; display: flex; flex-direction: column; gap: 8px;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
#filter { background: var(--panel-2); border: 1px solid var(--line); color: var(--text);
|
||||
font: 12px var(--mono); padding: 6px 9px; border-radius: 3px; width: 100%; }
|
||||
#filter:focus { outline: 1px solid var(--accent-dim); }
|
||||
#chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip { font: 10px/1 var(--mono); letter-spacing: .05em; text-transform: uppercase;
|
||||
padding: 4px 8px; border-radius: 999px; border: 1px solid var(--line);
|
||||
color: var(--dim); cursor: pointer; user-select: none; }
|
||||
.chip.on { color: var(--text); border-color: currentcolor; }
|
||||
.chip.on.c-initiative { color: var(--t-initiative); }
|
||||
.chip.on.c-epic { color: var(--t-epic); }
|
||||
.chip.on.c-story { color: var(--t-story); }
|
||||
.chip.on.c-task { color: var(--t-task); }
|
||||
.chip.on.c-bug { color: var(--t-bug); }
|
||||
|
||||
#list { overflow-y: auto; flex: 1 1 auto; min-height: 0; }
|
||||
.ghead { position: sticky; top: 0; z-index: 2; display: flex; gap: 8px;
|
||||
align-items: baseline; padding: 8px 12px 6px; background: var(--bg);
|
||||
border-bottom: 1px solid var(--line); cursor: pointer; user-select: none; }
|
||||
.ghead .arrow { color: var(--faint); font: 10px var(--mono); width: 10px; }
|
||||
.ghead .gname { font: 600 10px/1 var(--mono); letter-spacing: .12em;
|
||||
text-transform: uppercase; }
|
||||
.ghead .gcount { font: 10px/1 var(--mono); color: var(--faint); }
|
||||
.card { padding: 8px 12px 9px 14px; border-bottom: 1px solid var(--line);
|
||||
border-left: 2px solid transparent; cursor: pointer; }
|
||||
.card:hover { background: var(--panel); }
|
||||
.card.sel { background: var(--panel-2); border-left-color: var(--accent); }
|
||||
.card .chain { font: 10px/1.4 var(--mono); color: var(--faint); }
|
||||
.card .idrow { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.card .tid { font: 600 11px/1 var(--mono); color: var(--dim); }
|
||||
.card .title { font: 12.5px/1.35 var(--sans); margin-top: 3px; }
|
||||
.card.done .title, .card.cancelled .title { color: var(--dim); }
|
||||
.card.cancelled .title { text-decoration: line-through; }
|
||||
.prio { font: 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 2px 5px; border-radius: 2px; margin-left: auto; }
|
||||
.prio.critical { color: var(--t-bug); border: 1px solid var(--t-bug); }
|
||||
.prio.high { color: var(--accent); border: 1px solid var(--accent-dim); }
|
||||
|
||||
/* ---- detail pane ---- */
|
||||
#detail { flex: 1 1 auto; overflow-y: auto; min-width: 0; padding: 22px 30px 60px; }
|
||||
#empty { color: var(--faint); font: 13px var(--mono); padding: 40px; }
|
||||
.d-chain { font: 11px/1.5 var(--mono); color: var(--dim); }
|
||||
.d-chain a { color: var(--dim); } .d-chain a:hover { color: var(--accent); }
|
||||
#d-idrow { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
|
||||
#d-id { font: 700 15px var(--mono); color: var(--accent); }
|
||||
#d-prio { font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||
#d-title { font: 600 21px/1.3 var(--sans); text-wrap: balance; margin: 2px 0 14px;
|
||||
max-width: 46ch; }
|
||||
#d-status { display: flex; border: 1px solid var(--line); border-radius: 3px;
|
||||
overflow: hidden; width: max-content; max-width: 100%; margin-bottom: 16px; }
|
||||
#d-status span { font: 9.5px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase;
|
||||
padding: 6px 13px; color: var(--faint); border-right: 1px solid var(--line); }
|
||||
#d-status span:last-child { border-right: 0; }
|
||||
#d-status span.on { background: var(--panel-2); color: var(--accent);
|
||||
box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
#d-meta { display: grid; grid-template-columns: max-content 1fr; gap: 5px 18px;
|
||||
font: 12px/1.5 var(--mono); margin-bottom: 18px; max-width: 70ch; }
|
||||
#d-meta dt { color: var(--faint); text-transform: uppercase; font-size: 10px;
|
||||
letter-spacing: .08em; padding-top: 2px; }
|
||||
#d-meta dd { margin: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
.tchip { display: inline-block; font: 11px/1 var(--mono); padding: 3px 7px;
|
||||
border: 1px solid var(--line); border-radius: 3px; margin: 0 5px 5px 0;
|
||||
color: var(--text); cursor: pointer; }
|
||||
.tchip:hover { border-color: var(--accent-dim); }
|
||||
.tchip .st { margin-left: 6px; }
|
||||
.tchip.open .st { color: var(--accent); }
|
||||
.tchip.closed .st { color: var(--s-done); }
|
||||
#d-desc { font: 13.5px/1.65 var(--sans); max-width: 72ch; color: var(--text); }
|
||||
#d-desc p { margin: 0 0 12px; }
|
||||
#d-desc p + p { border-top: 1px dashed var(--line); padding-top: 12px; }
|
||||
.seclabel { font: 600 10px/1 var(--mono); letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--faint); margin: 18px 0 8px; }
|
||||
::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-thumb { background: var(--line); }
|
||||
@media (max-width: 900px) {
|
||||
main { flex-direction: column; overflow-y: auto; }
|
||||
#rail { flex: 0 0 auto; max-height: 45vh; border-right: 0;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<header>
|
||||
<h1>Settled Reach · pql</h1>
|
||||
<span class="counts" id="counts"></span>
|
||||
<span class="stamp">snapshot __STAMP__</span>
|
||||
</header>
|
||||
<main>
|
||||
<div id="rail">
|
||||
<div id="tools">
|
||||
<input id="filter" type="search" placeholder="Filter tickets… (id or title)" />
|
||||
<div id="chips"></div>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
</div>
|
||||
<div id="detail"><div id="empty">Select a ticket.</div></div>
|
||||
</main>
|
||||
|
||||
<script type="application/json" id="data">__DATA__</script>
|
||||
<script>
|
||||
const RAW = JSON.parse(document.getElementById("data").textContent);
|
||||
const T = RAW.tickets, BLK = RAW.blockers, PLAN = RAW.plan;
|
||||
const byId = Object.fromEntries(T.map(t => [t.id, t]));
|
||||
const children = {}; const blocks = {};
|
||||
for (const t of T) if (t.parent_id) (children[t.parent_id] ||= []).push(t.id);
|
||||
for (const [id, bs] of Object.entries(BLK)) for (const b of bs) (blocks[b] ||= []).push(id);
|
||||
const TYPES = ["initiative","epic","story","task","bug"];
|
||||
const GROUPS = [
|
||||
["in_progress","IN PROGRESS",true],["review","REVIEW",true],["ready","READY",true],
|
||||
["backlog","BACKLOG",true],["done","DONE",false],["cancelled","CANCELLED",false]];
|
||||
const STATUSES = ["backlog","ready","in_progress","review","done","cancelled"];
|
||||
const SCOLOR = {in_progress:"var(--s-progress)",review:"var(--s-review)",ready:"var(--s-ready)",
|
||||
backlog:"var(--s-backlog)",done:"var(--s-done)",cancelled:"var(--s-cancelled)"};
|
||||
const typeOn = Object.fromEntries(TYPES.map(t=>[t,true]));
|
||||
const openGroups = Object.fromEntries(GROUPS.map(([k,,d])=>[k,d]));
|
||||
let query = "", selected = null;
|
||||
|
||||
const counts = PLAN.tickets?.by_status || {};
|
||||
document.getElementById("counts").textContent =
|
||||
`${PLAN.tickets?.total ?? T.length} tickets · ${counts.in_progress||0} wip · ` +
|
||||
`${counts.backlog||0} backlog · ${counts.done||0} done`;
|
||||
|
||||
function chain(t){ const parts=[]; let p=t.parent_id;
|
||||
while(p && byId[p]){ parts.unshift(byId[p].id); p=byId[p].parent_id; }
|
||||
return parts; }
|
||||
function esc(s){ const d=document.createElement("span"); d.textContent=s??""; return d.innerHTML; }
|
||||
function terminal(s){ return s==="done"||s==="cancelled"; }
|
||||
|
||||
const chipsEl = document.getElementById("chips");
|
||||
for (const ty of TYPES){
|
||||
const c=document.createElement("span");
|
||||
c.className=`chip on c-${ty}`; c.textContent=ty; c.tabIndex=0;
|
||||
const flip=()=>{ typeOn[ty]=!typeOn[ty]; c.classList.toggle("on",typeOn[ty]); renderList(); };
|
||||
c.onclick=flip; c.onkeydown=e=>{ if(e.key==="Enter"||e.key===" "){e.preventDefault();flip();} };
|
||||
chipsEl.appendChild(c);
|
||||
}
|
||||
document.getElementById("filter").addEventListener("input", e=>{
|
||||
query=e.target.value.trim().toLowerCase(); renderList(); });
|
||||
|
||||
function visible(){
|
||||
return T.filter(t => typeOn[t.type] &&
|
||||
(!query || t.id.toLowerCase().includes(query) ||
|
||||
(t.title||"").toLowerCase().includes(query)));
|
||||
}
|
||||
|
||||
function renderList(){
|
||||
const listEl=document.getElementById("list"); listEl.innerHTML="";
|
||||
const vis=visible();
|
||||
for (const [key,label] of GROUPS.map(g=>[g[0],g[1]])){
|
||||
const rows=vis.filter(t=>t.status===key);
|
||||
if(!rows.length) continue;
|
||||
const gh=document.createElement("div"); gh.className="ghead";
|
||||
gh.innerHTML=`<span class="arrow">${openGroups[key]?"▾":"▸"}</span>`+
|
||||
`<span class="gname" style="color:${SCOLOR[key]}">${label}</span>`+
|
||||
`<span class="gcount">· ${rows.length}</span>`;
|
||||
gh.onclick=()=>{ openGroups[key]=!openGroups[key]; renderList(); };
|
||||
listEl.appendChild(gh);
|
||||
if(!openGroups[key]) continue;
|
||||
for (const t of rows){
|
||||
const c=document.createElement("div");
|
||||
c.className=`card ${t.status}${t.id===selected?" sel":""}`;
|
||||
c.dataset.id=t.id;
|
||||
const ch=chain(t);
|
||||
c.innerHTML=(ch.length?`<div class="chain">${ch.map(esc).join(" → ")}</div>`:"")+
|
||||
`<div class="idrow"><span class="dot" style="background:var(--t-${t.type})"></span>`+
|
||||
`<span class="tid">${esc(t.id)}</span>`+
|
||||
(t.priority==="critical"||t.priority==="high"
|
||||
?`<span class="prio ${t.priority}">${t.priority}</span>`:"")+
|
||||
`</div><div class="title">${esc(t.title)}</div>`;
|
||||
c.onclick=()=>select(t.id);
|
||||
listEl.appendChild(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tchip(id){
|
||||
const t=byId[id]; if(!t) return `<span class="tchip">${esc(id)}</span>`;
|
||||
const cls=terminal(t.status)?"closed":"open";
|
||||
return `<span class="tchip ${cls}" data-id="${esc(id)}" title="${esc(t.title)}">`+
|
||||
`${esc(id)}<span class="st">${terminal(t.status)?"✓":esc(t.status)}</span></span>`;
|
||||
}
|
||||
|
||||
function select(id, push=true){
|
||||
selected=id; if(push) location.hash=id;
|
||||
renderList();
|
||||
const t=byId[id]; const d=document.getElementById("detail");
|
||||
if(!t){ d.innerHTML=`<div id="empty">Unknown ticket ${esc(id)}.</div>`; return; }
|
||||
const ch=chain(t);
|
||||
const kids=(children[t.id]||[]);
|
||||
const bl=BLK[t.id]||[]; const bs=blocks[t.id]||[];
|
||||
const meta=[["type",esc(t.type)],["team",esc(t.team||"—")],
|
||||
["priority",esc(t.priority||"—")],["decision",esc(t.decision_ref||"—")],
|
||||
["created",esc((t.created_at||"").slice(0,16))],
|
||||
["updated",esc((t.updated_at||"").slice(0,16))],
|
||||
["record",esc(t.record_id||"")]];
|
||||
d.innerHTML=
|
||||
(ch.length?`<div class="d-chain">${ch.map(p=>`<a href="#${esc(p)}">${esc(p)}</a>`).join(" → ")}</div>`:"")+
|
||||
`<div id="d-idrow"><span class="dot" style="background:var(--t-${t.type});width:9px;height:9px"></span>`+
|
||||
`<span id="d-id">${esc(t.id)}</span>`+
|
||||
`<span id="d-prio" style="color:${t.priority==="critical"?"var(--t-bug)":t.priority==="high"?"var(--accent)":"var(--dim)"}">${esc(t.priority||"")}</span></div>`+
|
||||
`<div id="d-title">${esc(t.title)}</div>`+
|
||||
`<div id="d-status">${STATUSES.map(s=>`<span class="${s===t.status?"on":""}">${s.replace("_"," ")}</span>`).join("")}</div>`+
|
||||
`<dl id="d-meta">${meta.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`+
|
||||
(bl.length?`<div class="seclabel">Blocked by</div><div>${bl.map(tchip).join("")}</div>`:"")+
|
||||
(bs.length?`<div class="seclabel">Blocks</div><div>${bs.map(tchip).join("")}</div>`:"")+
|
||||
(kids.length?`<div class="seclabel">Children · ${kids.length}</div><div>${kids.map(tchip).join("")}</div>`:"")+
|
||||
`<div class="seclabel">Description</div>`+
|
||||
`<div id="d-desc">${(t.description||"—").split(/\\n\\n+/).map(p=>`<p>${esc(p).replace(/\\n/g,"<br>")}</p>`).join("")}</div>`;
|
||||
d.scrollTop=0;
|
||||
d.querySelectorAll(".tchip[data-id]").forEach(el=>el.onclick=()=>select(el.dataset.id));
|
||||
const card=document.querySelector(`.card[data-id="${CSS.escape(id)}"]`);
|
||||
if(card) card.scrollIntoView({block:"nearest"});
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange",()=>{ const h=location.hash.slice(1);
|
||||
if(h && h!==selected) select(h,false); });
|
||||
document.addEventListener("keydown",e=>{
|
||||
if(e.target.id==="filter") return;
|
||||
if(e.key!=="ArrowDown"&&e.key!=="ArrowUp") return;
|
||||
const vis=visible().filter(t=>openGroups[t.status]);
|
||||
const order=[]; for(const [k] of GROUPS) order.push(...vis.filter(t=>t.status===k));
|
||||
if(!order.length) return;
|
||||
const i=order.findIndex(t=>t.id===selected);
|
||||
const n=e.key==="ArrowDown"?Math.min(order.length-1,i+1):Math.max(0,i<0?0:i-1);
|
||||
select(order[n].id); e.preventDefault();
|
||||
});
|
||||
|
||||
renderList();
|
||||
const initial=location.hash.slice(1);
|
||||
if(initial&&byId[initial]) select(initial,false);
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
out_path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/pql-board.html"
|
||||
tickets, blockers, plan = fetch()
|
||||
stamp = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
data = json.dumps(
|
||||
{"tickets": tickets, "blockers": blockers, "plan": plan},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).replace("</", "<\\/")
|
||||
page = PAGE.replace("__STAMP__", stamp).replace("__DATA__", data)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(page)
|
||||
print(
|
||||
f"wrote {out_path}: {len(tickets)} tickets, "
|
||||
f"{sum(len(v) for v in blockers.values())} dep edges, "
|
||||
f"{len(page) // 1024} KiB"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user