# VideoBI dashboards: fetching and rendering with Apache ECharts

Reference for developers and AI agents. It describes the VideoBI Dashboard API and how to turn its JSON into charts
that look and read like VideoBI's own dashboards, using Apache ECharts (VideoBI uses ECharts 6). Follow it exactly
where it gives numbers; where it says "should", it describes VideoBI's own behavior, which you may adapt.

- Human docs: https://videobi.ai/developers
- API base URL: https://app.videobi.ai
- API version described: 1 (every response has `"api_version": 1`)

## 1. Fetching

All requests need an organization API key, created in VideoBI under Settings → API keys (owners and admins;
Professional and Enterprise plans):

```
Authorization: Bearer vai_key_...
```

Call from a server, never from browser code: the key would be exposed, and the API sends no CORS headers.

| Request | Returns |
|---|---|
| `GET /v1/api/dashboards` | `{items: [{id, name, description, audience, cadence, version, published_at}]}` |
| `GET /v1/api/dashboards/{id}` | one dashboard with each widget's data (below) |

Query parameters for the second:

- `range`: one of `today`, `yesterday`, `last_24_hours`, `last_7_days`, `this_week`, `last_week`, `last_4_weeks`,
  `last_30_days`, `this_month`, `last_month`, `last_90_days`, `this_quarter`, `last_quarter`, `last_13_weeks`,
  `last_12_months`. Omit it for the dashboard's default. `last_N_days` ends at local midnight today (today is
  excluded); weeks start on Monday.
- `from` and `to`: ISO 8601 with offset, instead of `range`; both or neither.
- `location`: one location id.
- `daypart`: a daypart name such as `lunch_peak`.

Errors are `application/problem+json`: `{type, title, status, detail}`. 400 bad parameter, 401 bad or missing key,
403 plan or location not allowed, 404 no such published dashboard, 429 over 60 requests a minute per key. Data
refreshes every 15 minutes: cache responses for a few minutes.

## 2. The response

```json
{
  "api_version": 1,
  "dashboard": { "id": "…", "name": "Area manager weekly", "description": "…", "audience": "manager",
                 "cadence": "weekly", "version": 1, "published_at": "…", "columns": 12 },
  "time_range": { "preset": "last_7_days" },
  "timezone": "America/Los_Angeles",
  "filters": { "location_ids": null, "daypart": null },
  "rendered_at": "2026-10-06T22:07:34.448Z",
  "widgets": [
    { "id": "waste", "title": "Waste", "viz": "stat",
      "layout": { "x": 0, "y": 0, "w": 3, "h": 3 },
      "metric": { "id": "waste_value", "name": "Waste", "description": "…", "unit": "currency",
                  "format": "currency", "direction": "lower_is_better" },
      "query": { "metric": "waste_value", "grain": "period", "group_by": [], "compare_to": "prior_period", "…": "…" },
      "display": { "format": "currency", "aggregate": "sum" },
      "data": { "columns": [ … ], "rows": [ … ], "coverage": { … }, "meta": { … } } }
  ]
}
```

Widget fields:

- `viz`: how to draw it (section 5).
- `layout`: `{x, y, w, h}` on a grid of `dashboard.columns` (12) columns (section 4).
- `metric`: `{id, name, description, unit, format, direction}` or `null` for non-metric widgets.
  - `unit`: `count`, `ratio` (0–1), `minutes`, `hours`, `seconds`, `currency`, `score`, `index`.
  - `direction`: `higher_is_better`, `lower_is_better` or `neutral`.
- `query`: what was computed.
  - `grain`: `now`, `15m`, `hour`, `day`, `week`, `month`, `quarter` or `period` (one total for the whole range).
  - `group_by`: up to 3 of `location`, `zone`, `zone_role`, `hour_of_day`, `day_of_week`, `counter_config`.
  - `compare_to`: `prior_period`, `prior_year` (52 weeks earlier, same weekdays) or `null`.
- `display`: presentation hints (section 3.3).
- `data`: the result (section 3), or `{"error": {"code", "message"}}` if this widget failed. Show the message in the
  widget's frame and draw the rest of the dashboard normally.

Use the title from `title`, falling back to `metric.name`. Use the number format `display.format ?? metric.format`.

## 3. Metric widget data

### 3.1 Shape

```ts
{
  columns: { name: string; type: "timestamp" | "string" | "number" | "ratio" | "currency" | "integer" }[];
  rows: unknown[][];          // positional: rows[i][j] is the value of columns[j]
  coverage: { overall: number | null; byRow: (number | null)[] };
  meta: { grain, timezone, from, to, asOf, partialLastBucket, warnings: string[], source, daily, took_ms, cached };
  // only on some stat and table widgets:
  today?: number | null;                                        // display.avg_today: the metric over today
  baseline?: { now, avg, diff, pct, days };                     // display.baseline: now vs the same time on past days
  summary?: Record<string, unknown> | null;                      // display.summary_row: the all-locations row
}
```

Rows are arrays; read cells by column name: `const col = Object.fromEntries(data.columns.map((c, i) => [c.name, i]))`.

### 3.2 Columns, in order

1. `bucket` (timestamp): start of the time bucket in the location's local time, as an ISO instant. For grain
   `period` it is the window start (one bucket).
2. One or two columns per `group_by` dimension, in order:
   - `location` → `location_id`, `location_name`
   - `zone` → `zone_id`, `zone_name`
   - `zone_role`, `counter_config` → a string column of the same name
   - `hour_of_day` → integer 0–23 (local); `day_of_week` → integer 0–6, 0 = Sunday
3. `value`: the metric. A `ratio` is 0–1 (0.8 means 80%). Durations are in seconds when the format is `clock`.
4. Sometimes extra metric columns (names starting `s_` are 0–1 component scores). Don't chart them unless asked.
5. `coverage_pct` (0–1): how much of the time the cameras could see. Below 0.9 the number is less certain; below 0.8,
   treat it as unreliable.
6. Only with `compare_to`: `compare_value` (may be null), `delta` = value − compare_value, `compare_bucket`.

Rows come sorted by `bucket` then dimensions, or by `value` when `display.sort` is `asc` or `desc`. Show
`location_name` and `zone_name` to people, never ids. A null value is "no data": draw a gap, never zero.

### 3.3 Display hints

| Field | Meaning |
|---|---|
| `format` | `integer`, `percent`, `duration`, `clock`, `currency`, `decimal1`, `decimal2`, `score100` |
| `aggregate` | how to collapse rows to one number for `stat` and `gauge`: `sum`, `avg`, `last`, `max`, `min` |
| `sort`, `limit` | `asc`, `desc` or `time`; how many categories or rows to keep |
| `thresholds` | `[{lt?: number, gte?: number, level: "ok" | "watch" | "act"}]`, checked in order, first match wins; none matching means `ok` |
| `target` | a target value in the metric's unit: draw a dashed reference line |
| `reference` | `average` (draw the average of the values) or `none` |
| `highlight` | `worst`, `below_target`, `above_target` or `none`: which bars to color as the accent |
| `orientation` | `horizontal` or `vertical` bars |
| `unit_label` | a suffix for the big number |
| `compare_label` | the header for the comparison column in tables |
| `show_coverage` | `false`: don't show coverage |
| `message`, `message_ok` | text templates for `message` widgets: `{{value}} {{zone_name}} {{location_name}} {{threshold}}` |

### 3.4 Formatting numbers

```js
function formatValue(n, format) {
  if (n === null || n === undefined || n === "") return "n/a";
  n = Number(n);
  switch (format) {
    case "percent":  return (n * 100).toFixed(1) + "%";                 // value is a 0–1 ratio
    case "integer":  return Math.round(n).toLocaleString("en-US");
    case "duration": { const s = Math.round(n); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }
    case "clock": {  // seconds → mm:ss or h:mm:ss
      const s = Math.round(n), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = String(s % 60).padStart(2, "0");
      return h ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${String(m).padStart(2, "0")}:${sec}`;
    }
    case "currency": {
      const a = Math.abs(n), sign = n < 0 ? "-" : "";
      return a >= 1000 ? `${sign}$${(a / 1000).toFixed(1)}K` : a >= 100 ? `${sign}$${Math.round(a)}` : `${sign}$${a.toFixed(2)}`;
    }
    case "decimal2": return n.toFixed(2);
    case "score100": return String(Math.round(n));
    default:         return Number.isInteger(n) ? n.toLocaleString("en-US") : n.toFixed(1);  // decimal1
  }
}
// A change for percent metrics is in points: (delta * 100).toFixed(1) + " pts"; otherwise a signed formatValue.
```

Time labels, in the response's `timezone`:

- grain `15m`, `hour`, `now`: `Mon D HH:MM`, or just `HH:MM` when all points fall on one day
- `day`, `week`: `Mon D` (add `, YYYY` when the series spans more than one year)
- `month`, `quarter`: `Mon YYYY`
- `hour_of_day`: `6a`, `12p`, `3p`; `day_of_week`: `Sun`…`Sat`; `counter_config`: underscores to spaces,
  first letter capitalized

## 4. Layout

A 12-column grid (`dashboard.columns`). VideoBI uses rows 56 px tall with a 12 px gap:

- width = `w / columns` of the container, less the gaps
- height = `h × 56 + (h − 1) × 12` px
- position: `x` columns from the left, `y` rows from the top

On screens narrower than 720 px, stack the widgets in `(y, x)` order at full width, keeping their heights. A widget
without `layout` can go in a 4×4 slot.

## 5. Drawing each `viz`

Base ECharts settings for every chart:

```js
const PALETTE = ["#3568C4", "#E08A2E", "#3FA37A", "#B0578F", "#5C6E8E", "#D9B43C", "#4CA6C6", "#A8624B"];
const base = { color: PALETTE, animation: false, textStyle: { fontFamily: "Inter, system-ui, sans-serif" } };
// chart.setOption({ ...base, ...option }, { notMerge: true })
```

### 5.1 Shaping rows into categories and series

Dimension columns, in order of preference: `location_name`, `zone_name`, `zone_role`, `hour_of_day`,
`day_of_week`, `counter_config` (never the id columns).

- **Time grains** (not `period`): categories = the distinct `bucket` values, ascending, as time labels.
  - Each distinct combination of dimension values is one series, named by joining the labels with `" · "`.
  - With no dimensions there is one series, named `metric.name`.
  - For `day` and coarser, key buckets by calendar date so locations in different time zones line up.
- **Grain `period`**: categories = the first ordinal dimension (`hour_of_day`, `day_of_week`), else the first
  dimension; any remaining dimensions are the series.
  - Ordinal categories sort numerically. Others sort by value when `display.sort` is `asc` or `desc`, else they
    keep the API's order.
  - Apply `display.limit` to the categories.
  - With no dimensions there is one category, `metric.name`.
- Values: `Number(value)`, or `null` (a gap).
- **Charts don't draw `compare_value`.** Comparisons belong in `stat` and `table`.

### 5.2 `line`, `area`, `bar`

```js
const isBar = viz === "bar";
const option = {
  tooltip: { trigger: "axis", valueFormatter: (v) => formatValue(v, format),
             axisPointer: isBar ? { type: "shadow" } : { type: "line", lineStyle: { type: "dashed" } } },
  grid: { left: 4, right: target != null ? 56 : 20, top: 12, bottom: series.length > 1 ? 30 : 4, containLabel: true },
  legend: series.length >= 2 && series.length <= 8 ? { bottom: 0, icon: "roundRect" } : undefined,
  xAxis: { type: "category", data: categoryLabels, boundaryGap: isBar,
           axisLabel: { hideOverlap: true, formatter: (s) => (s.length > 16 ? s.slice(0, 15) + "…" : s) } },
  yAxis: { type: "value", splitLine: { lineStyle: { type: [3, 4] } },
           ...(isBar ? { min: 0, max: format === "percent" ? 1 : undefined } : {}),
           axisLabel: { formatter: (v) => (format === "percent" ? Math.round(v * 100) + "%" : formatValue(v, format)) } },
  series: series.map((s, i) => ({
    name: s.name, type: isBar ? "bar" : "line", data: s.values, smooth: false, connectNulls: false,
    showSymbol: !isBar && categoryLabels.length <= 2, lineStyle: { width: 2 },
    areaStyle: viz === "area" ? { opacity: 0.16 } : series.length === 1 && !isBar ? { opacity: 0.07 } : undefined,
    itemStyle: isBar ? { borderRadius: [4, 4, 0, 0] } : undefined, barMaxWidth: 40, barCategoryGap: "40%", barGap: "12%",
    label: isBar && categoryLabels.length * series.length <= 12
      ? { show: true, position: "top", formatter: (p) => formatValue(p.value, format) } : undefined,
    markLine: target != null && i === 0 ? { symbol: "none", data: [{ yAxis: target, lineStyle: { type: "dashed" },
      label: { formatter: "Target " + formatValue(target, format) } }] } : undefined,
  })),
};
```

- `target` is `display.target`. Bars sit side by side: VideoBI doesn't stack series.
- For lines, zoom the y axis to the data with about 15% padding, never below 0, and at most 1 for percent.
- Always use a category x axis, not a time axis.

**Ranked bars** (one series, categories that are locations, zones and the like, `orientation` not `vertical`)
should be horizontal:

- `yAxis: {type: "category", inverse: true}` with the names; `xAxis: {type: "value", show: false, min: 0}`.
- Labels at the end of each bar.
- Bars grey (`#C3C9D4`), with flagged bars in the accent (`#24509F`). The flag follows `display.highlight`:
  - `worst`: the lowest value when `direction` is `higher_is_better`, the highest when `lower_is_better`.
  - `below_target` / `above_target`: values on the wrong side of `display.target`.
  - Default: `below_target` or `above_target` when there's a target, `none` when direction is neutral, else `worst`.
- Reference lines: the target (dashed, "Target X") and, unless `reference` is `none`, the average of the values
  (dotted, "Average X").

### 5.3 `heatmap`

```js
{
  tooltip: { position: "top" },
  grid: { left: 4, right: 4, top: 8, bottom: 48, containLabel: true },
  xAxis: { type: "category", data: categoryLabels, splitArea: { show: false } },
  yAxis: { type: "category", data: seriesNames },
  visualMap: { min: 0, max: maxValue, orient: "horizontal", left: "center", bottom: 0, calculable: false,
               inRange: { color: ["#EEF0F4", "#3568C4"] } },
  series: [{ type: "heatmap", data: cells /* [xIndex, yIndex, value] */,
             itemStyle: { borderColor: "#FFFFFF", borderWidth: 2, borderRadius: 3 },
             label: { show: cells.length <= 60, formatter: (p) => formatValue(p.value[2], format) } }],
}
```

### 5.4 `stat` (one big number, not a chart)

1. Value: collapse the rows with `display.aggregate`. If absent, use `avg` for percent and ratio metrics, `last`
   for grain `now`, and `sum` otherwise.
2. Comparison: the same aggregate over `compare_value`, if any are non-null. Show `▲` or `▼` with the formatted
   change and "vs prior period" (or "vs last year").
   - Green if the change is good for `metric.direction`, red if bad, grey if neutral or zero.
3. Level: run the value through `display.thresholds`. Color the number and add a "Watch" or "Act" chip.
4. Optional sparkline: when grain isn't `period` and there are at least 3 buckets, a small line of the values
   summed per bucket (an ECharts line with no axes works).
5. Show `coverage_pct` as a note when the average is below 0.9.

### 5.5 `gauge`

A half-circle from 0 to max (1 for percent; otherwise 1.1 × the largest threshold or value), filled in the
threshold level's color, with the value in the middle. ECharts `series: [{type: "gauge", startAngle: 180,
endAngle: 0, min: 0, max, progress: {show: true}, detail: {formatter}}]` matches it well.

### 5.6 `table`

- Columns: the dimension name columns, then `value`, then, with a comparison, `compare_value` (as "Prior" or
  `display.compare_label`) and `delta` (as "Change", colored by direction).
- Hide id columns, `compare_bucket` and `s_*` columns.
- Show `coverage_pct` only if some row is below 0.95.
- Hide the time column for grain `period`.
- Sort by `display.sort`, then apply `display.limit`.
- Color a value by its threshold level. If `summary` is present, pin it as a first row labelled "All locations".

### 5.7 Other metric widgets

- **`pivot`**: rows = `group_by[0]`; columns = `group_by[1]`, or `bucket` for time grains. Shade cells by value,
  min to max.
- **`list`**: rows sorted by value, descending; label = `zone_name` ?? `location_name` ?? `zone_role`, with a
  threshold-colored dot and the formatted value.
- **`message`**: for each zone's latest row, evaluate thresholds; for each `watch` or `act` row, fill the
  `display.message` template. `act` lines come first, at most 6. If none, show `display.message_ok` (default
  "All clear").
- **`map_floor`**: draw as a `table`.

### 5.8 Media widgets

- `image_carousel` and `clip_carousel`: `data.assets[]` with `kind`, `url`, `thumbnail_url`, `captured_at`
  (or `start_ts`/`end_ts`), `location_name`, `zone_name`, `alert_message`, `labels`.
  - Links are signed and valid for 10 minutes. Re-fetch the dashboard for fresh ones; don't store them.
  - `url` is null unless `status` is `available`.
- `snapshot`: `data.snapshot = {url, captured_at}` and `data.camera = {name, …}`.
- `live_stream`: not available through the API (`data.live.available` is false). Show the snapshot or skip it.

### 5.9 Composite and live widgets

These return their own top-level key instead of `columns` and `rows`:

| `viz` | data key |
|---|---|
| `brief` | `brief`: `{headline, sentences[]}`: render as a heading and paragraph |
| `scorecard` | `scorecard.stores[]`: render as a table (execution index below 65 is "act", below 80 "watch") |
| `dt_lane` | `lane` |
| `hold_board` | `hold_board` |
| `pickup_shelf` | `pickup` |
| `kitchen_stations` | `stations` |
| `table_status` | `tables` |
| `store_actions` | `store_actions` |
| `bake_plan` | `bake_plan` |
| `dock_doors` | `dock_doors` |
| `station_flow` | `station_flow` |
| `assumptions` | `assumptions` |
| `daypart_table` | `daypart_table` |
| `ops_scorecard` | `ops_scorecard` |
| `cost_bridge` | `cost_bridge` |
| `detention_split` | `detention_split` |
| `cfo_actions` | `cfo_actions` |

For any other key, render a simple table of its rows if it holds an array of objects; otherwise show the title and
"Shown in VideoBI" with a link to https://app.videobi.ai. `records` returns `{records: {columns, rows, total}}`:
draw it as a table.

## 6. Colors and levels

| Level | Text | Fill |
|---|---|---|
| ok | `#0F6B3A` | `#DCF3E6` |
| watch | `#7A5200` | `#FDEFC7` |
| act | `#A11B1B` | `#FBDCDC` |

Other colors:

- Neutral text: `#232837` (headings), `#6B7486` (secondary).
- Grid lines and borders: `#DDE1E8`.
- Accent: `#24509F`.

Color always comes with words ("Act", "▲ 4.2 pts", "Target 80%"), never alone.

## 7. A minimal renderer

```js
import * as echarts from "echarts";

// on your server: const dash = await (await fetch(url, { headers: { Authorization: `Bearer ${key}` } })).json();
export function renderDashboard(dash, container) {
  const cols = dash.dashboard.columns || 12;
  container.style.cssText = `display:grid;grid-template-columns:repeat(${cols},1fr);grid-auto-rows:56px;gap:12px`;
  for (const w of dash.widgets) {
    const el = document.createElement("section");
    const { x = 0, y = 0, w: cw = 4, h = 4 } = w.layout || {};
    el.style.cssText = `grid-column:${x + 1} / span ${cw};grid-row:${y + 1} / span ${h};border:1px solid #DDE1E8;border-radius:8px;padding:12px;display:flex;flex-direction:column`;
    el.innerHTML = `<h3 style="margin:0 0 8px;font:600 14px system-ui">${escape(w.title || w.metric?.name || "")}</h3>`;
    const body = document.createElement("div"); body.style.flex = "1"; el.appendChild(body); container.appendChild(el);
    if (w.data?.error) { body.textContent = w.data.error.message; continue; }
    if (["line", "area", "bar", "heatmap"].includes(w.viz) && w.data?.rows) {
      echarts.init(body).setOption(buildChartOption(w), { notMerge: true });   // sections 5.1–5.3
    } else if (w.viz === "stat" && w.data?.rows) {
      body.innerHTML = statHtml(w);                                             // section 5.4
    } else if (w.data?.rows) {
      body.appendChild(tableEl(w));                                             // section 5.6
    } else {
      body.textContent = "Shown in VideoBI";                                    // section 5.9
    }
  }
}
const escape = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
```

## 8. Checklist

- Server-side fetch with the key; cache a few minutes; handle 401, 403, 404 and 429.
- Read cells by column name; show names, never ids; null means a gap.
- Format with `display.format ?? metric.format`; ratios are 0–1.
- Judge good and bad with `metric.direction`; levels with `display.thresholds`.
- One failed widget (`data.error`) never blanks the dashboard.
- Media links expire in 10 minutes.
