Data-heavy operations
Analysis dashboard
A finished page under Kiln. Copy it and it is yours outright: unlike a brickwork component or shell, it never upgrades under semver.
Skip to main content
Revenue analysis
Q3 2026 against the same quarter last year. Figures settle 48 hours after period close.
Headline figures
Net revenue
GBP 1.24m
GBP 1.08m same quarter last year
increased
15% higher
New accounts
412
increased
38 more than Q2
Cost per account
GBP 38.10
decreased
18% lower, which is the target
Net revenue by month
Net revenue by month
No chart yet
Connect a charting library to plot net revenue by month, then pass its mount markup as mount=.
Read the chart contractBreakdowns
-
Direct sales GBP 612k
-
Partner referrals GBP 398k
-
Self-serve GBP 230k
-
United Kingdom GBP 704k
-
Ireland GBP 341k
-
Netherlands GBP 195k
Progress against target
78%
Scroll inside the frame to inspect the full page.
{% extends "brickwork/shell/app.html" %}
{% comment %}
An analysis dashboard: one question answered at the top, the shape of the
answer over time beneath it, then the breakdowns that explain it.
COPY THIS FILE into your project and edit it. It is not on the template loader
path, so you cannot extend it (ADR-056).
What your view must supply:
headline_tiles the weighted scorecard grid (see the shape note below)
channel_rows ranked rows: label / amount / value dicts
region_rows ranked rows: label / amount / value dicts
nav_items / nav_active as in app/list.html
Optional when you wire a charting library:
revenue_mount a {% templatetag openblock %} bw_chart_mount {% templatetag closeblock %} result, marked safe
The default copy below uses the chart card's empty state so the page still
reads as designed before an engine is connected. Swap empty= for mount=
when you have one.
Every heading, caption and judgement below is typed into the template, which is
where you change them.
WHY THIS PAGE IS SHAPED THIS WAY, since it is the part worth copying:
A dashboard is not a list of numbers. It is an argument, and an argument has an
order. This page reads top to bottom as one: the headline says what happened,
the chart shows the shape of it, the breakdowns say where it came from, and the
gauge says whether we are on track. A reader who stops after the first band has
still learned the answer. That ordering, not the choice of components, is what
makes a dashboard readable.
So the first band is NOT four equal tiles. Equal tiles say every number matters
equally, which is never true, and a reader scanning them has to work out the
ranking themselves. The scorecard's span= carries that ranking instead: the
metric this dashboard exists to report gets span=2 and is read first, and the
supporting metrics take one column each. Change which tile is wide and you have
changed what the dashboard is about, which is the right amount of effort for
that decision.
THE SCORECARD'S SHAPE, and why it needs your view:
_scorecard.html takes items as a list of dicts, each {content, span}, where
content is already-rendered, already-safe markup. A Django template cannot
build a list of dicts and cannot capture an {% templatetag openblock %} include
{% templatetag closeblock %} into a variable, so this one grid genuinely has to
come from Python. Build it in your view:
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
headline_tiles = [
{"content": mark_safe(render_to_string(
"brickwork/components/_stat_comparison.html",
{"label": "Net revenue", "current": "GBP 1.24m", "previous": "GBP 1.08m",
"period_label": "same quarter last year", "trend": "up",
"trend_label": "15% higher", "size": "lg"})), "span": 2},
...
]
The grid is 1 column on a phone, 2 at sm and 4 at lg, so a span=2 tile is full
width on a phone, full width at sm, and half the row at lg. Spans read as
designed at lg and clamp gracefully below it; there is nothing to write for the
narrow case.
ON TREND, and this is the trap worth reading twice: trend states the DIRECTION
THE NUMBER MOVED, never whether that is good news. _trend_indicator.html renders
trend="down" as a down arrow plus the hidden word "decreased", and you cannot
override that word. A cost that fell moved DOWN, so it takes trend="down" even
though falling is what you wanted; the judgement goes in trend_label, in words
("18% lower, which is the target"). Get this backwards and a screen reader
announces the opposite of what a sighted reader sees. No gate in this package
catches it: not axe, not the render tests, not a visual check. Only reading it
does.
States: the scorecard has no states of its own, each tile carries its own
(see _stat.html and _stat_comparison.html's own headers); the chart card's
loading/error/empty/populated states, which are mutually exclusive and
resolve in that order (see _chart_card.html's own header); each ranked
list's own loading/empty states; the gauge's threshold bands.
Accessibility: inherits shell/app.html's skip link, sidebar/drawer nav and
page-header region. Every number is visible text: the chart is a mount
whose accessible name is supplied at the call site, the sparkline and the
ranked bars are aria-hidden decoration reinforcing figures stated in
words beside them, and the gauge always renders its own numeric reading.
No reading on this page rides on colour or on a shape alone. Covered by
the archetype harness's full gate sweep (render, axe WCAG 2.2 AA, no
horizontal overflow, light/dark distinctness, skip-link first-tab-stop
with JS disabled) at every W0.1 breakpoint, both themes.
Responsive: no breakpoint switch of its own; inherits shell/app.html's
sidebar-to-drawer collapse at --bw-breakpoint-md (48rem). The scorecard
steps 1 to 2 to 4 columns at its own sm and lg breakpoints and clamps
wide spans automatically; the ranked lists and chart card reflow inside
whatever column they land in.
{% endcomment %}
{% load brickwork_components brickwork_nav %}
{% block page_title %}Revenue analysis - Northwind{% endblock %}
{% block sidebar %}{% bw_nav nav_items nav_active %}{% endblock %}
{% block sidebar_mobile %}{% bw_nav nav_items nav_active %}{% endblock %}
{% block brand_wordmark %}Northwind{% endblock %}
{% block page_header %}
{% include "brickwork/components/_page_header.html" with title="Revenue analysis" description="Q3 2026 against the same quarter last year. Figures settle 48 hours after period close." %}
{% endblock %}
{% block page_actions %}
{% bw_button "Export" variant="secondary" icon="download" href="/analysis/export/" %}
{% endblock %}
{% block content %}
<div class="bw-section-stack">
{% comment %}
BAND 1, the answer. One weighted grid, not a row of equal tiles. The wide
tile is a comparison (this period against last), because the question this
dashboard answers is "how are we doing against last year", and a bare number
cannot answer a comparative question. The three supporting tiles are ordinary
stats, deliberately quieter.
The heading is visible via .bw-band-heading rather than hidden: _scorecard.html
is view-only tiles with no title mechanism of its own (ADR-090 decision 4), so
nothing else on the page carries "Headline figures" for a reader to see, and
hiding it would leave the page's opening band unlabelled to anyone but a
screen reader user.
{% endcomment %}
<section aria-labelledby="headline-heading">
<h2 id="headline-heading" class="bw-band-heading">Headline figures</h2>
{% include "brickwork/components/_scorecard.html" with items=headline_tiles %}
</section>
{% comment %}
BAND 2, the shape of the answer over time. This leads the body of the page
rather than sitting under a wall of counts, because a trend line answers "is
this getting better" faster than any number does.
The default below uses empty=True so the card's designed empty state shows
before an engine is wired (icvoss/django-brickwork#509). When you have a
library, drop empty= and pass mount=revenue_mount instead:
revenue_mount = render_to_string(...) or capture the tag:
{% templatetag openblock %} bw_chart_mount aria_label="Net revenue by month, Q3 2026" as revenue_mount {% templatetag closeblock %}
The accessible name is REQUIRED unless you pass decorative=True, and a chart
carrying the page's main finding is never decorative.
NOTE the card title, which used to be a trap and no longer is. _chart_card.html
EXTENDS _card.html and does not redeclare the title block, so it inherits
_card.html's dual consumption on that one region (icvoss/django-brickwork#398,
ADR-090 decision 3): title="..." on the include below now renders the same
<h2 class="bw-card__title"> a caller filling {% templatetag openblock %} block
title {% templatetag closeblock %} by hand would write. A caller who instead
extends the template and fills the block still wins, since block override
takes precedence by construction; nothing here changes that path.
The band's own <h2> stays visually hidden here even so, because this band's
title is genuinely carried by the card rather than by the band: one card, one
chart, one title, and duplicating it as a second visible heading immediately
above the card would read as two headings for one thing. The band-level <h2>
exists only to give the <section> its accessible name via aria-labelledby;
give the CARD the visible title with title="Net revenue by month" below, and
it, not the hidden band heading, is what a sighted reader sees.
{% endcomment %}
<section aria-labelledby="trend-heading">
<h2 id="trend-heading" class="bw-visually-hidden">Net revenue by month</h2>
{% include "brickwork/components/_chart_card.html" with empty=True empty_heading="No chart yet" empty_body="Connect a charting library to plot net revenue by month, then pass its mount markup as mount=." empty_action_href="/docs/" empty_action_label="Read the chart contract" title="Net revenue by month" %}
</section>
{% comment %}
BAND 3, where the answer came from. Two ranked lists side by side IS the
natural arrangement here, and .bw-band-grid--2 is the shipped route to it
(ADR-090 decision 1): the app family's own N-column primitive, content-driven
rather than breakpoint-driven, so the pair reflows to one column on its own
once a list no longer fits at 24rem rather than waiting for a viewport
breakpoint. Do not reach for bw-feature-grid--2 or any other class
marketing.css defines instead: it works mechanically in the same compiled
bundle, but it is marketing-family, styled for marketing rhythm, and
tests/test_family_boundary.py fails a build that uses it on an app-family
shell (ADR-090 decision 2).
The band gets a real, visible heading via .bw-band-heading rather than the
hidden-<h2>-plus-aria-labelledby pattern the other bands use: unlike the
scorecard or the chart card, no single component here carries a title of its
own for "Breakdowns" to duplicate, so hiding the heading would leave the band
genuinely unlabelled to a sighted reader. .bw-band-heading is sized at the
heading-lg role with no inline-size rule of its own, so it reads at a proper
heading size without narrowing the band the way wrapping it in bw-prose
would (bw-prose's first rule is a 65ch content measure, not a heading
utility). The id/aria-labelledby wiring is unchanged: a visible heading still
needs both, exactly as a hidden one does.
Each list states its own figures as text beside the bar; the bars are
aria-hidden decoration, so the ranking is readable without seeing them.
{% endcomment %}
<section aria-labelledby="breakdown-heading">
<h2 id="breakdown-heading" class="bw-band-heading">Breakdowns</h2>
<div class="bw-band-grid--2">
{% bw_ranked_list channel_rows basis="total" label="Revenue by channel" empty_heading="No channel data" empty_body="Revenue attributed to a channel will appear here once the quarter opens." %}
{% bw_ranked_list region_rows basis="total" label="Revenue by region" empty_heading="No regional data" empty_body="Revenue attributed to a region will appear here once the quarter opens." %}
</div>
</section>
{% comment %}
BAND 4, are we on track. The gauge closes the argument the headline opened:
the first band said what happened, this one says what it means against a
target. Threshold bands tint the arc, but the percentage always renders as
visible text underneath, so the reading never depends on seeing the colour.
The heading is visible via .bw-band-heading, matching bands 1 and 3: the
gauge's own label= only sets the SVG's aria-label, so nothing on this band
carries a visible caption for a reader who is not using a screen reader.
{% endcomment %}
<section aria-labelledby="target-heading">
<h2 id="target-heading" class="bw-band-heading">Progress against target</h2>
{% bw_gauge value=78 min=0 max=100 label="Progress to annual revenue target" size="lg" threshold_bands=target_bands %}
</section>
</div>
{% endblock %}
Details
| Kind | Page example |
|---|---|
| Used in | Data-heavy operations |
Composed from
| Template | Description |
|---|---|
brickwork/components/_button.html |
A button or link styled as a button, in several variants. |
brickwork/components/_chart_card.html |
The card chrome around a consumer's own charting engine: frame, legend, and loading, error and empty states. |
brickwork/components/_gauge.html |
A circular progress ring reading one quantity against a fixed range. |
brickwork/components/_page_header.html |
A page's title, description, and action row. |
brickwork/components/_ranked_list.html |
An ordered top-N breakdown: label and value rows, each with a proportional bar. |
brickwork/components/_scorecard.html |
A responsive grid arranging several pre-rendered stat or chart cards. |
brickwork/nav/_nav.html |
Not a catalogue component |
brickwork/shell/app.html |
The authenticated app shell: sidebar, topbar, and content region. |