Plank help · updated 2026-08-07

Charts for dashboards

The Plank chart library — copy-ready, CSP-safe SVG/CSS components (KPI tiles, bars, donut, line, gauge, funnel, gantt, kanban, calendar, tables) that read and write app-data. Reuse these instead of inventing charts.

Agents: fetch the raw markdown of this page at /en/help/dashboard-charts.md

Charts for dashboards

Plank dashboards are self-contained HTML files that run in a sandboxed srcdoc iframe under a strict CSP — no CDN, no external JS or CSS. So you cannot import Chart.js, D3, Recharts, or a Google Charts <script>. Instead, Plank ships PlankCharts: a small set of dependency-free components rendered as inline SVG + CSS that read and write your app-data tables directly.

Reuse these components. Do not invent a new chart or pull a library. Every sample dashboard in the samples workspace is built from exactly these pieces, so a workspace full of dashboards reads as one system. This page is the catalog: for each component, what form it is, when to reach for it, and a minimal copy-ready snippet.

  • For where the data lives (tables, columns, the apps/<table> API), read App data & databases first.
  • For a full working drag-and-drop board, read Build a sales pipeline CRM — the kanban section below points at it.
  • For whether the dashboard is any good — does the panel heading state a finding, is a threshold stated, is there one focal point — the standard ships as two installable skills, Deliverable Writing and Deliverable Design. If either is installed in this workspace, invoke it before you deliver. See Skills.

The rules (read once, they apply to every chart)

These are non-negotiable — they come from the dataviz method the samples were validated against.

  1. Pick the form first. Match the chart to the question, not to taste. Use the chooser below.
  2. Strict CSP — inline everything. All CSS in one <style>, all chart geometry as inline <svg> strings, all JS in one <script>. No <link>, no <script src>, no web fonts from a CDN, no remote images.
  3. Theme tokens, light + dark. Never hard-code hex in a component. Use the CSS custom properties below; they redefine themselves under prefers-color-scheme: dark, so one component works in both modes with no JS.
  4. Validated categorical colors, assigned in fixed order. Series/segment colors come from the --c1 … --c6 slots in order — never cycle a hue, never pick colors ad hoc. Max 6 categories; if you have more, group the tail into "Other".
  5. Single axis, always. A trend line overlaid on bars is indexed to the same scale — never add a second y-axis.
  6. Legend + direct labels — never color alone. A single series is named in the title and draws no legend. Any chart with ≥ 2 series always carries a legend, and categories are additionally labeled directly (in the legend list or beside the mark) wherever the marks have room — direct labeling is in addition to the legend, never instead of it. Color is a redundant cue, never the only one. This is what lets the validated palette pass contrast/CVD floors.
  7. Status colors are reserved and always paired. --st-good / --st-warn / --st-bad / --st-neutral mean on-track / due-soon / overdue / n-a. Always show them with an icon and a text label, never as bare color.
  8. A hover tooltip on every plotted mark. Each mark carries a data-tip attribute; one shared listener shows it. Users must be able to read the exact value.

Pick the form

The questionReach forComponent
One headline number + its recent trendstat tile + sparklinesparkline
Magnitude over an ordered axis, with directionbars + dashed trendbarTrend
A total split into parts, over several categoriesstacked barsstackedBars
How you got from a start value to an end valuewaterfallwaterfall
One ratio against a targetradial gaugegauge
A single total split into a few partsproportion bar or donutproportionBar / donut
Part-to-whole, ≤ 6 slices, total in the middledonutdonut
One measure over time (continuous)line / arealineChart
Bars positioned by a date rangetimelinegantt
Drop-off through ordered stagesfunnelfunnel
A weekly scheduleweek calendarweekCalendar
Move items between columns and remember itkanban boardsee CRM recipe
A checklist whose ticks must persisttask checklistasanaChecklist
Rows with a state to flagstatus-pill tablestatusTable
Completion against a targetprogress barsprogress
Not a quantity — an order, a handoff, what contains whata diagram, not a chartsee Diagrams

The dashboard skeleton

Every dashboard opens with the same three blocks: the theme tokens, the shared tooltip + helpers, and a load() that fetches app-data and renders. Paste this once, then add components into the grid.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dashboard</title>
<style>
  /* --- 1. THEME TOKENS (light + dark, no JS) --- */
  :root{
    --brand:#4F6DF5;
    --surface:#FFFFFF; --surface-2:#F7F7F5;
    --ink:#121217; --ink-2:#3A3A42; --ink-muted:#6B7080;
    --grid:#ECECEF; --hairline:#E2E2E6;
    /* validated 6-slot categorical ramp — assign in order, never cycle */
    --c1:#2a78d6; --c2:#008300; --c3:#b8547d; --c4:#a97400; --c5:#158a60; --c6:#c9541f;
    /* reserved status colors — always with an icon + label */
    --st-good:#0CA30C; --st-warn:#E0900A; --st-bad:#D03B3B; --st-neutral:#7B7F8A;
  }
  @media (prefers-color-scheme: dark){
    :root{
      --surface:#16161D; --surface-2:#1D1D26;
      --ink:#FAFAF7; --ink-2:#C9C9D2; --ink-muted:#9A9AA6;
      --grid:#26262F; --hairline:#2E2E38;
      --c1:#5b9be8; --c2:#3fa64d; --c3:#e087aa; --c4:#d0a24a; --c5:#3fbf90; --c6:#e07a4e;
      --st-good:#2FBE4F; --st-warn:#F0A93A; --st-bad:#E8635F; --st-neutral:#9A9AA6;
    }
  }
  *{box-sizing:border-box}
  body{margin:0;font-family:Inter,system-ui,sans-serif;background:var(--surface-2);color:var(--ink);padding:24px}
  .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}
  .card{background:var(--surface);border:1px solid var(--hairline);border-radius:18px;padding:18px 20px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
  .card h3{margin:0 0 12px;font-size:13px;font-weight:600;letter-spacing:-.01em;color:var(--ink-2)}
  /* shared tooltip */
  #tt{position:fixed;z-index:50;pointer-events:none;opacity:0;transition:opacity .12s;background:var(--ink);color:var(--surface);font-size:12px;line-height:1.4;padding:8px 10px;border-radius:8px;max-width:220px;box-shadow:0 6px 24px rgba(0,0,0,.18)}
  #tt .r{display:flex;justify-content:space-between;gap:14px;margin-top:2px;color:var(--surface-2)}
  /* legend (pair with any multi-series chart) */
  .legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:10px}
  .lg{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2)}
  .lg i{width:10px;height:10px;border-radius:3px;display:inline-block;flex:none}
</style>
</head>
<body>
  <div id="tt" role="status"></div>
  <div class="grid" id="grid"></div>
<script>
  /* --- 2. SHARED HELPERS + TOOLTIP --- */
  function niceMax(v){const p=Math.pow(10,Math.floor(Math.log10(v||1)));const f=(v||1)/p;const n=f<=1?1:f<=2?2:f<=5?5:10;return n*p;}
  function enc(s){return String(s).replace(/"/g,'&quot;');}      // safe inside data-tip="..."
  function api(path,opts){return fetch(path,opts).then(function(r){return r.json();});}
  var tt=document.getElementById('tt');
  document.addEventListener('mousemove',function(e){
    var el=e.target.closest('[data-tip]');
    if(!el){tt.style.opacity=0;return;}
    tt.innerHTML=el.getAttribute('data-tip');
    tt.style.opacity=1;
    tt.style.left=Math.min(e.clientX+14,innerWidth-232)+'px';
    tt.style.top=(e.clientY+14)+'px';
  });

  /* --- paste the component functions you need here (see below) --- */

  /* --- 3. LOAD DATA + RENDER --- */
  function load(){
    api('apps/finance_accounts').then(function(res){
      var rows=res.data||[];
      // build cards from rows, e.g.:
      // document.getElementById('grid').insertAdjacentHTML('beforeend', ...)
    });
  }
  load();
</script>
</body>
</html>

Data wiring, in one place — every read and write is a relative fetch against apps/<table> (the file viewer signs the request; see App data):

  • Read allfetch('apps/<table>'){ data: [ …rows… ] }. Numerics come back as stringsNumber() them before plotting.
  • Read onefetch('apps/<table>/' + id){ data: [ row ] } (one-element array).
  • Insertfetch('apps/<table>', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(row) }).
  • Updatefetch('apps/<table>/' + id, { method:'PATCH', … body: JSON.stringify(changedFields) }).
  • Deletefetch('apps/<table>/' + id, { method:'DELETE' }).
  • Another workspacefetch('apps/~<workspaceId>/<table>') (membership required). See App data.

Writes may fail (view-only access, offline). Apply the change optimistically, then revert on !r.ok so the UI never shows an unsaved change as saved — the kanban and checklist below both do this.


KPI stat tile + sparkline

What it is: a headline number with a tiny bar-sparkline of its recent trail. When: the top row of almost every dashboard — one metric that deserves a big number plus a glance at where it is heading (free cash, MRR, open tickets). Put the value in --ink, never the series color.

function sparkline(vals,{w=120,h=34,color='var(--brand)'}={}){
  var max=Math.max.apply(null,vals.concat(1)), min=Math.min.apply(null,vals.concat(0)), rng=(max-min)||1;
  var x=function(i){return vals.length>1?i/(vals.length-1)*w:w/2;}, y=function(v){return h-((v-min)/rng)*h;};
  var bw=Math.max(2,w/vals.length-2);
  var bars=vals.map(function(v,i){return '<rect x="'+(x(i)-bw/2).toFixed(1)+'" y="'+y(v).toFixed(1)+'" width="'+bw.toFixed(1)+'" height="'+(h-y(v)).toFixed(1)+'" rx="1.5" fill="'+color+'" opacity="'+(i===vals.length-1?1:0.4)+'"/>';}).join('');
  return '<svg viewBox="0 0 '+w+' '+h+'" width="'+w+'" height="'+h+'" role="img">'+bars+'</svg>';
}
<div class="card">
  <div style="font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink-muted)">Free cash</div>
  <div style="font-size:30px;font-weight:700;letter-spacing:-.02em;margin:4px 0 8px" id="kpi">—</div>
  <div id="kpi-spark"></div>
  <div style="font-size:12px;font-weight:600;color:var(--st-good)">&#9650; 4.2% vs last month</div>
</div>
<script>
  document.getElementById('kpi').textContent = '1 140 000 ₸';
  document.getElementById('kpi-spark').innerHTML = sparkline([12,15,11,18,17,22,21]);
</script>

Bar chart with trend

What it is: magnitude over an ordered axis (months, projection horizons) with a dashed least-squares trend on the same axis. When: "how big, in order, and which way is it going" — revenue by month, a free-cash projection across today / 7d / 30d / month-end. The trend is a reading aid, not a second series — keep it a single axis.

function barTrend(pts,{w=560,h=230,color='var(--brand)',trend='var(--ink-muted)',fmt=function(v){return v;}}={}){
  var padL=52,padR=14,padT=16,padB=30,iw=w-padL-padR,ih=h-padT-padB;
  var vals=pts.map(function(p){return Number(p.value)||0;});
  var nice=niceMax(Math.max.apply(null,vals.concat(1)));
  var step=iw/pts.length, bw=step*0.6;
  var x=function(i){return padL+step*i+step/2;}, y=function(v){return padT+ih-(v/nice)*ih;};
  var grid='';for(var g=0;g<=4;g++){var gv=nice*g/4,gy=y(gv);grid+='<line x1="'+padL+'" y1="'+gy.toFixed(1)+'" x2="'+(w-padR)+'" y2="'+gy.toFixed(1)+'" stroke="var(--grid)"/><text x="'+(padL-8)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" style="fill:var(--ink-muted);font-size:10.5px">'+fmt(gv)+'</text>';}
  var bars=pts.map(function(p,i){return '<rect x="'+(x(i)-bw/2).toFixed(1)+'" y="'+y(vals[i]).toFixed(1)+'" width="'+bw.toFixed(1)+'" height="'+(padT+ih-y(vals[i])).toFixed(1)+'" rx="4" fill="'+color+'" data-tip="'+enc('<b>'+(p.label||'')+'</b><div class=\'r\'><span>value</span><span>'+fmt(vals[i])+'</span></div>')+'"/>';}).join('');
  var n=vals.length, sx=0,sy=0,sxy=0,sxx=0;
  vals.forEach(function(v,i){sx+=i;sy+=v;sxy+=i*v;sxx+=i*i;});
  var b=(n*sxy-sx*sy)/((n*sxx-sx*sx)||1), a=(sy-b*sx)/n;
  var tl='M'+x(0).toFixed(1)+' '+y(a).toFixed(1)+' L'+x(n-1).toFixed(1)+' '+y(a+b*(n-1)).toFixed(1);
  var ax=pts.map(function(p,i){return '<text x="'+x(i).toFixed(1)+'" y="'+(h-12)+'" text-anchor="middle" style="fill:var(--ink-muted);font-size:10.5px">'+(p.label||'')+'</text>';}).join('');
  return '<svg viewBox="0 0 '+w+' '+h+'" width="100%" preserveAspectRatio="xMidYMid meet" role="img">'+grid+bars+'<path d="'+tl+'" fill="none" stroke="'+trend+'" stroke-width="2" stroke-dasharray="5 4"/>'+ax+'</svg>';
}
// barTrend([{label:'Jan',value:820},{label:'Feb',value:910},{label:'Mar',value:1140}], {fmt:function(v){return (v/1000).toFixed(1)+'M';}})

Stacked bars

What it is: a total per category, split into ordered segments. When: a composition that changes across categories — expenses by department per month, revenue by product line per quarter. Always pair it with a legend([...]) (segments repeat, so color needs a key). Keep to ≤ 6 segments.

function stackedBars(cats,keys,{w=560,h=240,fmt=function(v){return v;}}={}){
  // cats:[{label, values:{keyName:number}}]   keys:[{name, color}]
  var padL=52,padR=14,padT=16,padB=30,iw=w-padL-padR,ih=h-padT-padB;
  var tot=cats.map(function(c){return keys.reduce(function(s,k){return s+(Number(c.values[k.name])||0);},0);});
  var nice=niceMax(Math.max.apply(null,tot.concat(1)));
  var step=iw/cats.length, bw=step*0.6;
  var x=function(i){return padL+step*i+step/2;}, y=function(v){return padT+ih-(v/nice)*ih;};
  var grid='';for(var g=0;g<=4;g++){var gv=nice*g/4,gy=y(gv);grid+='<line x1="'+padL+'" y1="'+gy.toFixed(1)+'" x2="'+(w-padR)+'" y2="'+gy.toFixed(1)+'" stroke="var(--grid)"/><text x="'+(padL-8)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" style="fill:var(--ink-muted);font-size:10.5px">'+fmt(gv)+'</text>';}
  var bars='';
  cats.forEach(function(c,i){var acc=0;keys.forEach(function(k){var v=Number(c.values[k.name])||0;if(!v)return;var y1=y(acc),y0=y(acc+v);bars+='<rect x="'+(x(i)-bw/2).toFixed(1)+'" y="'+y0.toFixed(1)+'" width="'+bw.toFixed(1)+'" height="'+(y1-y0).toFixed(1)+'" fill="'+k.color+'" data-tip="'+enc('<b>'+k.name+'</b><div class=\'r\'><span>'+(c.label||'')+'</span><span>'+fmt(v)+'</span></div>')+'"/>';acc+=v;});});
  var ax=cats.map(function(c,i){return '<text x="'+x(i).toFixed(1)+'" y="'+(h-12)+'" text-anchor="middle" style="fill:var(--ink-muted);font-size:10.5px">'+(c.label||'')+'</text>';}).join('');
  return '<svg viewBox="0 0 '+w+' '+h+'" width="100%" preserveAspectRatio="xMidYMid meet" role="img">'+grid+bars+ax+'</svg>';
}
// pair with a legend:
function legend(items){ // items:[{name,color}]
  return '<div class="legend">'+items.map(function(i){return '<span class="lg"><i style="background:'+i.color+'"></i>'+i.name+'</span>';}).join('')+'</div>';
}

Waterfall

What it is: how a start value becomes an end value through signed steps, drawn as floating bars. When: a bridge — opening cash → +inflows → −payroll → −tax → closing cash; budget vs actual variance. Rising steps use --st-good, falling steps --st-bad (paired with the value in the tooltip).

function waterfall(steps,{w=560,h=240,fmt=function(v){return v;}}={}){
  // steps:[{label, delta}]  (signed; running total is drawn for you)
  var padL=52,padR=14,padT=16,padB=30,iw=w-padL-padR,ih=h-padT-padB;
  var run=0, tops=[], mx=0;
  steps.forEach(function(s){var a=run;run+=s.delta;tops.push([a,run]);mx=Math.max(mx,a,run);});
  var nice=niceMax(mx);
  var step=iw/steps.length, bw=step*0.6;
  var x=function(i){return padL+step*i+step/2;}, y=function(v){return padT+ih-(v/nice)*ih;};
  var grid='';for(var g=0;g<=4;g++){var gv=nice*g/4,gy=y(gv);grid+='<line x1="'+padL+'" y1="'+gy.toFixed(1)+'" x2="'+(w-padR)+'" y2="'+gy.toFixed(1)+'" stroke="var(--grid)"/><text x="'+(padL-8)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" style="fill:var(--ink-muted);font-size:10.5px">'+fmt(gv)+'</text>';}
  var bars=steps.map(function(s,i){var a=tops[i][0],b=tops[i][1],up=s.delta>=0,y0=y(Math.max(a,b)),y1=y(Math.min(a,b));var col=up?'var(--st-good)':'var(--st-bad)';return '<rect x="'+(x(i)-bw/2).toFixed(1)+'" y="'+y0.toFixed(1)+'" width="'+bw.toFixed(1)+'" height="'+Math.max(2,y1-y0).toFixed(1)+'" rx="3" fill="'+col+'" data-tip="'+enc('<b>'+(s.label||'')+'</b><div class=\'r\'><span>'+(up?'+':'')+fmt(s.delta)+'</span><span>'+fmt(b)+'</span></div>')+'"/>';}).join('');
  var ax=steps.map(function(s,i){return '<text x="'+x(i).toFixed(1)+'" y="'+(h-12)+'" text-anchor="middle" style="fill:var(--ink-muted);font-size:10px">'+(s.label||'').slice(0,10)+'</text>';}).join('');
  return '<svg viewBox="0 0 '+w+' '+h+'" width="100%" preserveAspectRatio="xMidYMid meet" role="img">'+grid+bars+ax+'</svg>';
}
// waterfall([{label:'Open',delta:24500},{label:'Inflows',delta:9000},{label:'Payroll',delta:-6800},{label:'Tax',delta:-2400},{label:'Close',delta:0}])

Gauge (ratio vs target)

What it is: one ratio rendered as a 270° arc, filling toward a target tick, colored by how close it is. When: a single "are we there yet" number that deserves the whole card — 30-day payment coverage, quota attainment, capacity used. Do not use a gauge for more than one number.

function gauge(value,target,{size=180,label='',fmt=function(v){return v;}}={}){
  var r=size/2, cx=r, cy=r, rr=r-16, start=135, sweep=270;
  var frac=Math.max(0,Math.min(1.2,value/(target||1)));
  var pol=function(deg,rad){return [cx+Math.cos(deg*Math.PI/180)*rad, cy+Math.sin(deg*Math.PI/180)*rad];};
  var arc=function(a0,a1,rad,large){var p0=pol(a0,rad),p1=pol(a1,rad);return 'M'+p0[0].toFixed(1)+' '+p0[1].toFixed(1)+' A'+rad+' '+rad+' 0 '+large+' 1 '+p1[0].toFixed(1)+' '+p1[1].toFixed(1);};
  var track=arc(start,start+sweep,rr, sweep>180?1:0);
  var filled=sweep*Math.min(1,frac);
  var fill=arc(start,start+filled,rr, filled>180?1:0);
  var col=frac>=1?'var(--st-good)':frac>=0.7?'var(--st-warn)':'var(--st-bad)';
  var tk=pol(start+sweep,rr), tk2=pol(start+sweep,rr-14); // target tick at 100%
  return '<svg viewBox="0 0 '+size+' '+size+'" width="'+size+'" height="'+size+'" role="img">'
    +'<path d="'+track+'" fill="none" stroke="var(--grid)" stroke-width="14" stroke-linecap="round"/>'
    +'<path d="'+fill+'" fill="none" stroke="'+col+'" stroke-width="14" stroke-linecap="round"/>'
    +'<line x1="'+tk[0].toFixed(1)+'" y1="'+tk[1].toFixed(1)+'" x2="'+tk2[0].toFixed(1)+'" y2="'+tk2[1].toFixed(1)+'" stroke="var(--ink-2)" stroke-width="2"/>'
    +'<text x="'+cx+'" y="'+cy+'" text-anchor="middle" style="fill:var(--ink);font-size:26px;font-weight:700">'+Math.round(frac*100)+'%</text>'
    +'<text x="'+cx+'" y="'+(cy+20)+'" text-anchor="middle" style="fill:var(--ink-muted);font-size:11px">'+label+'</text></svg>';
}
// gauge(27.9, 30, {label:'30-day coverage'})

Proportion bar

What it is: a single total split into parts along one horizontal bar, with a direct-labeled legend list. When: the lightest part-to-whole — obligations by category, headcount by team — when a donut would be too much furniture. Every segment is labeled in the list, so identity never rests on color.

function proportionBar(segs,{h=26}={}){
  // segs:[{label, value, color}]
  var total=segs.reduce(function(s,x){return s+x.value;},0)||1, acc=0;
  var parts=segs.map(function(s){var wpct=s.value/total*100, x=acc; acc+=wpct; return '<div style="position:absolute;left:'+x.toFixed(2)+'%;width:'+wpct.toFixed(2)+'%;top:0;bottom:0;background:'+s.color+'" data-tip="'+enc('<b>'+s.label+'</b><div class=\'r\'><span>'+Math.round(wpct)+'%</span><span>'+s.value+'</span></div>')+'"></div>';}).join('');
  var list=segs.map(function(s){return '<span class="lg"><i style="background:'+s.color+'"></i>'+s.label+' <b>'+Math.round(s.value/total*100)+'%</b></span>';}).join('');
  return '<div style="position:relative;width:100%;border-radius:8px;overflow:hidden;background:var(--grid);height:'+h+'px">'+parts+'</div><div class="legend">'+list+'</div>';
}
// proportionBar([{label:'Payroll',value:6800,color:'var(--c1)'},{label:'Tax',value:2400,color:'var(--c2)'},{label:'Rent',value:1500,color:'var(--c3)'}])

Donut (part-to-whole, ≤ 6)

What it is: a ring split into slices with the total in the center. A 2px --surface ring between arcs keeps slices from blurring together. When: a single total split into a few identity categories (≤ 6) where you want the total foregrounded — spend by category, tickets by channel. More than 6 slices → group the tail into "Other" or switch to bars.

function donut(segs,{size=168,thick=26,centerLabel='',centerValue=''}={}){
  // segs:[{label, value, color}]
  var total=segs.reduce(function(s,x){return s+x.value;},0)||1;
  var r=size/2, rin=r-thick, cx=r, cy=r, a0=-Math.PI/2, out='';
  var P=function(a,rad){return [cx+Math.cos(a)*rad, cy+Math.sin(a)*rad];};
  segs.forEach(function(s){
    var frac=Math.max(0.0001,s.value/total), a1=a0+frac*2*Math.PI, large=frac>0.5?1:0;
    var p0=P(a0,r),p1=P(a1,r),pi1=P(a1,rin),pi0=P(a0,rin);
    out+='<path d="M'+p0[0].toFixed(2)+' '+p0[1].toFixed(2)+' A'+r+' '+r+' 0 '+large+' 1 '+p1[0].toFixed(2)+' '+p1[1].toFixed(2)+' L'+pi1[0].toFixed(2)+' '+pi1[1].toFixed(2)+' A'+rin+' '+rin+' 0 '+large+' 0 '+pi0[0].toFixed(2)+' '+pi0[1].toFixed(2)+' Z" fill="'+s.color+'" stroke="var(--surface)" stroke-width="2" data-tip="'+enc('<b>'+s.label+'</b><div class=\'r\'><span>'+Math.round(frac*100)+'%</span><span>'+s.value+'</span></div>')+'"/>';
    a0=a1;
  });
  var ctr=(centerLabel||centerValue)?'<text x="'+cx+'" y="'+(cy-3)+'" text-anchor="middle" style="fill:var(--ink);font-size:20px;font-weight:700">'+centerValue+'</text><text x="'+cx+'" y="'+(cy+15)+'" text-anchor="middle" style="fill:var(--ink-muted);font-size:11px">'+centerLabel+'</text>':'';
  return '<svg viewBox="0 0 '+size+' '+size+'" width="'+size+'" height="'+size+'" role="img">'+out+ctr+'</svg>';
}
// donut([{label:'Payroll',value:6800,color:'var(--c1)'},{label:'Tax',value:2400,color:'var(--c2)'}], {centerLabel:'Total', centerValue:'10.7M'})
// Always add a legend([...]) beside it — the slices repeat colors, so color needs a key.

Line / area time-series

What it is: one measure over ordered points, with a soft area fill, a 2px line, end/point dots, and a hover value. When: a continuous trend — daily cash balance, weekly signups, monthly revenue. Single axis only; for a second measure, stack a second small chart rather than a twin axis.

function lineChart(pts,{w=560,h=230,color='var(--brand)',area=true,fmt=function(v){return v;}}={}){
  // pts:[{label, value}]
  var padL=52,padR=14,padT=16,padB=30,iw=w-padL-padR,ih=h-padT-padB;
  var vals=pts.map(function(p){return Number(p.value)||0;});
  var min=Math.min.apply(null,vals.concat(0)), max=Math.max.apply(null,vals.concat(1)), nice=niceMax(max), rng=(nice-min)||1;
  var x=function(i){return padL+(pts.length>1?i/(pts.length-1):0.5)*iw;};
  var y=function(v){return padT+ih-((v-min)/rng)*ih;};
  var gid='lg'+Math.random().toString(36).slice(2,7);
  var grid='';for(var g=0;g<=4;g++){var gv=min+(nice-min)*g/4, gy=y(gv);grid+='<line x1="'+padL+'" y1="'+gy.toFixed(1)+'" x2="'+(w-padR)+'" y2="'+gy.toFixed(1)+'" stroke="var(--grid)"/><text x="'+(padL-8)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" style="fill:var(--ink-muted);font-size:10.5px">'+fmt(gv)+'</text>';}
  var line=pts.map(function(p,i){return (i?'L':'M')+x(i).toFixed(1)+' '+y(vals[i]).toFixed(1);}).join(' ');
  var areaP=area?'<path d="'+line+' L'+x(pts.length-1).toFixed(1)+' '+y(min).toFixed(1)+' L'+x(0).toFixed(1)+' '+y(min).toFixed(1)+' Z" fill="url(#'+gid+')"/>':'';
  var dots=pts.map(function(p,i){return '<circle cx="'+x(i).toFixed(1)+'" cy="'+y(vals[i]).toFixed(1)+'" r="3" fill="'+color+'" stroke="var(--surface)" stroke-width="1.5" data-tip="'+enc('<b>'+(p.label||'')+'</b><div class=\'r\'><span>value</span><span>'+fmt(vals[i])+'</span></div>')+'"/>';}).join('');
  var ax=pts.map(function(p,i){return '<text x="'+x(i).toFixed(1)+'" y="'+(h-12)+'" text-anchor="middle" style="fill:var(--ink-muted);font-size:10.5px">'+(p.label||'')+'</text>';}).join('');
  return '<svg viewBox="0 0 '+w+' '+h+'" width="100%" preserveAspectRatio="xMidYMid meet" role="img">'
    +'<defs><linearGradient id="'+gid+'" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="'+color+'" stop-opacity=".22"/><stop offset="100%" stop-color="'+color+'" stop-opacity="0"/></linearGradient></defs>'
    +grid+areaP+'<path d="'+line+'" fill="none" stroke="'+color+'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'+dots+ax+'</svg>';
}

Gantt / timeline

What it is: bars positioned by a date range on a shared month axis. When: anything with a start and an end — lease periods, contract validity, project tasks, license expiry. Pass ISO YYYY-MM-DD dates; per-row color can carry a status.

function gantt(rows,{w=640,rowH=30,labelW=150}={}){
  // rows:[{label, start:'YYYY-MM-DD', end:'YYYY-MM-DD', color}]
  var D=function(v){return String(v).slice(0,10);};
  var days=rows.reduce(function(a,r){return a.concat([D(r.start),D(r.end)]);},[]).filter(Boolean).sort();
  var t0=new Date(days[0]+'T00:00:00'), t1=new Date(days[days.length-1]+'T00:00:00');
  var span=Math.max(1,(t1-t0)/86400000);
  var padT=24, iw=w-labelW-14, h=padT+rows.length*rowH+6;
  var x=function(iso){return labelW+((new Date(D(iso)+'T00:00:00')-t0)/86400000/span)*iw;};
  var MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  var grid='', cur=new Date(t0.getFullYear(),t0.getMonth(),1);
  while(cur<=t1){var gx=labelW+((cur-t0)/86400000/span)*iw;if(gx>=labelW){grid+='<line x1="'+gx.toFixed(1)+'" y1="'+(padT-6)+'" x2="'+gx.toFixed(1)+'" y2="'+h+'" stroke="var(--grid)"/><text x="'+gx.toFixed(1)+'" y="'+(padT-10)+'" style="fill:var(--ink-muted);font-size:10px">'+MON[cur.getMonth()]+'</text>';}cur=new Date(cur.getFullYear(),cur.getMonth()+1,1);}
  var bars='';
  rows.forEach(function(r,i){var y=padT+i*rowH, x0=x(r.start), x1=Math.max(x0+6,x(r.end)), col=r.color||'var(--brand)';
    bars+='<text x="0" y="'+(y+rowH/2+4).toFixed(1)+'" style="fill:var(--ink-2);font-size:12px">'+(r.label||'').slice(0,22)+'</text>';
    bars+='<rect x="'+x0.toFixed(1)+'" y="'+(y+5).toFixed(1)+'" width="'+(x1-x0).toFixed(1)+'" height="'+(rowH-12)+'" rx="5" fill="'+col+'" data-tip="'+enc('<b>'+(r.label||'')+'</b><div class=\'r\'><span>'+D(r.start)+'</span><span>'+D(r.end)+'</span></div>')+'"/>';});
  return '<svg viewBox="0 0 '+w+' '+h+'" width="100%" preserveAspectRatio="xMidYMid meet" role="img">'+grid+bars+'</svg>';
}

Funnel (pipeline stages)

What it is: ordered stages as descending bars, each showing the stage-to-stage conversion. When: drop-off through a fixed sequence — recruitment (applied → screened → interviewed → offered → hired), a sales funnel snapshot, an activation flow. Uses one hue at stepped opacity (an ordinal ramp), with the label outside the bar so short bars stay readable.

function funnel(stages,{fmt=function(v){return v;}}={}){
  // stages:[{label, value}]  (descending)
  var top=Math.max.apply(null,stages.map(function(s){return s.value;}).concat(1));
  return '<div class="funnel">'+stages.map(function(s,i){
    var pct=s.value/top*100, conv=i?Math.round(s.value/stages[i-1].value*100):100;
    return '<div class="fn-row"><div class="fn-label">'+s.label+'</div><div class="fn-track"><div class="fn-bar" style="width:'+pct.toFixed(1)+'%;opacity:'+(1-i*0.13).toFixed(2)+'"></div></div><div class="fn-val">'+fmt(s.value)+' <span>'+conv+'%</span></div></div>';
  }).join('')+'</div>';
}
.fn-row{display:grid;grid-template-columns:130px 1fr 120px;align-items:center;gap:12px;margin:6px 0}
.fn-label{font-size:13px;color:var(--ink-2)}
.fn-track{background:var(--grid);border-radius:8px;overflow:hidden;height:26px}
.fn-bar{height:100%;background:var(--brand);border-radius:8px}
.fn-val{font-size:13px;color:var(--ink);text-align:right}
.fn-val span{color:var(--ink-muted);font-size:11px}

Week calendar (schedules)

What it is: a 7-column day grid with events positioned by time-of-day. When: a weekly rota, interview schedule, delivery timetable, or content calendar. Events take a time range and an optional color.

function weekCalendar(events,{startHour=8,endHour=20}={}){
  // events:[{day:0..6 (Mon=0), start:'HH:MM', end:'HH:MM', title, color}]
  var days=['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
  var span=(endHour-startHour)*60, toMin=function(t){var p=t.split(':');return (+p[0])*60+(+p[1]);};
  var cols=days.map(function(d,di){
    var evs=events.filter(function(e){return e.day===di;}).map(function(e){
      var top=(toMin(e.start)-startHour*60)/span*100, ht=(toMin(e.end)-toMin(e.start))/span*100;
      return '<div class="ev" style="top:'+top.toFixed(1)+'%;height:'+ht.toFixed(1)+'%;background:'+(e.color||'var(--brand)')+'" data-tip="'+enc('<b>'+e.title+'</b><div class=\'r\'><span>'+e.start+'</span><span>'+e.end+'</span></div>')+'">'+e.title+'</div>';
    }).join('');
    return '<div class="wc-col"><div class="wc-head">'+d+'</div><div class="wc-body">'+evs+'</div></div>';
  }).join('');
  return '<div class="wcal">'+cols+'</div>';
}
.wcal{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}
.wc-head{font-size:12px;color:var(--ink-muted);text-align:center;padding:4px 0}
.wc-body{position:relative;height:320px;background:var(--surface-2);border-radius:10px;border:1px solid var(--hairline)}
.ev{position:absolute;left:3px;right:3px;border-radius:6px;color:#fff;font-size:10px;padding:2px 4px;overflow:hidden}

Kanban (drag-to-persist board)

What it is: columns of cards you drag between stages, where each move PATCHes the row's status so it persists. When: anything that moves between states and must be remembered — a sales pipeline, a hiring board, a ticket workflow.

Do not re-derive this — it has a complete, copy-ready recipe. Read Build a sales pipeline CRM: it gives you the deals table, the full board HTML, and the optimistic-move-then-revert-on-failure pattern. The load-bearing bit is the drop handler:

// optimistic move, then persist; revert if the write is refused
cards.appendChild(card);
fetch('apps/deals/' + encodeURIComponent(id), {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ status: stage }),
}).then(function(r){ if(!r.ok){ from.appendChild(card); alert('Could not save (status ' + r.status + ')'); } })
  .catch(function(){ from.appendChild(card); alert('Could not save — are you offline?'); });

Change the stage names and card fields and it is any board you like.


Task checklist (checkbox persists)

What it is: an Asana-style list grouped by project, where ticking a task PATCHes its status so the tick survives a reload — the same persist-and-revert discipline as the kanban. When: onboarding checklists, project task lists, compliance steps. Needs a table like { project, title, owner, due_date, status }.

function asanaChecklist(tasks,{table='tasks',doneVal='closed',openVal='open'}={}){
  // tasks:[{id, project, title, owner, due_date, status}]
  var byProj={};
  tasks.forEach(function(t){(byProj[t.project||'—']=byProj[t.project||'—']||[]).push(t);});
  return Object.keys(byProj).map(function(proj){
    var list=byProj[proj], done=list.filter(function(t){return t.status===doneVal;}).length;
    var rows=list.map(function(t){var d=t.status===doneVal;
      return '<div class="task" data-id="'+t.id+'"><div class="check'+(d?' done':'')+'" role="checkbox" aria-checked="'+d+'" tabindex="0">'+(d?'&#10003;':'')+'</div><div class="t-body"><div class="t-title'+(d?' done':'')+'">'+t.title+'</div><div class="t-meta">'+(t.owner||'')+(t.due_date?' · '+t.due_date:'')+'</div></div></div>';
    }).join('');
    return '<div class="proj"><div class="proj-head">'+proj+' <span>'+done+'/'+list.length+'</span></div>'
      +'<div class="pg-track"><div class="pg-fill" style="width:'+(list.length?done/list.length*100:0).toFixed(1)+'%;background:var(--st-good)"></div></div>'+rows+'</div>';
  }).join('');
}
// wire one delegated click handler that toggles + persists:
document.addEventListener('click', function(e){
  var box=e.target.closest('.check'); if(!box) return;
  var row=box.closest('.task'), id=row.dataset.id, willDone=!box.classList.contains('done');
  box.classList.toggle('done'); // optimistic
  fetch('apps/tasks/'+encodeURIComponent(id), {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status: willDone?'closed':'open'})})
    .then(function(r){ if(!r.ok) box.classList.toggle('done'); })   // revert on failure
    .catch(function(){ box.classList.toggle('done'); });
});
.proj-head{font-size:13px;font-weight:600;color:var(--ink-2);margin:14px 0 6px;display:flex;justify-content:space-between}
.proj-head span{color:var(--ink-muted);font-weight:500}
.task{display:flex;gap:10px;align-items:center;padding:8px 0;border-bottom:1px solid var(--grid)}
.check{width:20px;height:20px;border-radius:6px;border:2px solid var(--hairline);cursor:pointer;display:grid;place-items:center;flex:none;color:#fff;font-size:12px}
.check.done{background:var(--st-good);border-color:var(--st-good)}
.t-title.done{text-decoration:line-through;color:var(--ink-muted)}
.t-meta{font-size:11px;color:var(--ink-muted)}
.pg-track{height:8px;background:var(--grid);border-radius:6px;overflow:hidden;margin-bottom:6px}
.pg-fill{height:100%;border-radius:6px}

Status-pill table

What it is: a plain data table where a state column renders as a colored pill with an icon and label (never bare color). When: rows that carry a state you need to flag — obligations with overdue/due-soon/on-track, tasks by status, invoices by payment state.

var STATUS={
  overdue:{cls:'bad', icon:'&#9679;', label:'Overdue'},
  due:{cls:'warn', icon:'&#9679;', label:'Due soon'},
  ok:{cls:'good', icon:'&#9679;', label:'On track'}
};
function statusTable(rows,cols){
  // cols:[{key, label, status?:true}]
  var head=cols.map(function(c){return '<th>'+c.label+'</th>';}).join('');
  var body=rows.map(function(r){return '<tr>'+cols.map(function(c){
    if(c.status){var s=STATUS[r[c.key]]||STATUS.ok;return '<td><span class="pill '+s.cls+'"><i>'+s.icon+'</i>'+s.label+'</span></td>';}
    return '<td>'+(r[c.key]==null?'':r[c.key])+'</td>';
  }).join('')+'</tr>';}).join('');
  return '<table class="tbl"><thead><tr>'+head+'</tr></thead><tbody>'+body+'</tbody></table>';
}
// statusTable(rows, [{key:'counterparty',label:'Vendor'},{key:'amount',label:'Amount'},{key:'state',label:'Status',status:true}])
.tbl{width:100%;border-collapse:collapse;font-size:13px}
.tbl th{text-align:left;color:var(--ink-muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.04em;padding:8px 10px;border-bottom:1px solid var(--hairline)}
.tbl td{padding:10px;border-bottom:1px solid var(--grid);color:var(--ink-2)}
.pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;padding:2px 10px;border-radius:999px}
.pill i{font-size:8px}
.pill.good{background:color-mix(in srgb,var(--st-good) 15%,transparent);color:var(--st-good)}
.pill.warn{background:color-mix(in srgb,var(--st-warn) 18%,transparent);color:var(--st-warn)}
.pill.bad{background:color-mix(in srgb,var(--st-bad) 15%,transparent);color:var(--st-bad)}

Progress bars

What it is: completion against a target as a thin filled track with a percentage. When: a small metric that is fundamentally "x of y" — budget used, quota met, onboarding steps done, storage consumed. Cheap enough to stack several in a card.

function progress(value,max,{color='var(--brand)',label=''}={}){
  var pct=Math.max(0,Math.min(100,value/(max||1)*100));
  return '<div style="margin:10px 0"><div class="pg-top"><span>'+label+'</span><span>'+Math.round(pct)+'%</span></div>'
    +'<div class="pg-track"><div class="pg-fill" style="width:'+pct.toFixed(1)+'%;background:'+color+'"></div></div></div>';
}
.pg-top{display:flex;justify-content:space-between;font-size:12px;color:var(--ink-2);margin-bottom:4px}
.pg-track{height:8px;background:var(--grid);border-radius:6px;overflow:hidden}
.pg-fill{height:100%;border-radius:6px}

Where to see them working

Every one of these is live in the samples workspacefinance-overview/ (stat tiles, barTrend, gauge, proportion bar, status-pill table), finance-reports/ (waterfall, stacked bars), hr/ (recruitment funnel), and the CRM / projects / owner dashboards. Open a sample, view source, and lift the exact wiring. When you build a new dashboard, copy the theme tokens, tooltip, and component functions from an existing sample so everything stays one coherent system — do not restyle from scratch.

For the storage side (creating tables, columns, JSON columns, cross-workspace reads), see App data & databases. For a full interactive board, see Build a sales pipeline CRM.