Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

Analytics blocks from API responses

Six ready blocks for your pages: tiles, line, sources, funnel, visibility and Pulse. Each is drawn from an analytics API response without libraries; a Chart.js variant is on a separate tab.

Shared base

All blocks share one CSS and a few helpers. An API key in the browser is not needed and is dangerous: the blocks read data from your server, which calls the analytics API - see the "Your own dashboard" example. A null in a response means "no data", not zero; the helpers account for it.

CSS

The --ab-* variables on the .ab root are what you recolour. Dark theme follows the system and data-theme.

.ab {
  --ab-bg: #ffffff; --ab-fg: #1f2d26; --ab-muted: #4f6458; --ab-line: #e4ddcf; --ab-track: #eef1ec;
  --ab-accent: #2d6a4f; --ab-soft: #e8f5e9; --ab-up: #17703f; --ab-down: #b3261e; --ab-radius: 16px;
  --ab-d1: #1a3c2a; --ab-d2: #2d6a4f; --ab-d3: #7fb89b; --ab-d4: #cfd8d2;
  --ab-direct: #6b7a72; --ab-ai: #a9741f; --ab-social: #7a3db8; --ab-ads: #0b6bcb;
  background: var(--ab-bg); color: var(--ab-fg); border: 1px solid var(--ab-line); border-radius: var(--ab-radius);
  padding: 16px; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; min-width: 0;
}
@media (prefers-color-scheme: dark) {
  .ab:not([data-theme="light"]) {
    --ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
    --ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
    --ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
    --ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
  }
}
.ab[data-theme="dark"] {
  --ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
  --ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
  --ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
  --ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
}
.ab-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.ab-tile { background: var(--ab-soft); border-radius: 12px; padding: 12px 14px; min-width: 0; }
.ab-label, .ab-sub, .ab-axis { font-size: 12px; color: var(--ab-muted); }
.ab-value { font-size: 24px; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere; }
.ab-delta { font-size: 12px; font-weight: 600; color: var(--ab-muted); }
.ab-up { color: var(--ab-up); }
.ab-down { color: var(--ab-down); }
.ab-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; }
.ab-big { font-size: 26px; font-weight: 700; }
.ab-plot { height: 160px; margin-top: 10px; }
.ab-plot svg, .ab-bar svg, .ab-stack svg { display: block; width: 100%; height: 100%; }
.ab-grid { stroke: var(--ab-line); stroke-width: 1; vector-effect: non-scaling-stroke; }
.ab-line { fill: none; stroke: var(--ab-accent); stroke-width: 2.5; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.ab-area { fill: var(--ab-accent); opacity: 0.14; }
.ab-axis { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
.ab-rows { display: grid; gap: 12px; }
.ab-row { display: grid; grid-template-columns: minmax(90px, 150px) minmax(0, 1fr) minmax(96px, auto); gap: 4px 12px; align-items: center; }
.ab-name { font-weight: 600; overflow-wrap: anywhere; }
.ab-bar { height: 10px; border-radius: 5px; overflow: hidden; }
.ab-bar-none { background: var(--ab-track); opacity: 0.5; }
.ab-num { display: flex; flex-direction: column; align-items: flex-end; text-align: right; line-height: 1.3; }
.ab-track { fill: var(--ab-track); }
.ab-fill { fill: var(--ab-accent); }
.ab-k-direct { fill: var(--ab-direct); }
.ab-k-ai_assistant { fill: var(--ab-ai); }
.ab-k-social { fill: var(--ab-social); }
.ab-k-ads { fill: var(--ab-ads); }
.ab-sub-title { margin: 16px 0 8px; font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1.4; color: var(--ab-fg); }
p.ab-sub { margin: 0 0 8px; }
.ab-stack { height: 14px; border-radius: 7px; overflow: hidden; }
.ab-d1 { fill: var(--ab-d1); } .ab-d2 { fill: var(--ab-d2); } .ab-d3 { fill: var(--ab-d3); } .ab-d4 { fill: var(--ab-d4); }
.ab-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; list-style: none; margin: 10px 0 0; padding: 0; }
.ab-legend li { display: flex; gap: 6px; align-items: baseline; }
.ab-legend li::before { content: ""; width: 10px; height: 10px; border-radius: 3px; background: var(--ab-d1); align-self: center; }
.ab-legend .ab-d2-key::before { background: var(--ab-d2); }
.ab-legend .ab-d3-key::before { background: var(--ab-d3); }
.ab-legend .ab-d4-key::before { background: var(--ab-d4); }
.ab-list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; }
.ab-item { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--ab-line); border-radius: 12px; }
.ab-item-text { display: flex; flex-direction: column; min-width: 0; }
.ab-chip { background: var(--ab-soft); color: var(--ab-up); font-weight: 700; border-radius: 999px; padding: 2px 10px; white-space: nowrap; }
.ab-note { margin: 0 0 8px; padding: 12px; border-radius: 12px; background: var(--ab-soft); color: var(--ab-muted); }
@media (max-width: 480px) {
  .ab-row { grid-template-columns: minmax(0, 1fr) auto; }
  .ab-bar { grid-column: 1 / -1; grid-row: 2; }
  .ab-num { grid-column: 2; grid-row: 1; }
}

JS helpers

// Labels: edit for your site. The keys (clicks, google, ...) come from the API.
var LOCALE = 'en-GB';
var T = {
  impressions: 'Impressions', clicks: 'Clicks', sessions: 'Sessions', ai_people: 'People from AI assistants', revenue: 'Revenue',
  leads: 'Leads', deals_created: 'Deals created', deals_won: 'Deals won',
  google: 'Google', yandex: 'Yandex', bing: 'Bing', chatgpt: 'ChatGPT', perplexity: 'Perplexity', ai_other: 'Other AI',
  social: 'Social', ads: 'Ads', direct: 'Direct', other: 'Other'
};
var M = {
  dash: '—', noBase: 'no base', no_data: 'No data for the period', no_source: 'Source not connected',
  not_connected: 'Section not connected', through: 'data through', total: 'total', leads: 'leads', prevStep: 'of previous step',
  share: 'Visibility share', spread: 'Query positions', top3: 'Top 3', top10: 'Top 10', top30: 'Top 30', outside: 'Below',
  aeo: 'Citations in AI answers', volume: 'demand', new7: 'New in 7 days'
};

function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function el(tag, cls, text) {
  var n = document.createElement(tag);
  if (cls) n.className = cls;
  if (text != null) n.textContent = text;   // textContent, not innerHTML: the text never becomes markup
  return n;
}
// An API value: a number, money {amount, currency} or null. null means "no data", not 0.
function num(v) { return v == null ? null : (typeof v === 'object' ? Number(v.amount) : v); }
function fmt(v) {
  if (v == null) return M.dash;
  if (typeof v === 'object') return new Intl.NumberFormat(LOCALE, { style: 'currency', currency: v.currency, maximumFractionDigits: 0 }).format(Number(v.amount));
  return new Intl.NumberFormat(LOCALE, { maximumFractionDigits: 1 }).format(v);
}
function delta(v) {
  if (v == null) return el('span', 'ab-delta', M.noBase);
  var sign = v > 0 ? '▲ +' : (v < 0 ? '▼ ' : '');
  return el('span', 'ab-delta' + (v > 0 ? ' ab-up' : (v < 0 ? ' ab-down' : '')), sign + fmt(v) + '%');
}
function note(root, availability) { root.appendChild(el('p', 'ab-note', M[availability] || M.no_data)); }
function bar(percent, cls) {
  var b = el('div', 'ab-bar');
  b.innerHTML = '<svg viewBox="0 0 100 10" preserveAspectRatio="none" aria-hidden="true"><rect class="ab-track" width="100" height="10"/><rect class="' + cls + '" width="' + Math.max(0.8, Math.min(100, percent)).toFixed(1) + '" height="10"/></svg>';
  return b;
}

Metric tiles

The key figures of the period with the change against the previous period of equal length. A data gap is a dash, not a zero.

GET /dashboards/overview?blocks=kpi · scope dashboards:read

Metric tilesexample
Impressions
182,400
▲ +6.1%
Clicks
4,210
▲ +8.5%
Sessions
3,920
▲ +8.9%
People from AI assistants
212
▲ +51.4%
Revenue
IDR 98,000,000
▲ +27.8%

The block drawn from the example response below. The figures are made up.

Response JSON

Example: the figures are made up; the fields match the contract.

{
  "data": {
    "from": "2026-10-01",
    "to": "2026-10-28",
    "blocks": {
      "kpi": {
        "availability": "ok",
        "data_as_of": { "g:search_console": "2026-10-25", "tracker": "2026-10-28", "crm": "2026-10-28" },
        "tiles": [
          { "key": "impressions", "current": 182400, "prev": 171900, "delta_pct": 6.1 },
          { "key": "clicks", "current": 4210, "prev": 3880, "delta_pct": 8.5 },
          { "key": "sessions", "current": 3920, "prev": 3601, "delta_pct": 8.9 },
          { "key": "ai_people", "current": 212, "prev": 140, "delta_pct": 51.4 },
          {
            "key": "revenue",
            "current": { "amount": "98000000.00", "currency": "IDR" },
            "prev": { "amount": "77000000.00", "currency": "IDR" },
            "delta_pct": 27.8
          }
        ]
      }
    }
  }
}

HTML

<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-tiles" aria-label="Metric tiles" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-tiles');
  // The address of your proxy: the API key stays on the server (see "Your own dashboard")
  fetch('/analytics-api/overview?blocks=kpi')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderTiles(root, body.data.blocks.kpi.tiles); })
    .catch(function () { root.textContent = 'Could not load the block'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Metric tiles: GET /dashboards/overview?blocks=kpi -> data.blocks.kpi.tiles
function renderTiles(root, tiles) {
  var grid = el('div', 'ab-tiles');
  tiles.forEach(function (t) {
    var tile = el('div', 'ab-tile');
    tile.appendChild(el('div', 'ab-label', T[t.key] || t.key));
    tile.appendChild(el('div', 'ab-value', fmt(t.current)));
    tile.appendChild(delta(t.delta_pct));
    grid.appendChild(tile);
  });
  root.appendChild(grid);
}

Daily line

One metric by day on inline SVG. Days without data break the line instead of pulling it to zero.

GET /analytics/timeseries · scope analytics:read

Daily lineexample
Clicks5,783▲ +11%
2026-09-15data through 2026-09-282026-09-28

The block drawn from the example response below. The figures are made up.

Response JSON

Example: the figures are made up; the fields match the contract.

{
  "data": {
    "metric": "clicks",
    "from": "2026-09-15",
    "to": "2026-09-28",
    "granularity": "day",
    "agg": "sum",
    "engine": "google",
    "availability": "ok",
    "reason": null,
    "series": [
      { "date": "2026-09-15", "value": 388 },
      { "date": "2026-09-16", "value": 402 },
      { "date": "2026-09-17", "value": 371 },
      { "date": "2026-09-18", "value": 415 },
      { "date": "2026-09-19", "value": 440 },
      { "date": "2026-09-20", "value": 398 },
      { "date": "2026-09-21", "value": 352 },
      { "date": "2026-09-22", "value": 361 },
      { "date": "2026-09-23", "value": 409 },
      { "date": "2026-09-24", "value": 431 },
      { "date": "2026-09-25", "value": 452 },
      { "date": "2026-09-26", "value": 466 },
      { "date": "2026-09-27", "value": 447 },
      { "date": "2026-09-28", "value": 401 }
    ],
    "comparison": { "current": 5783, "prev": 5210, "delta_pct": 11, "data_through": "2026-09-28" },
    "summary": { "total": 5783, "latest": 401, "non_zero_days": 14 },
    "cluster_applied": false,
    "data_as_of": { "g:search_console": "2026-09-28" }
  }
}

HTML

<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-timeseries" aria-label="Daily line" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-timeseries');
  // The address of your proxy: the API key stays on the server (see "Your own dashboard")
  fetch('/analytics-api/timeseries?metric=clicks&from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderTimeseries(root, body.data); })
    .catch(function () { root.textContent = 'Could not load the block'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Daily line: GET /analytics/timeseries (one metric) -> data
function renderTimeseries(root, item) {
  if (item.availability !== 'ok') { note(root, item.availability); return; }
  var vals = item.series.map(function (p) { return num(p.value); });
  var present = vals.filter(function (v) { return v != null; });
  var max = Math.max.apply(null, present.concat([1]));
  var W = 640, H = 200, step = vals.length > 1 ? W / (vals.length - 1) : 0, d = '', open = false, grid = '';
  vals.forEach(function (v, i) {
    if (v == null) { open = false; return; }          // a gap in the data: do not draw the line across it
    d += (open ? 'L' : 'M') + (i * step).toFixed(1) + ' ' + (H - 8 - (v / max) * (H - 16)).toFixed(1);
    open = true;
  });
  for (var g = 0; g <= 4; g++) { var y = (8 + g * (H - 16) / 4).toFixed(1); grid += '<line class="ab-grid" x1="0" x2="' + W + '" y1="' + y + '" y2="' + y + '"/>'; }
  var area = present.length === vals.length ? '<path class="ab-area" d="' + d + 'L' + W + ' ' + H + 'L0 ' + H + 'Z"/>' : '';
  var head = el('div', 'ab-head');
  head.appendChild(el('strong', 'ab-title', T[item.metric] || item.metric));
  head.appendChild(el('span', 'ab-big', fmt(item.summary ? item.summary.total : null)));
  head.appendChild(delta(item.comparison.delta_pct));
  var plot = el('div', 'ab-plot');
  plot.setAttribute('role', 'img');
  plot.setAttribute('aria-label', (T[item.metric] || item.metric) + ', ' + item.from + ' – ' + item.to + ', ' + M.total + ' ' + fmt(item.summary ? item.summary.total : null));
  plot.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" aria-hidden="true">' + grid + area + '<path class="ab-line" d="' + d + '"/></svg>';
  var axis = el('div', 'ab-axis');
  axis.appendChild(el('span', '', item.series[0].date));
  axis.appendChild(el('span', '', M.through + ' ' + item.comparison.data_through));
  axis.appendChild(el('span', '', item.series[item.series.length - 1].date));
  root.appendChild(head); root.appendChild(plot); root.appendChild(axis);
}

Chart.js

Code only: Chart.js is not loaded on this page. Install it from npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.

export function chartTimeseries(canvas, item) {
  return new Chart(canvas, {
    type: 'line',
    data: {
      labels: item.series.map(function (p) { return p.date; }),
      datasets: [{
        label: item.metric,
        data: item.series.map(function (p) { return num(p.value); }),
        borderColor: '#2d6a4f', backgroundColor: 'rgba(45, 106, 79, 0.14)',
        fill: true, tension: 0.25, spanGaps: false, pointRadius: 2
      }]
    },
    options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
  });
}

Traffic sources

Bars per source: sessions and leads. Search, AI assistants, social, ads and direct visits.

GET /analytics/traffic-sources · scope analytics:read

Traffic sourcesexample
Google
820leads: 9
Direct
410leads: 2
Yandex
260leads: 3
ChatGPT
190leads: 4
Social
96leads: 1

The block drawn from the example response below. The figures are made up.

Response JSON

Example: the figures are made up; the fields match the contract.

{
  "data": [
    { "source": "google", "kind": "search", "sessions": 820, "visitors": 640, "leads": 9 },
    { "source": "direct", "kind": "direct", "sessions": 410, "visitors": 330, "leads": 2 },
    { "source": "yandex", "kind": "search", "sessions": 260, "visitors": 214, "leads": 3 },
    {
      "source": "chatgpt",
      "kind": "ai_assistant",
      "sessions": 190,
      "visitors": 150,
      "leads": 4
    },
    { "source": "social", "kind": "social", "sessions": 96, "visitors": 88, "leads": 1 }
  ],
  "data_as_of": { "tracker": "2026-10-07" }
}

HTML

<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-traffic-sources" aria-label="Traffic sources" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-traffic-sources');
  // The address of your proxy: the API key stays on the server (see "Your own dashboard")
  fetch('/analytics-api/traffic-sources?from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderSources(root, body.data); })
    .catch(function () { root.textContent = 'Could not load the block'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Source bars: GET /analytics/traffic-sources -> data (an array)
function renderSources(root, rows) {
  if (!rows.length) { note(root, 'no_data'); return; }
  var max = Math.max.apply(null, rows.map(function (r) { return r.sessions; }));
  var list = el('div', 'ab-rows');
  rows.forEach(function (r) {
    var row = el('div', 'ab-row');
    row.appendChild(el('span', 'ab-name', T[r.source] || r.source));
    row.appendChild(bar(r.sessions / max * 100, 'ab-fill ab-k-' + esc(r.kind)));
    var nums = el('span', 'ab-num');
    nums.appendChild(el('strong', '', fmt(r.sessions)));
    nums.appendChild(el('span', 'ab-sub', M.leads + ': ' + fmt(r.leads)));
    row.appendChild(nums);
    list.appendChild(row);
  });
  root.appendChild(list);
}

Chart.js

Code only: Chart.js is not loaded on this page. Install it from npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.

export function chartSources(canvas, rows) {
  return new Chart(canvas, {
    type: 'bar',
    data: {
      labels: rows.map(function (r) { return r.source; }),
      datasets: [
        { label: 'sessions', data: rows.map(function (r) { return r.sessions; }), backgroundColor: '#2d6a4f' },
        { label: 'leads', data: rows.map(function (r) { return r.leads; }), backgroundColor: '#c9a96e' }
      ]
    },
    options: { indexAxis: 'y', responsive: true, scales: { x: { beginAtZero: true } } }
  });
}

Funnel

Steps from impressions to won deals. The client computes the share of the previous step; a step without data is marked.

GET /analytics/funnel · scope analytics:read

Funnelexample
Impressions
182,400▲ +6.1%
Clicks
4,2102.3% of previous step
Sessions
3,92093.1% of previous step
Leads
1122.9% of previous step
Deals created
—No data for the period
Deals won
1715.2% of previous step
Revenue
IDR 98,000,000▲ +27.8%

The block drawn from the example response below. The figures are made up.

Response JSON

Example: the figures are made up; the fields match the contract.

{
  "data": {
    "engine": "google",
    "from": "2026-10-01",
    "to": "2026-10-28",
    "steps": [
      {
        "key": "impressions",
        "availability": "ok",
        "sources": ["g:search_console"],
        "unit": "count",
        "current": 182400,
        "prev": 171900,
        "delta_pct": 6.1,
        "data_through": "2026-10-25"
      },
      {
        "key": "clicks",
        "availability": "ok",
        "sources": ["g:search_console"],
        "unit": "count",
        "current": 4210,
        "prev": 3880,
        "delta_pct": 8.5,
        "data_through": "2026-10-25"
      },
      {
        "key": "sessions",
        "availability": "ok",
        "sources": ["tracker"],
        "unit": "count",
        "current": 3920,
        "prev": 3601,
        "delta_pct": 8.9,
        "data_through": "2026-10-28"
      },
      {
        "key": "leads",
        "availability": "ok",
        "sources": ["crm"],
        "unit": "count",
        "current": 112,
        "prev": 94,
        "delta_pct": 19.1,
        "data_through": "2026-10-28"
      },
      {
        "key": "deals_created",
        "availability": "no_data",
        "sources": ["crm"],
        "unit": "count",
        "current": null,
        "prev": null,
        "delta_pct": null,
        "data_through": null
      },
      {
        "key": "deals_won",
        "availability": "ok",
        "sources": ["crm"],
        "unit": "count",
        "current": 17,
        "prev": 12,
        "delta_pct": 41.7,
        "data_through": "2026-10-28"
      },
      {
        "key": "revenue",
        "availability": "ok",
        "sources": ["crm"],
        "unit": "money",
        "current": { "amount": "98000000.00", "currency": "IDR" },
        "prev": { "amount": "77000000.00", "currency": "IDR" },
        "delta_pct": 27.8,
        "data_through": "2026-10-28"
      }
    ],
    "by_channel": [],
    "by_product": [],
    "crm": { "availability": "ok" }
  },
  "data_as_of": { "g:search_console": "2026-10-25", "crm": "2026-10-28" }
}

HTML

<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-funnel" aria-label="Funnel" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-funnel');
  // The address of your proxy: the API key stays on the server (see "Your own dashboard")
  fetch('/analytics-api/funnel?from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderFunnel(root, body.data); })
    .catch(function () { root.textContent = 'Could not load the block'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Funnel: GET /analytics/funnel -> data. The step shares are computed by the client.
function renderFunnel(root, data) {
  var list = el('div', 'ab-rows'), prev = null;
  data.steps.forEach(function (s) {
    var row = el('div', 'ab-row');
    var ok = s.availability === 'ok';
    row.appendChild(el('span', 'ab-name', T[s.key] || s.key));
    var cur = ok ? num(s.current) : null;
    if (ok && s.unit === 'count') {
      var rate = prev ? cur / prev * 100 : 100;      // share of the previous step that has data
      row.appendChild(bar(rate, 'ab-fill ab-k-search'));
    } else {
      row.appendChild(el('div', 'ab-bar ab-bar-none'));
    }
    var nums = el('span', 'ab-num');
    nums.appendChild(el('strong', '', ok ? fmt(s.current) : M.dash));
    if (!ok) nums.appendChild(el('span', 'ab-sub', M[s.availability] || M.no_data));
    else if (s.unit === 'count' && prev) nums.appendChild(el('span', 'ab-sub', fmt(cur / prev * 100) + '% ' + M.prevStep));
    else nums.appendChild(delta(s.delta_pct));
    row.appendChild(nums);
    list.appendChild(row);
    if (ok && s.unit === 'count' && cur > 0) prev = cur;
  });
  root.appendChild(list);
}

Chart.js

Code only: Chart.js is not loaded on this page. Install it from npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.

export function chartFunnel(canvas, data) {
  // Count steps with data only: money and unavailable steps do not share one scale.
  var steps = data.steps.filter(function (s) { return s.availability === 'ok' && s.unit === 'count'; });
  return new Chart(canvas, {
    type: 'bar',
    data: {
      labels: steps.map(function (s) { return s.key; }),
      datasets: [{ label: 'count', data: steps.map(function (s) { return num(s.current); }), backgroundColor: '#2d6a4f' }]
    },
    options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { type: 'logarithmic' } } }
  });
}

Visibility

Five sections, each with its own availability: a section may have no data or no source, and the block shows it.

GET /analytics/visibility · scope analytics:read

Visibilityexample
Visibility share
14.2%
▲ +10.1%
Citations in AI answers
10.4%
▲ +15.6%

Query positions

  • Top 318
  • Top 1074
  • Top 30210
  • Below530

The block drawn from the example response below. The figures are made up.

Response JSON

Example: the figures are made up; the fields match the contract.

{
  "data": {
    "from": "2026-10-01",
    "to": "2026-10-28",
    "engine": "google",
    "share": {
      "availability": "ok",
      "source": "g:search_console",
      "data_as_of": { "g:search_console": "2026-10-25" },
      "data": {
        "value": 14.2,
        "comparison": { "current": 14.2, "prev": 12.9, "delta_pct": 10.1 },
        "keywords": 312,
        "volume_total": 48200
      }
    },
    "distribution": {
      "availability": "ok",
      "source": "g:search_console",
      "data_as_of": { "g:search_console": "2026-10-25" },
      "data": {
        "top3": 18,
        "top10": 74,
        "top30": 210,
        "outside": 530,
        "total": 832,
        "median": 21.5,
        "average": 29.3
      }
    },
    "aeo": {
      "availability": "ok",
      "source": "aeo",
      "data_as_of": { "aeo": "2026-10-27" },
      "data": {
        "engines": [
          { "engine": "perplexity", "probes": 24, "cited": 3, "rate": 12.5 },
          { "engine": "chatgpt", "probes": 24, "cited": 2, "rate": 8.3 }
        ],
        "probes": 48,
        "cited": 5,
        "rate": 10.4,
        "comparison": { "current": 10.4, "prev": 9, "delta_pct": 15.6 }
      }
    },
    "regions": {
      "availability": "not_connected",
      "reason": "no_country_dimension",
      "source": "g:search_console",
      "data_as_of": {},
      "data": { "rows": [] }
    }
  }
}

HTML

<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-visibility" aria-label="Visibility" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-visibility');
  // The address of your proxy: the API key stays on the server (see "Your own dashboard")
  fetch('/analytics-api/visibility?from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderVisibility(root, body.data); })
    .catch(function () { root.textContent = 'Could not load the block'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Visibility: GET /analytics/visibility -> data. Five sections, each with its own availability.
function renderVisibility(root, v) {
  var s = v.share, dist = v.distribution, aeo = v.aeo;
  var box = el('div', 'ab-tiles');
  var a = el('div', 'ab-tile');
  a.appendChild(el('div', 'ab-label', M.share));
  if (s.availability === 'ok' && s.data.value != null) {
    a.appendChild(el('div', 'ab-value', fmt(s.data.value) + '%'));
    a.appendChild(delta(s.data.comparison ? s.data.comparison.delta_pct : null));
  } else { a.appendChild(el('div', 'ab-value', M.dash)); a.appendChild(el('span', 'ab-delta', M[s.availability] || M.no_data)); }
  box.appendChild(a);
  var b = el('div', 'ab-tile');
  b.appendChild(el('div', 'ab-label', M.aeo));
  if (aeo.availability === 'ok') {
    b.appendChild(el('div', 'ab-value', fmt(aeo.data.rate) + '%'));
    b.appendChild(delta(aeo.data.comparison ? aeo.data.comparison.delta_pct : null));
  } else { b.appendChild(el('div', 'ab-value', M.dash)); b.appendChild(el('span', 'ab-delta', M[aeo.availability] || M.no_data)); }
  box.appendChild(b);
  root.appendChild(box);
  root.appendChild(el('h4', 'ab-sub-title', M.spread));
  if (dist.availability !== 'ok') { note(root, dist.availability); return; }
  var parts = ['top3', 'top10', 'top30', 'outside'], x = 0, rects = '';
  parts.forEach(function (k, i) {
    var w = dist.data[k] / dist.data.total * 100;
    rects += '<rect class="ab-d' + (i + 1) + '" x="' + x.toFixed(1) + '" width="' + w.toFixed(1) + '" height="14"/>';
    x += w;
  });
  var stack = el('div', 'ab-stack');
  stack.innerHTML = '<svg viewBox="0 0 100 14" preserveAspectRatio="none" aria-hidden="true">' + rects + '</svg>';
  root.appendChild(stack);
  var legend = el('ul', 'ab-legend');
  parts.forEach(function (k, i) {
    var li = el('li', 'ab-d' + (i + 1) + '-key');
    li.appendChild(el('span', '', M[k]));
    li.appendChild(el('strong', '', fmt(dist.data[k])));
    legend.appendChild(li);
  });
  root.appendChild(legend);
}

Chart.js

Code only: Chart.js is not loaded on this page. Install it from npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() comes from the blocks code (an API value: number, money or null). null draws a gap, not zero.

export function chartVisibility(canvas, dist) {
  var d = dist.data;   // data.distribution.data: top3, top10, top30, outside (exclusive, they sum to total)
  return new Chart(canvas, {
    type: 'doughnut',
    data: {
      labels: ['top3', 'top10', 'top30', 'outside'],
      datasets: [{ data: [d.top3, d.top10, d.top30, d.outside], backgroundColor: ['#1a3c2a', '#2d6a4f', '#7fb89b', '#cfd8d2'] }]
    },
    options: { responsive: true, cutout: '62%' }
  });
}

"Pulse" findings

A short list of what was noticed over the last days: what grew, what fell and what is worth checking.

GET /dashboards/pulse · scope dashboards:read

"Pulse" findingsexample

New in 7 days: 2

  • Ниацинамид и пористостьниацинамид для пор · demand 3,400×2.4
  • Пептиды в сывороткахпептидная сыворотка состав · demand 1,900—

The block drawn from the example response below. The figures are made up.

Response JSON

Example: the figures are made up; the fields match the contract.

{
  "data": {
    "period": { "from": "2026-09-13", "to": "2026-10-10" },
    "new_7d": 2,
    "truncated": false,
    "alerts": [
      {
        "id": "alert_EXAMPLE_01",
        "topic_id": "topic_EXAMPLE_01",
        "topic_name": "Ниацинамид и пористость",
        "keyword": "ниацинамид для пор",
        "horizon": "short",
        "status": "open",
        "episode_start": "2026-10-04",
        "last_seen": "2026-10-10",
        "score": 0.82,
        "volume": 3400,
        "growth": 2.4,
        "sources": [{ "code": "serp_suggest", "outcome": "spike", "value": 120, "base": 50 }],
        "strategist_topic": { "id": "tpc_EXAMPLE_01", "status": "proposed" }
      },
      {
        "id": "alert_EXAMPLE_02",
        "topic_id": "topic_EXAMPLE_02",
        "topic_name": "Пептиды в сыворотках",
        "keyword": "пептидная сыворотка состав",
        "horizon": "mid",
        "status": "open",
        "episode_start": "2026-10-06",
        "last_seen": "2026-10-10",
        "score": 0.64,
        "volume": 1900,
        "growth": null,
        "sources": [{ "code": "serp_suggest", "outcome": "spike", "value": 40, "base": 0 }],
        "strategist_topic": null
      }
    ],
    "data_as_of": { "pulse": "2026-10-10" }
  }
}

HTML

<!-- The block container. Styles are from "Shared base"; the block code is included above. -->
<section class="ab" id="ab-pulse" aria-label="&quot;Pulse&quot; findings" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-pulse');
  // The address of your proxy: the API key stays on the server (see "Your own dashboard")
  fetch('/analytics-api/pulse')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderPulse(root, body.data); })
    .catch(function () { root.textContent = 'Could not load the block'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// "Pulse" findings: GET /dashboards/pulse -> data
function renderPulse(root, data) {
  if (!data.alerts.length) { note(root, 'no_data'); return; }
  root.appendChild(el('p', 'ab-sub', M.new7 + ': ' + fmt(data.new_7d)));
  var list = el('ul', 'ab-list');
  data.alerts.forEach(function (a) {
    var li = el('li', 'ab-item');
    var text = el('span', 'ab-item-text');
    text.appendChild(el('strong', '', a.topic_name || a.keyword));
    text.appendChild(el('span', 'ab-sub', a.keyword + ' · ' + M.volume + ' ' + fmt(a.volume)));
    li.appendChild(text);
    li.appendChild(el('span', 'ab-chip', a.growth == null ? M.dash : '×' + fmt(a.growth)));
    list.appendChild(li);
  });
  root.appendChild(list);
}

Next

Put the blocks into a page with a server proxy - your own dashboard. Fields and scopes are in the dashboards description.