Adding an intelligence widget

A widget is not one file — it's a matching set across four workspaces: a shared type, a ClickHouse query builder, a dispatch entry, a React component, and a registry entry. Rather than a generic checklist, this page traces one real widget — DORA Metrics — through every file it touches, so you can copy the pattern exactly.

deckgauge · Board · Intelligence
Lead time (p50)
31h
High
Deploy freq
4.2/wk
Elite
Change-fail
9%
High
Restore time
3.5h
Medium

The DORA Metrics widget this page traces — four tiles, each tiered Elite/High/Medium/Low.

The path, end to end

DORA Metrics computes four values from data Deckgauge already ingests — no new source, no new ClickHouse table. Here is every file in its path, in the order data actually flows.

1. Register the type — packages/shared

packages/shared/src/widget-types.ts is the single list every other layer reads from. DORA_METRICS is a member of the NEW_WIDGET_TYPES tuple, and WIDGET_SCOPE_REQUIREMENTS.DORA_METRICS lists ['github', 'gitlab', 'ado', 'jira'] — any one of those source kinds is enough to render it partially. A widget that needs genuinely new computation logic (not merely a query) also gets its own file here, the way DORA does: packages/shared/src/dora.ts defines the DoraMetric shape, the Elite/High/Medium/Low thresholds (DORA_BENCHMARKS), and buildDoraScorecard(), which turns raw numbers into tiered results. A widget that adds computation logic like this should have its own co-located test covering the thresholds and tiering, the same way dora.ts does internally — the published open-source snapshot strips every *.test.ts(x) file, so you're writing that test fresh rather than copying one.

2. Query ClickHouse — apps/api/src/intelligence-query/builders

dora-metrics.ts exports buildDoraMetricsSql({ config, scope }), which builds one parameterized SQL statement with a subquery per metric (lead time from the PR union, deploy frequency from merge counts, change-failure from a corrective-commit regex over commits, time-to-restore from the issue union) and returns null when the board has no source at all. At the bottom of the file, registerBuilder('DORA_METRICS', buildDoraMetricsSql) self-registers it. That only takes effect because apps/api/src/intelligence-query/builders/index.ts imports the file for its side effect — import './dora-metrics.js' sits alongside every other builder's import; skip that line and the builder silently never registers. Give your builder the same test-first treatment: assert the SQL it produces contains the right columns and date-range parameters for each source combination before trusting its output anywhere else — the published snapshot doesn't ship this repo's own test suite, so there's no existing builder test to copy.

3. Shape the response — apps/api/src/widgets/widget-data.service.ts

This one file holds a method per widget. getDoraMetrics(boardId, config) resolves the board's connected-source scope, calls buildDoraMetricsSql, runs the resulting SQL against the shared ClickHouse client, and feeds the raw row into buildDoraScorecard() from packages/shared — converting a deploy count into a per-week rate, a corrective-commit count into a percentage, and null values into null (never zero) so a missing source renders as "—" instead of a misleading 0. Its return type, DoraMetricsResult, is declared right above the method in the same file.

4. Wire the dispatch — apps/api/src/widgets/widget-data.routes.ts

WIDGET_METHOD_MAP is a lookup from widget-type string to service-method name; it has the line DORA_METRICS: 'getDoraMetrics'. The route handler behind both GET /boards/:boardId/widgets/:widgetType/data and the batched POST /boards/:boardId/widgets/data looks the widget type up in this map and calls the method dynamically — add a type to NEW_WIDGET_TYPES without a matching map entry and the API responds 501 Widget type not yet implemented instead of guessing.

5. Render it — apps/web/app/components/dashboard/widgets/DoraMetricsWidget.tsx

The component calls useWidgetData<Data>(boardId, 'DORA_METRICS', config) (a hook in the same widgets/ directory that wraps the batched fetch and caching), then renders a loading state, an empty-source state (WidgetEmptyState, when the service returned emptyReason), or the four metric tiles with a tier badge each. Test yours the same way: mock fetchWidgetData from apps/web/app/actions/widgets.ts and assert all three states render correctly — loading, empty-source, and populated, including any null-metric fallback badge. As with the other test files on this page, write it fresh; the published snapshot excludes the existing suite.

6. Register it in the picker — apps/web/app/components/dashboard/widgetRegistry.tsx

This file imports every widget component and exports one array the rest of the dashboard reads from — WidgetPicker.tsx (the add-widget menu) and DashboardCanvas.tsx (what actually renders a saved widget) both consume it, so a widget invisible here can never be added to a board even though the API can already serve it. DORA's entry sets its label, description, category: 'flow', the source kinds it needs, its chartKind, a default grid size, and a weeks config field with 4/12/26-week options — and points component at DoraMetricsWidget.

Optional but common: shipping it by default, and a public doc

  • A default presetapps/api/src/widgets/presets.service.ts defines ENGINEERING_INTELLIGENCE_PRESET_V1, the widget set a new board gets when the preset is applied. DORA Metrics has an entry there with its default layout and a { weeks: 12 } config — add one here only if the widget should ship out of the box rather than be picked manually.
  • A "read the full guide" linkapps/web/app/components/dashboard/widgetDocs.ts maps a widget type to a slug under /docs/widgets/<slug>; DORA_METRICS: 'dora-metrics' is what makes the in-app help popover link out to the published DORA Metrics page. Only add an entry once that page actually exists.

If a new widget needs data Deckgauge doesn't ingest yet

Everything above assumes the metric can be computed from data already flowing into ClickHouse. If it needs a field or table that isn't there, the work starts one layer earlier, in apps/worker: extend the relevant sync handler and its dual-writer (for example apps/worker/src/github-dual-writer.ts) to capture and insert the new field, and add or extend the ClickHouse table it lands in. That's a bigger change than this page covers — see Architecture for how the worker's dual-write path fits together before starting there.

Related

Last updated