- description
- the Master dashboard — starter: a copy of the Pro dashboard, to be cut to what a master project needs (P-1097, P-1037 step 4)
- template-target
- App:Dashboard
- template-levels
- master
- tags
- #template #master #wip
body{margin:0} .wait{color:var(--soft);font-size:13px;background:var(--slate-bg);border-radius:8px;padding:8px 12px} .feat{cursor:pointer}.feat .beh span{display:block} .st{font:600 11px var(--body);padding:1px 6px;border-radius:5px;margin-left:6px;white-space:nowrap} .st.tested{background:var(--leaf-bg);color:var(--leaf)}.st.planned{background:var(--sun-bg);color:var(--ink)}.st.none{background:var(--slate-bg);color:var(--soft)}.st.failing{background:var(--rose-bg);color:var(--rose)} .seg .n1{background:var(--line)} .think{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:16px;align-items:stretch} @media (max-width:760px){.think{grid-template-columns:1fr}} .think form{display:flex;flex-direction:column;min-width:0} .think textarea{flex:1;min-height:230px} .brain{position:relative;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--paper);overflow:hidden;display:flex;flex-direction:column} .brain canvas{display:block;width:100%;height:270px;cursor:pointer} .bcap{margin:0;padding:7px 12px;border-top:1px solid var(--line);font-size:13px;color:var(--soft);min-height:2.9em} .bcap b{color:var(--ink)} .think form{position:relative} .pop{position:absolute;z-index:5;min-width:230px;max-width:min(340px,92%);background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.14);padding:4px;font-size:13px} .pop .o{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:5px 8px;border-radius:7px;cursor:pointer;align-items:baseline} .pop .o b{font-weight:600;color:var(--ink)}.pop .o b u{text-decoration:none;color:var(--accent)} .pop .o .k{font:600 10px var(--body);letter-spacing:.05em;text-transform:uppercase;color:var(--soft)} .pop .o .d{grid-column:1/-1;color:var(--soft);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .pop .o.on{background:var(--accent-bg)} .pop .f{padding:4px 8px 2px;color:var(--soft);font-size:11px;border-top:1px solid var(--line);margin-top:3px} .pop kbd{font:600 10px var(--mono);border:1px solid var(--line);border-radius:4px;padding:0 4px;background:var(--paper);color:var(--ink)} .linkbtn{background:none;border:0;color:var(--accent);cursor:pointer;padding:0;font-size:13px}
Dashboard
Needs you what waits on your answer
Loading…
<section class="panel c12" aria-labelledby="h-pipe">
<h2 id="h-pipe">Work in motion <small>the pipeline, simplified: a ticket moves right as it is worked</small><a class="more" href="/pipeline" target="_top">Open the pipeline</a></h2>
<div id="pipe"><p class="wait">Loading…</p></div>
</section>
<section class="panel c12" aria-labelledby="h-apps">
<h2 id="h-apps">Apps <small>what people use</small><a class="more" href="/topics/Toolbox" target="_top">Toolbox</a></h2>
<div class="apps" id="apps"><p class="wait">Loading…</p></div>
</section>
<section class="panel c7" aria-labelledby="h-chg">
<h2 id="h-chg">What changed <small>decisions and changes, newest first</small><a class="more" href="/history" target="_top">History</a></h2>
<div id="chg"><p class="wait">Loading…</p></div>
</section>
<section class="panel c5" aria-labelledby="h-ai">
<h2 id="h-ai">Your AI <small>who is up now</small><a class="more" href="/agents" target="_top">Agents</a></h2>
<div id="ai"><p class="wait">Loading…</p></div>
</section>
<section class="panel c12" aria-labelledby="h-th">
<h2 id="h-th">A thought <small>note it here; your designer picks it up with the next streak</small></h2>
<div class="think">
<form id="tf"><textarea id="tx" aria-label="A thought" placeholder="#bug … or #idea … Name a thing of this project (a class, a feature) and the map on the right goes to it."></textarea>
<div class="pop" id="sug" role="listbox" aria-label="Completions" hidden></div>
<div style="display:flex;gap:10px;align-items:center;margin-top:8px;flex-wrap:wrap"><button class="btn primary" type="submit">Note it</button><span class="said" id="said" role="status"></span></div></form>
<div class="brain"><canvas id="brain" role="img" aria-label="A map of this project's classes and features"></canvas><p class="bcap" id="bcap">Loading the map…</p></div>
</div>
</section>
A first try, built as the topic App:Dashboard. Spec, Design, Test, Features, Tests, Gaps and Apps are worked out from this project's own topics each time the page opens. Failing tests show once an implementation has its test report (Report:testingProto1). Not here yet: Prototypes, Data.
const WAITS="P-1039"; const $=id=>document.getElementById(id), esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); const api=(m,p,b)=>window.d2.api(m,p,b); const waitLine=(what,e)=>'
'+(/E_SCOPE/.test(String(e&&e.message||e))?'Shows once an app may read '+what+' ('+WAITS+', with the coders).':'Could not read '+what+': '+esc(String(e&&e.message||e).slice(0,140)))+''; const ago=t=>{const s=(Date.now()-Date.parse(t))/1000;return s<90?"now":s<3600?Math.round(s/60)+" min":s<86400?Math.round(s/3600)+" h":Math.round(s/86400)+" d"}; const hl=(s,q)=>{if(!q)return esc(s);const i=s.toLowerCase().indexOf(q);return i<0?esc(s):esc(s.slice(0,i))+""+esc(s.slice(i,i+q.length))+""+esc(s.slice(i+q.length))}; let R=null,me=null,open=new Set(),showAll=false;async function who(){try{me=await api("GET","/api/v2/me");const h=me.here||{};$("pname").textContent=h.project||me.project||"Dashboard";$("plevel").textContent=h.level||"";document.title=(h.project||"")+" · Dashboard"}catch(e){}}
async function spec(){ const T={};await Promise.all([["Spec","Spec"],["SpecUi","SpecUi"],["Design","Design"],["Testing","Testing"]].map(async([k,n])=>{try{const r=await api("GET","/api/v2/topics/"+n);T[k]=typeof r==="string"?r:(r&&r.text)||""}catch(e){T[k]=""}})); let reps=[];try{reps=((await api("GET","/api/v2/topics?category=Report")).data||[]).map(t=>t.name).filter(n=>/^Report:testing/.test(n)).sort()}catch(e){} const RT=[];for(const n of reps){try{const r=await api("GET","/api/v2/topics/"+n);if(typeof r==="string")RT.push([n,r])}catch(e){}} T.Report=RT.map(x=>x[1]).join("\n");window.REPS=RT.map(x=>x[0]); if(!T.Testing){try{const r=await api("GET","/api/v2/topics/SpecTest");T.Testing=typeof r==="string"?r:"";if(T.Testing)document.querySelectorAll('a[href="/topics/Testing"]').forEach(a=>a.href="/topics/SpecTest")}catch(e){}} R=d2parse(T);const s=R.sum; $("tS").innerHTML=s.features+' features';$("tSs").textContent=s.behaviours+" behaviours"+(T.SpecUi?", in Spec and SpecUi":""); $("tD").innerHTML=s.designed+' of '+s.features+' designed';$("tDs").textContent=(s.features-s.designed)?(s.features-s.designed)+" features have behaviours and no design yet":"every feature has a design"; const tot=s.behaviours||1;$("tT").innerHTML=s.bTested+' of '+s.behaviours+' behaviours tested'; $("tTseg").innerHTML=''; $("tTs").textContent=(R.rep.has?s.bFailing+" failing, ":"")+s.bPlanned+" with a test planned, "+s.bNone+" with no test"; $("featN").textContent=s.features+", with their behaviours"; $("testsN").textContent=s.tests+" test lines"; drawFeats();drawTests();drawGaps(); } function drawFeats(){ if(!R)return;const q=$("fq").value.trim().toLowerCase(),out=[];let n=0; for(const f of R.F){ const hitF=!q||f.name.toLowerCase().includes(q)||f.id.includes(q),hitB=q?f.beh.filter(b=>b.code.includes(q)||b.text.toLowerCase().includes(q)):[]; if(!hitF&&!hitB.length)continue;if(!q&&!showAll&&n>=12){n++;continue}n++; const tot=f.beh.length,w=k=>(f.n[k]/tot*100).toFixed(1)+"%",list=q&&hitB.length?hitB:open.has(f.id)?f.beh:[]; out.push('
'+hl(f.name,q)+''+hl(f.id,q)+''+(f.designed?'':'no design')+''+tot+(tot===1?' behaviour':' behaviours')+'<i class="p" style="width:'+w("tested")+'"><i class="f" style="width:'+w("failing")+'"><i class="n0" style="width:'+w("planned")+'"><i class="n1" style="width:'+w("none")+'">'
+(list.length?''+list.slice(0,40).map(b=>'^'+hl(b.code,q)+' '+hl(b.text.length>170?b.text.slice(0,170)+"…":b.text,q)+''+(b.state==="none"?"no test":b.state)+'').join("")+'':'')+'');
}
$("feats").innerHTML=(out.join("")||'No feature or behaviour matches. Try a code like pipe-3, or a word.')+(!q&&!showAll&&n>12?'Show all '+n+' features':'');
}
$("fq").addEventListener("input",drawFeats);
$("feats").addEventListener("click",e=>{if(e.target.id==="allf"){showAll=true;return drawFeats()}const f=e.target.closest(".feat");if(!f)return;const id=f.dataset.f;open.has(id)?open.delete(id):open.add(id);drawFeats()});
function drawTests(){
const s=R.sum,worst=R.gaps.noTest.slice(0,5);
$("tests").innerHTML=''+esc(r.id)+' '+esc(r.name.slice(0,110))+(r.why?''+esc(r.why.slice(0,200))+'':'')+'').join("")+(R.rep.fails.length>6?'and '+(R.rep.fails.length-6)+' more failing':''):'PassingEvery test in the last run passed ('+s.rPass+')')+''); } function drawGaps(){ const g=R.gaps,li=[]; if(g.noDesign.length)li.push(''+g.noDesign.length+' features have behaviours and no design: '+g.noDesign.slice(0,6).map(f=>esc(f.name)).join(", ")+(g.noDesign.length>6?"…":"")+''); if(g.designOnly.length)li.push(''+g.designOnly.length+' design sections name no behaviour: '+g.designOnly.slice(0,5).map(s=>esc(s.title)).join(", ")+(g.designOnly.length>5?"…":"")+''); const un=R.F.filter(f=>f.n.tested===0&&f.beh.length>1);if(un.length)li.push(''+un.length+' features have no built test at all: '+un.slice(0,6).map(f=>esc(f.name)).join(", ")+(un.length>6?"…":"")+''); $("gaps").innerHTML=li.length?'
const LANES="New",["new","pending",["Working",["in-progress","review"]],["Waiting",["waiting","waiting-error"]],["Waiting on a person",["waiting-input"]],["Done",["done"]]]; let last=new Map(); async function pipe(first){ try{const r=await api("GET","/api/v2/pipeline?status=open"),items=r.data||[];let done=[]; try{done=((await api("GET","/api/v2/pipeline?status=done&limit=4")).data||[]).slice(0,4)}catch(e){} const all=items.concat(done),moved=new Set();for(const i of all){if(!first&&last.has(i.id)&&last.get(i.id)!==i.status)moved.add(i.id)}last=new Map(all.map(i=>[i.id,i.status])); $("pipe").innerHTML='
'+LANES.map(([n,st],k)=>{const ts=all.filter(i=>st.includes(i.status)).sort((a,b)=>String(b.updated).localeCompare(String(a.updated))); return ''+n+''+ts.length+''+ts.slice(0,4).map(t=>'<a class="tk'+(k===3?" you":k===4?" done":"")+'" target="_top" href="/pipeline/'+t.id+'" style="color:inherit;text-decoration:none'+(moved.has(t.id)?'':';animation:none')+'">'+t.id+(t.forRole?" · "+esc(t.forRole):"")+''+esc(String(t.title||"").slice(0,70))+'').join("")+(ts.length>4?'and '+(ts.length-4)+' more':'')+''}).join("")+'';
const mine=items.filter(i=>i.status==="waiting-input"&&me&&[i.waitingOn,i.forName].includes(me.handle)).sort((a,b)=>String(a.updated).localeCompare(String(b.updated)));
needs(mine);
}catch(e){$("pipe").innerHTML=waitLine("the pipeline",e);$("needs").innerHTML=waitLine("the pipeline and your drafts",e)}
}
async function needs(mine){
let dr=0;try{dr=((await api("GET","/api/v2/drafts")).data||[]).length}catch(e){}
const n=mine.length+(dr?1:0);$("needsN").hidden=!n;$("needsN").textContent=n;
$("needs").innerHTML=n?'// ---- the map beside the thought box: the project's classes and their links, and its features; typing a known word goes there
async function domain(){
const C=[];let names=[];
try{names=((await api("GET","/api/v2/topics?category=Spec")).data||[]).map(t=>t.name).slice(0,12)}catch(e){}
for(const n of names){let t="";try{const r=await api("GET","/api/v2/topics/"+n);t=typeof r==="string"?r:""}catch(e){}
const re=/$class\s+(\w+)\s*(([\s\S]?)\n)([^\n])/g;let m;
while((m=re.exec(t))){const fields=[];for(const l of m[2].split("\n")){const f=/^\s*(?:@\w+\s+)(\w+)\s:\s*(?:<>)?\s*(\w+)\s*([?]?)/.exec(l);if(f)fields.push({name:f[1],type:f[2],many:f[3]===""})}
C.push({name:m[1],fields,group:(/@group("([^"])")/.exec(m[3])||[])[1]||"",tip:(/@tooltip("([^"])")/.exec(m[3])||[])[1]||""})}}
return C;
}
async function brain(){
const cv=$("brain"),cap=$("bcap"),g=cv.getContext("2d"),still=matchMedia("(prefers-reduced-motion: reduce)").matches;
const C=await domain(),byName=new Map(C.map(c=>[c.name,c])),N=[],E=[];
for(const c of C)N.push({id:"c:"+c.name,kind:"class",label:c.name,group:c.group,tip:c.tip,words:[c.name.toLowerCase(),c.name.toLowerCase()+"s",c.name.replace(/([a-z])([A-Z])/g,"$1 $2").toLowerCase()],r:7});
const node=new Map(N.map(n=>[n.id,n]));
for(const c of C)for(const f of c.fields)if(byName.has(f.type)&&f.type!==c.name)E.push({a:node.get("c:"+c.name),b:node.get("c:"+f.type),label:f.name+(f.many?" (many)":"")});
const F=(R&&R.F||[]).slice(0,36);
for(const f of F){const n={id:"f:"+f.id,kind:"feature",label:f.name,sub:f.beh.length+" behaviours",words:[f.name.toLowerCase(),f.id],r:4,f};N.push(n);
const text=f.beh.map(b=>b.text).join(" ").toLowerCase();
for(const c of C){const k=text.split(c.name.replace(/([a-z])([A-Z])/g,"$1 $2").toLowerCase()).length-1;if(k>=2)E.push({a:n,b:node.get("c:"+c.name),soft:true,label:"mentions"})}}
if(!N.length){cap.textContent="No classes or features to draw yet: the map fills in from the project's Spec.";return}
// layout: classes on an inner ring by group, features on an outer ring, then a few rounds of springs
const cls=N.filter(n=>n.kind==="class").sort((x,y)=>x.group.localeCompare(y.group)),fe=N.filter(n=>n.kind==="feature");
cls.forEach((n,i)=>{const t=i/Math.max(1,cls.length)6.283;n.x=Math.cos(t)150;n.y=Math.sin(t)110});
fe.forEach((n,i)=>{const t=i/Math.max(1,fe.length)6.283+.3;n.x=Math.cos(t)(cls.length?330:190);n.y=Math.sin(t)(cls.length?240:140)});
for(let k=0;k<160;k++){for(const a of N)for(const b of N){if(a===b)continue;const dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy+1,f=2600/d2;a.x+=dx/Math.sqrt(d2)*f;a.y+=dy/Math.sqrt(d2)*f}
for(const e of E){const dx=e.b.x-e.a.x,dy=e.b.y-e.a.y,d=Math.sqrt(dx*dx+dy*dy)+1,f=(d-(e.soft?170:125))*.02;e.a.x+=dx/d*f;e.a.y+=dy/d*f;e.b.x-=dx/d*f;e.b.y-=dy/d*f}}
const nb=n=>E.filter(e=>e.a===n||e.b===n).map(e=>({n:e.a===n?e.b:e.a,e}));
let W=0,H=0,dpr=1,cam={x:0,y:0,z:1},to={x:0,y:0,z:1},focus=null,pulses=[],t0=0;
const col=k=>getComputedStyle(document.documentElement).getPropertyValue(k).trim();
function size(){dpr=Math.min(2,devicePixelRatio||1);W=cv.clientWidth;H=cv.clientHeight;cv.width=Wdpr;cv.height=Hdpr}
function fit(){let x0=1e9,x1=-1e9,y0=1e9,y1=-1e9;for(const n of N){x0=Math.min(x0,n.x);x1=Math.max(x1,n.x);y0=Math.min(y0,n.y);y1=Math.max(y1,n.y)}
to={x:(x0+x1)/2,y:(y0+y1)/2,z:Math.min(W/(x1-x0+120),H/(y1-y0+70))}}
function go(n){focus=n;if(!n){fit();cap.innerHTML=C.length?""+C.length+" classes and their links"+(F.length?", with the "+F.length+" largest features around them":"")+". Type a name on the left, or tap a dot.":""+F.length+" features. This project has no class model yet, so the map shows its features only.";return}
const ns=nb(n);let x0=n.x,x1=n.x,y0=n.y,y1=n.y;for(const {n:m} of ns){x0=Math.min(x0,m.x);x1=Math.max(x1,m.x);y0=Math.min(y0,m.y);y1=Math.max(y1,m.y)}
to={x:(x0+x1)/2,y:(y0+y1)/2,z:Math.min(2.2,W/(x1-x0+220),H/(y1-y0+120))};pulses=ns.slice(0,12).map(({n:m},i)=>({a:n,b:m,p:-i*.12}));
const links=ns.filter(x=>!x.e.soft).map(x=>esc(x.e.a===n?x.e.label+" → "+x.n.label:x.n.label+"."+x.e.label)),near=ns.filter(x=>x.e.soft).map(x=>esc(x.n.label));
cap.innerHTML=""+esc(n.label)+" "+(n.kind==="class"?"class"+(n.group?" in "+esc(n.group):""):"feature, "+esc(n.sub))+(n.tip?": "+esc(n.tip):"")+(links.length?"
"+links.slice(0,6).join(" · "):"")+(near.length?"
"+(n.kind==="class"?"Features that mention it: ":"Mentions: ")+near.slice(0,6).join(", "):"")}
function draw(ts){
const dt=Math.min(.05,(ts-t0)/1000||0);t0=ts;const k=still?1:Math.min(1,dt*4);cam.x+=(to.x-cam.x)*k;cam.y+=(to.y-cam.y)k;cam.z+=(to.z-cam.z)k;
const ink=col("--ink"),soft=col("--soft"),line=col("--line"),acc=col("--accent"),attn=col("--attn"),leaf=col("--leaf");
g.setTransform(dpr,0,0,dpr,0,0);g.clearRect(0,0,W,H);g.translate(W/2,H/2);g.scale(cam.z,cam.z);g.translate(-cam.x,-cam.y);
const hot=new Set(focus?[focus,...nb(focus).map(x=>x.n)]:[]);
for(const e of E){const on=focus&&(e.a===focus||e.b===focus);g.beginPath();g.moveTo(e.a.x,e.a.y);g.lineTo(e.b.x,e.b.y);g.strokeStyle=on?acc:line;g.globalAlpha=on?.9:focus?.25:e.soft?.45:.9;g.lineWidth=(on?1.6:1)/cam.z;g.setLineDash(e.soft?[4/cam.z,4/cam.z]:[]);g.stroke()}
g.setLineDash([]);g.globalAlpha=1;
if(!still)for(const p of pulses){p.p+=dt.9;if(p.p>1)p.p-=1.25;if(p.p<0)continue;g.beginPath();g.arc(p.a.x+(p.b.x-p.a.x)*p.p,p.a.y+(p.b.y-p.a.y)*p.p,3/cam.z,0,6.3);g.fillStyle=attn;g.fill()}
for(const n of N){const on=hot.has(n),r=(n===focus?n.r+3:n.r);g.globalAlpha=focus&&!on?.3:1;g.beginPath();g.arc(n.x,n.y,r,0,6.3);g.fillStyle=n===focus?acc:n.kind==="class"?ink:leaf;g.fill();
if(focus?on:(n.kind==="class"||cam.z>1.5)){g.font=(n===focus?"700 ":n.kind==="class"?"600 ":"400 ")+(12/Math.max(.75,cam.z)(n===focus?1.15:1))+"px system-ui,sans-serif";g.fillStyle=n===focus?acc:n.kind==="class"?ink:soft;g.textAlign="center";g.fillText(n.label.length>26?n.label.slice(0,25)+"…":n.label,n.x,n.y-r-5/cam.z)}}
g.globalAlpha=1;requestAnimationFrame(draw)}
// What the map knows of what is being typed. Every node has keys: its names, and the longer words of its label.
// The word under the caret matches by its beginning, so the map stays put while a word is being finished
// ("pipe" → "pipel" → "pipeline"); words already finished match whole.
const STOP=new Set(["the","and","for","with","from","that","this","page","pages","your","their","into","over","when","what","each","every","more"]);
for(const n of N){const ks=new Set(n.words);for(const w of n.label.toLowerCase().split(/[^a-z0-9]+/))if(w.length>=4&&!STOP.has(w))ks.add(w);n.keys=[...ks].filter(k=>k.length>=3)}
// Completion: what the word under the caret could become, best first. A thing linked to something already
// named in the text comes before one that isn't; Tab takes the first, Tab again the next.
let sug=[],si=0,part0="",moved=false;
const said=t=>{const s=new Set();for(const n of N)for(const k of n.keys){const i=t.indexOf(k);if(i>=0&&!/[a-z0-9]/.test(t[i-1]||" ")&&!/[a-z0-9]/.test(t[i+k.length]||" ")){s.add(n);break}}return s};
const wordFor=(n,part)=>n.kind==="class"?n.label:(n.label.toLowerCase().split(/[^a-z0-9]+/).find(w=>w.startsWith(part)&&w.length>=4)||n.keys.find(k=>k.startsWith(part))||n.label.toLowerCase());
// where the caret is inside the textarea, by laying the same text out in a hidden copy
function caretXY(x,pos){const m=document.createElement("div"),cs=getComputedStyle(x);
for(const p of ["boxSizing","width","fontFamily","fontSize","fontWeight","lineHeight","letterSpacing","paddingTop","paddingRight","paddingBottom","paddingLeft","borderTopWidth","borderLeftWidth","borderRightWidth","tabSize"])m.style[p]=cs[p];
m.style.cssText+=";position:absolute;visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow:hidden;top:0;left:0";
m.textContent=x.value.slice(0,pos);const s=document.createElement("span");s.textContent="";m.appendChild(s);x.parentNode.appendChild(m);
const r={x:s.offsetLeft,y:s.offsetTop-x.scrollTop,h:parseFloat(cs.lineHeight)||s.offsetHeight||18};m.remove();return r}
function draw_sug(){const p=$("sug"),x=$("tx");if(!sug.length){p.hidden=true;p.innerHTML="";return}
p.innerHTML=sug.map((s,k)=>{const t=s.text,l=part0.length;return '