@caddis/cli 0.4.72 → 0.4.74
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_coordinate.py +123 -6
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +46 -1
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +9 -0
- package/bundles/antigravity-plugin/scripts/templates/coordinator.md +5 -2
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +29 -5
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/SKILL.md +193 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/assets/viewer-template.html +190 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/references/edge-list-sql.md +105 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/check-render.mjs +139 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +37 -2
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/SKILL.md +193 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/assets/viewer-template.html +190 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/references/edge-list-sql.md +105 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/check-render.mjs +139 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Case Flow Chart</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Flow chart page from the flow-chart-from-counts skill.
|
|
9
|
+
One .fv block per chart. Each block names a <script type="text/plain"> that holds the Mermaid source.
|
|
10
|
+
--canvas stays light in dark mode on purpose: the coloured boxes use dark text and need a light ground. */
|
|
11
|
+
:root{
|
|
12
|
+
--bg:#faf8f5; --surface:#ffffff; --ink-1:#1c1917; --ink-2:#57534e; --ink-3:#78716c; --line:#e7e2da; --canvas:#fbfaf8;
|
|
13
|
+
--sans:"Segoe UI",system-ui,-apple-system,Arial,sans-serif; --mono:Consolas,ui-monospace,"Courier New",monospace;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
|
|
16
|
+
--bg:#23252f; --surface:#2c2f3b; --ink-1:#f3f1ec; --ink-2:#c9c6c0; --ink-3:#a19d97; --line:#3f4250; --canvas:#f4f2ee; color-scheme:dark}}
|
|
17
|
+
:root[data-theme="dark"]{
|
|
18
|
+
--bg:#23252f; --surface:#2c2f3b; --ink-1:#f3f1ec; --ink-2:#c9c6c0; --ink-3:#a19d97; --line:#3f4250; --canvas:#f4f2ee; color-scheme:dark}
|
|
19
|
+
*{box-sizing:border-box}
|
|
20
|
+
body{margin:0;background:var(--bg);color:var(--ink-1);font-family:var(--sans);font-size:14px;line-height:1.55}
|
|
21
|
+
.wrap{max-width:1000px;margin:0 auto;padding:24px 16px 56px;display:grid;gap:20px}
|
|
22
|
+
h1{font-size:22px;margin:0}
|
|
23
|
+
p{margin:0;max-width:74ch}
|
|
24
|
+
.small{font-size:13px;color:var(--ink-2)}
|
|
25
|
+
.legendrow{display:flex;flex-wrap:wrap;gap:14px;font-size:12px;color:var(--ink-2)}
|
|
26
|
+
.sw{display:inline-block;width:12px;height:12px;border-radius:3px;margin-right:5px;vertical-align:-2px;border:1px solid var(--ink-3)}
|
|
27
|
+
.sw.thick{border-width:3px}
|
|
28
|
+
.sw.dash{border-style:dashed;border-width:2px}
|
|
29
|
+
|
|
30
|
+
/* Viewer */
|
|
31
|
+
.fv{border:1px solid var(--line);border-radius:8px;background:var(--canvas);overflow:hidden;display:flex;flex-direction:column}
|
|
32
|
+
.fv-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;padding:8px 10px;border-bottom:1px solid var(--line);background:var(--surface)}
|
|
33
|
+
.fv-hint{font-size:12px;color:var(--ink-3)}
|
|
34
|
+
.fv-btns{display:flex;gap:6px;align-items:center}
|
|
35
|
+
.fv-btns button{font:inherit;font-size:13px;min-width:34px;padding:5px 10px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink-1);cursor:pointer}
|
|
36
|
+
.fv-btns button:hover{border-color:var(--ink-3)}
|
|
37
|
+
.fv-btns button:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
|
|
38
|
+
.fv-pct{font-family:var(--mono);font-size:12px;color:var(--ink-2);min-width:44px;text-align:center}
|
|
39
|
+
.fv-view{position:relative;height:70vh;min-height:420px;overflow:hidden;cursor:grab;touch-action:pan-y;background:var(--canvas)}
|
|
40
|
+
.fv-view.drag{cursor:grabbing}
|
|
41
|
+
.fv-stage{position:absolute;left:0;top:0;transform-origin:0 0}
|
|
42
|
+
.fv-stage > p{color:#57534e;padding:12px}
|
|
43
|
+
/* The title is HTML inside the zoomable stage, so it zooms with the chart and stays in full screen and screenshots. */
|
|
44
|
+
.fv-title{font-size:22px;line-height:1.35;color:#1c1917;padding:8px 0 14px;text-align:center}
|
|
45
|
+
.fv-title b{font-weight:700}
|
|
46
|
+
.fv-title span{display:block;font-size:15px;color:#57534e}
|
|
47
|
+
.fv-stage svg{max-width:none!important;display:block}
|
|
48
|
+
.fv.expanded{position:fixed;inset:0;z-index:50;border-radius:0}
|
|
49
|
+
.fv.expanded .fv-view{height:auto;flex:1}
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<div class="wrap">
|
|
54
|
+
<h1>How support cases moved on Mon 5 Oct 2026</h1>
|
|
55
|
+
<p class="small">Example data. Replace the title, the legend, the chart source and the note.</p>
|
|
56
|
+
|
|
57
|
+
<div class="legendrow" aria-label="Legend">
|
|
58
|
+
<span><span class="sw" style="background:#e7e5e4"></span>Automatic step</span>
|
|
59
|
+
<span><span class="sw thick" style="background:#bbf7d0;border-color:#15803d"></span>In scope (thick border)</span>
|
|
60
|
+
<span><span class="sw dash" style="background:#fde68a;border-color:#a16207"></span>Optional, owner choice (dashed)</span>
|
|
61
|
+
<span><span class="sw" style="background:#f6e7d6"></span>Hand-off to a team</span>
|
|
62
|
+
<span><span class="sw" style="background:#ffffff;border-color:#57534e;border-width:2px"></span>Start</span>
|
|
63
|
+
<span><span class="sw" style="background:#eceae6"></span>Case ends</span>
|
|
64
|
+
<span><span class="sw dash" style="background:#ffffff;border-color:#a8a29e;border-width:1px"></span>Stub: the path continues at the box named</span>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<!-- One chart. data-src = id of the script that holds the Mermaid source.
|
|
68
|
+
data-title and data-sub are plain text (set with textContent). data-label is the accessible name. -->
|
|
69
|
+
<div class="fv" data-src="chart1"
|
|
70
|
+
data-title="Support cases: Mon 5 Oct 2026"
|
|
71
|
+
data-sub="(4 Oct 23:00 to 5 Oct 23:00 UTC). Counts in brackets are cases. Paths under 20 cases are not drawn."
|
|
72
|
+
data-label="Flow of a support case on 5 October, from intake to self-service, team queues and case ends">
|
|
73
|
+
<div class="fv-bar">
|
|
74
|
+
<span class="fv-hint">Drag to move. Use + and − or Ctrl + scroll to zoom.</span>
|
|
75
|
+
<span class="fv-btns">
|
|
76
|
+
<button type="button" data-z="out" aria-label="Zoom out">−</button>
|
|
77
|
+
<span class="fv-pct">100%</span>
|
|
78
|
+
<button type="button" data-z="in" aria-label="Zoom in">+</button>
|
|
79
|
+
<button type="button" data-z="fit">Fit</button>
|
|
80
|
+
<button type="button" data-z="full">Full screen</button>
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="fv-view"><div class="fv-stage"><p class="small">Drawing the flow chart…</p></div></div>
|
|
84
|
+
</div>
|
|
85
|
+
<script type="text/plain" id="chart1">
|
|
86
|
+
flowchart TD
|
|
87
|
+
S(["Cases opened<br/>(2,400)"]) --> I["INTAKE<br/>Web form"]
|
|
88
|
+
I -->|"(180)"| X1(["Case ends"])
|
|
89
|
+
I -->|"(2,220)"| T["TRIAGE<br/>Rules engine<br/>(2,220)"]
|
|
90
|
+
T -->|"(1,300)"| A["SELF-SERVICE<br/>Account help<br/>(1,300)"]
|
|
91
|
+
T -->|"(about 600)"| Q["Team queues"]
|
|
92
|
+
T -->|"(320)"| C["CHECK, owner choice<br/>Fraud check"]
|
|
93
|
+
C -->|"(300)"| Q2["Fraud team queue"]
|
|
94
|
+
A -->|"(1,100)"| X2(["Case ends after self-service"])
|
|
95
|
+
A --> B(["back to Triage (190)"])
|
|
96
|
+
subgraph AFTER["After a team picks up a case"]
|
|
97
|
+
direction LR
|
|
98
|
+
AG["Agent"] -->|"(700)"| X3(["Case ends"])
|
|
99
|
+
end
|
|
100
|
+
X2 ~~~ AFTER
|
|
101
|
+
classDef auto fill:#e7e5e4,stroke:#78716c,color:#1c1917;
|
|
102
|
+
classDef scope fill:#bbf7d0,stroke:#15803d,stroke-width:3px,color:#1c1917;
|
|
103
|
+
classDef choice fill:#fde68a,stroke:#a16207,stroke-width:2px,stroke-dasharray:6 4,color:#1c1917;
|
|
104
|
+
classDef queue fill:#f6e7d6,stroke:#b45309,color:#1c1917;
|
|
105
|
+
classDef endn fill:#eceae6,stroke:#78716c,color:#1c1917;
|
|
106
|
+
classDef startn fill:#ffffff,stroke:#57534e,stroke-width:2px,color:#1c1917;
|
|
107
|
+
classDef stub fill:#ffffff,stroke:#a8a29e,stroke-dasharray:3 3,color:#57534e;
|
|
108
|
+
class I,T auto;
|
|
109
|
+
class A scope;
|
|
110
|
+
class C choice;
|
|
111
|
+
class Q,Q2,AG queue;
|
|
112
|
+
class X1,X2,X3 endn;
|
|
113
|
+
class S startn;
|
|
114
|
+
class B stub;
|
|
115
|
+
style AFTER fill:#fbfaf8,stroke:#a8a29e,stroke-dasharray:4 4,color:#57534e;
|
|
116
|
+
</script>
|
|
117
|
+
<p class="small">Every number counts distinct cases: an arrow counts the cases that took that path, a box counts the cases that reached it. Arrows into a box can sum to a little more than the box, because a case can reach it by two paths. "Case ends" means the data shows no further step; it does not say why. "Team queues" merges four small queues (about 600). Not drawn: 3 paths with fewer than 20 cases each (41 in total).</p>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js" integrity="sha512-6a80OTZVmEJhqYJUmYd5z8yHUCDlYnj6q9XwB/gKOEyNQV/Q8u+XeSG59a2ZKFEHGTYzgfOQKYEBtrZV7vBr+Q==" crossorigin="anonymous"></script>
|
|
121
|
+
<script>
|
|
122
|
+
(function(){
|
|
123
|
+
if(!window.mermaid){
|
|
124
|
+
document.querySelectorAll('.fv-stage').forEach(function(s){s.innerHTML='<p class="small">The chart library did not load, so the flow chart cannot be drawn.</p>';});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try{
|
|
128
|
+
mermaid.initialize({startOnLoad:false,securityLevel:'strict',theme:'base',
|
|
129
|
+
themeVariables:{fontFamily:'Segoe UI, system-ui, sans-serif',fontSize:'15px',lineColor:'#57534e',primaryTextColor:'#1c1917',edgeLabelBackground:'#ffffff',background:'#fbfaf8'},
|
|
130
|
+
flowchart:{htmlLabels:true,curve:'basis',nodeSpacing:40,rankSpacing:55,padding:10}});
|
|
131
|
+
}catch(e){console.error('mermaid.initialize failed',e);}
|
|
132
|
+
var n=0;
|
|
133
|
+
function viewer(fv){
|
|
134
|
+
var view=fv.querySelector('.fv-view'),stage=fv.querySelector('.fv-stage'),pct=fv.querySelector('.fv-pct'),full=fv.querySelector('button[data-z="full"]');
|
|
135
|
+
var k=1,x=0,y=0,w=0,h=0,fitK=1;
|
|
136
|
+
function msg(t){stage.innerHTML='<p class="small"></p>';stage.firstChild.textContent=t;}
|
|
137
|
+
// Page scroll stays free on a phone until the chart is zoomed in or in full screen; then a drag pans the chart.
|
|
138
|
+
function touch(){view.style.touchAction=(fv.classList.contains('expanded')||k>fitK*1.01)?'none':'pan-y';}
|
|
139
|
+
function apply(){touch();stage.style.transform='translate('+x+'px,'+y+'px) scale('+k+')';pct.textContent=Math.round(k*100)+'%';}
|
|
140
|
+
function fit(){var vw=view.clientWidth,vh=view.clientHeight;if(!w)return;k=Math.min(vw/w,vh/h)*0.96;fitK=k;x=(vw-w*k)/2;y=(vh-h*k)/2;apply();}
|
|
141
|
+
function zoomAt(f,cx,cy){var nk=Math.max(Math.min(0.15,fitK),Math.min(4,k*f));x=cx-(cx-x)*nk/k;y=cy-(cy-y)*nk/k;k=nk;apply();}
|
|
142
|
+
function title(){
|
|
143
|
+
var d=document.createElement('div');d.className='fv-title';
|
|
144
|
+
var b=document.createElement('b');b.textContent=fv.dataset.title||'';d.appendChild(b);
|
|
145
|
+
if(fv.dataset.sub){var s=document.createElement('span');s.textContent=fv.dataset.sub;d.appendChild(s);}
|
|
146
|
+
return d;
|
|
147
|
+
}
|
|
148
|
+
var src=document.getElementById(fv.dataset.src);
|
|
149
|
+
var done=Promise.resolve();
|
|
150
|
+
if(!src){msg('The flow chart source is missing.');}
|
|
151
|
+
else try{
|
|
152
|
+
done=mermaid.render('flowsvg'+(n++),src.textContent).then(function(r){
|
|
153
|
+
stage.innerHTML=r.svg;stage.insertBefore(title(),stage.firstChild);
|
|
154
|
+
var svg=stage.querySelector('svg');
|
|
155
|
+
var vb=svg.viewBox.baseVal;w=vb&&vb.width?vb.width:svg.getBBox().width;h=vb&&vb.height?vb.height:svg.getBBox().height;
|
|
156
|
+
svg.setAttribute('width',w);svg.setAttribute('height',h);svg.style.maxWidth='none';
|
|
157
|
+
w=Math.max(w,stage.scrollWidth);h=stage.scrollHeight;
|
|
158
|
+
svg.setAttribute('role','img');svg.setAttribute('aria-label',fv.dataset.label||'Flow chart');
|
|
159
|
+
fit();
|
|
160
|
+
}).catch(function(e){console.error(e);msg('The flow chart could not be drawn: '+e.message);});
|
|
161
|
+
}catch(e){console.error(e);msg('The flow chart could not be drawn: '+e.message);}
|
|
162
|
+
function toggle(){var on=!fv.classList.contains('expanded');fv.classList.toggle('expanded',on);full.textContent=on?'Close':'Full screen';
|
|
163
|
+
document.body.style.overflow=on?'hidden':'';
|
|
164
|
+
// Real full screen when the browser allows it; the fixed-position .expanded class is the fallback.
|
|
165
|
+
try{if(on&&fv.requestFullscreen)fv.requestFullscreen().catch(function(){});else if(!on&&document.fullscreenElement)document.exitFullscreen().catch(function(){});}catch(e){}
|
|
166
|
+
setTimeout(fit,60);}
|
|
167
|
+
fv.addEventListener('click',function(ev){var b=ev.target.closest('button[data-z]');if(!b)return;var c=[view.clientWidth/2,view.clientHeight/2];
|
|
168
|
+
if(b.dataset.z==='in')zoomAt(1.25,c[0],c[1]);else if(b.dataset.z==='out')zoomAt(0.8,c[0],c[1]);else if(b.dataset.z==='fit')fit();else toggle();});
|
|
169
|
+
document.addEventListener('fullscreenchange',function(){if(!document.fullscreenElement&&fv.classList.contains('expanded')){fv.classList.remove('expanded');full.textContent='Full screen';document.body.style.overflow='';setTimeout(fit,60);}});
|
|
170
|
+
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&fv.classList.contains('expanded'))toggle();});
|
|
171
|
+
// Plain scroll keeps scrolling the page; Ctrl + scroll (or any scroll in full screen) zooms.
|
|
172
|
+
view.addEventListener('wheel',function(e){if(!(e.ctrlKey||e.metaKey||fv.classList.contains('expanded')))return;e.preventDefault();var r=view.getBoundingClientRect();zoomAt(e.deltaY<0?1.12:0.89,e.clientX-r.left,e.clientY-r.top);},{passive:false});
|
|
173
|
+
var pts={};
|
|
174
|
+
view.addEventListener('pointerdown',function(e){view.setPointerCapture(e.pointerId);pts[e.pointerId]=[e.clientX,e.clientY];view.classList.add('drag');});
|
|
175
|
+
view.addEventListener('pointermove',function(e){if(!pts[e.pointerId])return;var p=pts[e.pointerId],ids=Object.keys(pts);
|
|
176
|
+
if(ids.length===2){var o=pts[ids[0]==e.pointerId?ids[1]:ids[0]];var d0=Math.hypot(p[0]-o[0],p[1]-o[1]);var d1=Math.hypot(e.clientX-o[0],e.clientY-o[1]);var r=view.getBoundingClientRect();if(d0>0)zoomAt(d1/d0,(e.clientX+o[0])/2-r.left,(e.clientY+o[1])/2-r.top);}
|
|
177
|
+
else{x+=e.clientX-p[0];y+=e.clientY-p[1];apply();}
|
|
178
|
+
pts[e.pointerId]=[e.clientX,e.clientY];});
|
|
179
|
+
function up(e){delete pts[e.pointerId];if(!Object.keys(pts).length)view.classList.remove('drag');}
|
|
180
|
+
view.addEventListener('pointerup',up);view.addEventListener('pointercancel',up);
|
|
181
|
+
window.addEventListener('resize',fit);
|
|
182
|
+
return done;
|
|
183
|
+
}
|
|
184
|
+
// Render one chart after another, so each chart finishes before the next one starts.
|
|
185
|
+
var all=document.querySelectorAll('.fv[data-src]'),i=0;
|
|
186
|
+
(function next(){if(i<all.length){Promise.resolve(viewer(all[i++])).then(next,next);}})();
|
|
187
|
+
})();
|
|
188
|
+
</script>
|
|
189
|
+
</body>
|
|
190
|
+
</html>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Edge list from an event table (SQL pattern)
|
|
2
|
+
|
|
3
|
+
Use this when the steps of each case are rows in an event table. The output is the edge list that
|
|
4
|
+
`scripts/edges_to_mermaid.py` reads: `from,to,count`, plus a box-count list `name,count`.
|
|
5
|
+
|
|
6
|
+
The SQL is ANSI with window functions. It runs on most SQL engines with small changes
|
|
7
|
+
(parameter style, date functions).
|
|
8
|
+
|
|
9
|
+
## Input shape
|
|
10
|
+
|
|
11
|
+
One row per step a case took:
|
|
12
|
+
|
|
13
|
+
| column | meaning |
|
|
14
|
+
|-------------|-----------------------------------------------------------------------------|
|
|
15
|
+
| `case_id` | the unit you count: a call, an order, a ticket, a session |
|
|
16
|
+
| `step` | the step name, in the words of the team that owns the process |
|
|
17
|
+
| `step_time` | when the step started, in UTC |
|
|
18
|
+
| `step_seq` | a tie-breaker when two steps share one timestamp (an event id or a counter) |
|
|
19
|
+
|
|
20
|
+
## Query
|
|
21
|
+
|
|
22
|
+
```sql
|
|
23
|
+
-- 1. Choose the cases by their START time in the window, then take ALL their steps.
|
|
24
|
+
-- If you filter steps by time instead, a case that crosses the window edge loses its
|
|
25
|
+
-- first or last steps, and the chart shows false starts and false ends.
|
|
26
|
+
WITH cases AS (
|
|
27
|
+
SELECT case_id
|
|
28
|
+
FROM events
|
|
29
|
+
GROUP BY case_id
|
|
30
|
+
HAVING MIN(step_time) >= :window_start_utc
|
|
31
|
+
AND MIN(step_time) < :window_end_utc
|
|
32
|
+
),
|
|
33
|
+
ordered AS (
|
|
34
|
+
SELECT e.case_id, e.step, e.step_time, e.step_seq,
|
|
35
|
+
LAG(e.step) OVER (PARTITION BY e.case_id ORDER BY e.step_time, e.step_seq) AS prev_step
|
|
36
|
+
FROM events e
|
|
37
|
+
JOIN cases c ON c.case_id = e.case_id
|
|
38
|
+
),
|
|
39
|
+
-- 2. Collapse consecutive repeats: A, A, B becomes A, B. A repeat is not a path.
|
|
40
|
+
collapsed AS (
|
|
41
|
+
SELECT case_id, step, step_time, step_seq
|
|
42
|
+
FROM ordered
|
|
43
|
+
WHERE prev_step IS NULL OR prev_step <> step
|
|
44
|
+
),
|
|
45
|
+
-- 3. The next step of each step. No next step means the case ends: 'END'.
|
|
46
|
+
pairs AS (
|
|
47
|
+
SELECT case_id,
|
|
48
|
+
step AS from_step,
|
|
49
|
+
COALESCE(LEAD(step) OVER (PARTITION BY case_id ORDER BY step_time, step_seq), 'END') AS to_step,
|
|
50
|
+
ROW_NUMBER() OVER (PARTITION BY case_id ORDER BY step_time, step_seq) AS rn
|
|
51
|
+
FROM collapsed
|
|
52
|
+
)
|
|
53
|
+
-- 4. Count DISTINCT cases per pair: the same unit as the boxes. Add one START row per case.
|
|
54
|
+
-- path_count (times taken) is kept only to compare; do not draw it.
|
|
55
|
+
SELECT from_step AS "from", to_step AS "to",
|
|
56
|
+
COUNT(DISTINCT case_id) AS "count",
|
|
57
|
+
COUNT(*) AS path_count
|
|
58
|
+
FROM pairs
|
|
59
|
+
GROUP BY from_step, to_step
|
|
60
|
+
HAVING COUNT(DISTINCT case_id) >= :min_cases
|
|
61
|
+
UNION ALL
|
|
62
|
+
SELECT 'START', from_step, COUNT(*), COUNT(*)
|
|
63
|
+
FROM pairs
|
|
64
|
+
WHERE rn = 1
|
|
65
|
+
GROUP BY from_step
|
|
66
|
+
ORDER BY 3 DESC;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Box counts (distinct cases per step), from the same `collapsed` set:
|
|
70
|
+
|
|
71
|
+
```sql
|
|
72
|
+
SELECT step AS name, COUNT(DISTINCT case_id) AS count
|
|
73
|
+
FROM collapsed
|
|
74
|
+
GROUP BY step;
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## What the numbers mean
|
|
78
|
+
|
|
79
|
+
- **Draw distinct cases on the arrows, the same unit as the boxes.** `count` is
|
|
80
|
+
`COUNT(DISTINCT case_id)` per pair. `path_count` (`COUNT(*)`) counts each time a path was taken,
|
|
81
|
+
so a case that loops (A, B, A, B) counts twice. A business reader then sees a box of 8,538
|
|
82
|
+
followed by an arrow of 8,541 and stops trusting the chart. On one measured day `COUNT(*)`
|
|
83
|
+
overstated an arrow by 4%. Keep `path_count` only to compare.
|
|
84
|
+
- **Self-check: no arrow into or out of a box exceeds the box's distinct count.** If one does,
|
|
85
|
+
the query counts occurrences, not cases. The converter warns on this when it has box counts.
|
|
86
|
+
- **Arrows into a box can sum to a little more than the box** when a case reaches it by two
|
|
87
|
+
paths. That is correct; say so in the note under the chart.
|
|
88
|
+
- **`HAVING` hides small paths.** Apply the threshold to either count, but label every number on
|
|
89
|
+
the chart as cases. Run the query once without it, or sum the hidden rows, so the
|
|
90
|
+
note under the chart can name what is not drawn and how many cases that is. A chart that hides
|
|
91
|
+
paths without saying so looks complete and is not.
|
|
92
|
+
- **The tie-breaker matters.** Without `step_seq`, two steps at one timestamp come out in any
|
|
93
|
+
order, and `LAG`/`LEAD` give different paths on each run. Check: run the query twice and compare.
|
|
94
|
+
- **Time zone.** Filter in UTC. When the reader thinks in local days, convert the local day to a
|
|
95
|
+
UTC window first, and put both on the chart title, for example
|
|
96
|
+
`Mon 5 Oct 2026 (4 Oct 23:00 to 5 Oct 23:00 UTC)`.
|
|
97
|
+
|
|
98
|
+
## Check the edge list before you draw it
|
|
99
|
+
|
|
100
|
+
- The `START` rows add up to the number of cases in the window.
|
|
101
|
+
- Before the `HAVING` filter, for each step the paths in equal the paths out (with `END`
|
|
102
|
+
counted as out). A gap means a filter dropped rows, or a step name differs in spelling
|
|
103
|
+
between rows.
|
|
104
|
+
- Run the converter: its note lists every hidden path. Read that list. A large hidden total means
|
|
105
|
+
the minimum is too high, or many small steps need to be merged into one named box.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Render check for a flow chart page made with the flow-chart-from-counts skill.
|
|
2
|
+
//
|
|
3
|
+
// Opens the page headless, waits for every .fv chart to draw, and fails when a chart has no SVG,
|
|
4
|
+
// shows an error message, has fewer nodes than --min-nodes, or the page throws. It saves one
|
|
5
|
+
// screenshot per chart and one of the first chart in full screen, then prints a JSON report.
|
|
6
|
+
// Node overlaps and edge labels that sit on a box are reported as warnings: open the screenshots
|
|
7
|
+
// to judge them, because some overlaps are harmless.
|
|
8
|
+
//
|
|
9
|
+
// Usage (run from a folder whose node_modules resolves "playwright"):
|
|
10
|
+
// node <skill folder>/scripts/check-render.mjs page.html --out shots [--channel msedge]
|
|
11
|
+
// [--min-nodes 3] [--width 1280] [--height 900] [--timeout 30000]
|
|
12
|
+
//
|
|
13
|
+
// Exit code: 0 pass, 1 a check failed, 2 bad arguments or Playwright not found.
|
|
14
|
+
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const opts = { out: 'shots', channel: '', minNodes: 3, width: 1280, height: 900, timeout: 30000 };
|
|
22
|
+
const rest = [];
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
const next = () => { if (i + 1 >= argv.length) throw new Error(`${a} needs a value`); return argv[++i]; };
|
|
26
|
+
if (a === '--out') opts.out = next();
|
|
27
|
+
else if (a === '--channel') opts.channel = next();
|
|
28
|
+
else if (a === '--min-nodes') opts.minNodes = Number(next());
|
|
29
|
+
else if (a === '--width') opts.width = Number(next());
|
|
30
|
+
else if (a === '--height') opts.height = Number(next());
|
|
31
|
+
else if (a === '--timeout') opts.timeout = Number(next());
|
|
32
|
+
else if (a.startsWith('--')) throw new Error(`unknown option ${a}`);
|
|
33
|
+
else rest.push(a);
|
|
34
|
+
}
|
|
35
|
+
if (rest.length !== 1) throw new Error('give exactly one page path');
|
|
36
|
+
opts.page = path.resolve(rest[0]);
|
|
37
|
+
return opts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let opts;
|
|
41
|
+
try { opts = parseArgs(process.argv.slice(2)); }
|
|
42
|
+
catch (e) { console.error(`error: ${e.message}`); process.exit(2); }
|
|
43
|
+
|
|
44
|
+
if (!existsSync(opts.page)) {
|
|
45
|
+
console.error(`error: page file not found: ${opts.page}`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let chromium;
|
|
50
|
+
try {
|
|
51
|
+
// Resolve playwright from the current folder, not from this script's folder (the skill folder has none).
|
|
52
|
+
({ chromium } = createRequire(path.join(process.cwd(), 'noop.js'))('playwright'));
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('error: playwright not found from the current folder. Run this from a folder whose node_modules has playwright.');
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mkdirSync(opts.out, { recursive: true });
|
|
59
|
+
const browser = await chromium.launch(opts.channel ? { channel: opts.channel } : {});
|
|
60
|
+
const failures = [];
|
|
61
|
+
const warnings = [];
|
|
62
|
+
try {
|
|
63
|
+
const page = await browser.newPage({ viewport: { width: opts.width, height: opts.height } });
|
|
64
|
+
const pageErrors = [];
|
|
65
|
+
page.on('pageerror', (e) => pageErrors.push(e.message));
|
|
66
|
+
await page.goto(pathToFileURL(opts.page).href);
|
|
67
|
+
|
|
68
|
+
const total = await page.locator('.fv[data-src]').count();
|
|
69
|
+
if (total === 0) failures.push('no .fv[data-src] chart blocks on the page');
|
|
70
|
+
|
|
71
|
+
// Wait until every chart has an SVG or a message that is not the loading text.
|
|
72
|
+
await page.waitForFunction(() => [...document.querySelectorAll('.fv[data-src]')].every((fv) => {
|
|
73
|
+
const st = fv.querySelector('.fv-stage');
|
|
74
|
+
const p = st && st.querySelector(':scope > p');
|
|
75
|
+
return st && (st.querySelector('svg') || (p && !/Drawing/.test(p.textContent)));
|
|
76
|
+
}), null, { timeout: opts.timeout }).catch(() => failures.push(`charts not drawn within ${opts.timeout} ms`));
|
|
77
|
+
|
|
78
|
+
const report = await page.evaluate(() => {
|
|
79
|
+
const rect = (el) => el.getBoundingClientRect();
|
|
80
|
+
const overlap = (a, b) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
|
|
81
|
+
return [...document.querySelectorAll('.fv[data-src]')].map((fv) => {
|
|
82
|
+
const stage = fv.querySelector('.fv-stage');
|
|
83
|
+
const svg = stage.querySelector('svg');
|
|
84
|
+
const msg = stage.querySelector(':scope > p');
|
|
85
|
+
const nodes = svg ? [...svg.querySelectorAll('g.node')] : [];
|
|
86
|
+
const boxes = nodes.map((n) => rect(n));
|
|
87
|
+
let nodeOverlaps = 0;
|
|
88
|
+
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) if (overlap(boxes[i], boxes[j])) nodeOverlaps++;
|
|
89
|
+
const labels = svg ? [...svg.querySelectorAll('g.edgeLabel')].filter((l) => l.textContent.trim()) : [];
|
|
90
|
+
let labelsOnBoxes = 0;
|
|
91
|
+
for (const l of labels) { const r = rect(l); if (r.width && boxes.some((b) => overlap(r, b))) labelsOnBoxes++; }
|
|
92
|
+
const text = svg ? svg.textContent : '';
|
|
93
|
+
return {
|
|
94
|
+
src: fv.dataset.src,
|
|
95
|
+
title: fv.dataset.title || '',
|
|
96
|
+
sub: fv.dataset.sub || '',
|
|
97
|
+
svg: !!svg,
|
|
98
|
+
nodes: nodes.length,
|
|
99
|
+
edges: svg ? svg.querySelectorAll('.edgePaths path, path.flowchart-link').length : 0,
|
|
100
|
+
edgeLabels: labels.length,
|
|
101
|
+
nodeOverlaps,
|
|
102
|
+
labelsOnBoxes,
|
|
103
|
+
errorText: (msg && msg.textContent) || (/Syntax error|Parse error/i.test(text) ? 'Mermaid error text in the SVG' : ''),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
for (const c of report) {
|
|
109
|
+
if (!c.svg) failures.push(`${c.src}: no SVG (${c.errorText || 'no message'})`);
|
|
110
|
+
else if (c.errorText) failures.push(`${c.src}: ${c.errorText}`);
|
|
111
|
+
if (c.svg && c.nodes < opts.minNodes) failures.push(`${c.src}: ${c.nodes} nodes, fewer than --min-nodes ${opts.minNodes}`);
|
|
112
|
+
if (!/\b(UTC|GMT|BST|IST|CET|CEST|EET|EEST|WET|WEST|EST|EDT|CST|CDT|MST|MDT|PST|PDT|JST|AEST|AEDT)\b|time ?zone|[+-]\d{2}:?\d{2}\b/i.test(`${c.title} ${c.sub}`))
|
|
113
|
+
warnings.push(`${c.src}: the title has no time-zone text (add the zone to data-title or data-sub)`);
|
|
114
|
+
if (c.nodeOverlaps) warnings.push(`${c.src}: ${c.nodeOverlaps} pairs of boxes overlap`);
|
|
115
|
+
if (c.labelsOnBoxes) warnings.push(`${c.src}: ${c.labelsOnBoxes} arrow labels sit on a box`);
|
|
116
|
+
}
|
|
117
|
+
for (const e of pageErrors) failures.push(`page error: ${e}`);
|
|
118
|
+
|
|
119
|
+
const charts = page.locator('.fv[data-src]');
|
|
120
|
+
for (let i = 0; i < total; i++) {
|
|
121
|
+
await charts.nth(i).screenshot({ path: path.join(opts.out, `chart-${i + 1}.png`) });
|
|
122
|
+
}
|
|
123
|
+
if (total > 0) {
|
|
124
|
+
const fullBtn = charts.nth(0).locator('button[data-z="full"]');
|
|
125
|
+
if (await fullBtn.count()) {
|
|
126
|
+
await fullBtn.click();
|
|
127
|
+
await page.waitForTimeout(300);
|
|
128
|
+
await page.screenshot({ path: path.join(opts.out, 'chart-1-full.png') });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
console.log(JSON.stringify({ page: opts.page, charts: report, failures, warnings, shots: path.resolve(opts.out) }, null, 2));
|
|
132
|
+
} finally {
|
|
133
|
+
await browser.close();
|
|
134
|
+
}
|
|
135
|
+
if (failures.length) {
|
|
136
|
+
console.error(`FAIL: ${failures.length} check(s) failed`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
console.error(`PASS${warnings.length ? ` with ${warnings.length} warning(s): open the screenshots` : ''}`);
|