Dashboard blocks
Overview layouts that pair live charts, stats, and activity into one glanceable screen.
Activity feed
Today's revenue numbers beside the event log that produced them — a timeline dashboard with no JavaScript
-
components
-
activity_feed
-
arden
-
-
templates
-
pages
-
<c-lb.drawer drawerId="activity-sidebar" class="min-h-screen lg:drawer-open" sideClass="z-50 lg:z-40 is-drawer-close:overflow-visible lg:overflow-visible">
<c-slot name="sideContent"><c-arden.sidebar active="activity" drawerId="activity-sidebar" /></c-slot>
<main class="relative min-h-screen bg-base-200/35">
<c-lb.drawer.toggle for="activity-sidebar" class="btn btn-square btn-xs absolute left-3 top-3 z-40 border border-base-300 bg-base-100 shadow-sm lg:hidden" aria-label="Toggle sidebar">
<c-lbi n="rmx.menu" w="14" h="14" />
</c-lb.drawer.toggle>
<div class="container mx-auto max-w-6xl px-4 pb-8 pt-14 sm:px-6 sm:pt-16 lg:px-10 lg:py-8 flex flex-col gap-6">
<c-activity-feed.header :today=today />
<c-activity-feed.stats :stats=stats />
<section class="grid gap-6 lg:grid-cols-3 items-start">
<div id="activity-log" class="lg:col-span-2 flex flex-col gap-6">
{% for group in groups %}
<c-activity-feed.day :day=group.day :events=group.events />
{% endfor %}
</div>
<div class="flex flex-col gap-6">
<c-activity-feed.team :team=team />
<c-activity-feed.digest />
</div>
</section>
</div>
</main>
</c-lb.drawer>
<c-vars day="" :events="[]" />
<section class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<h2 class="text-xs font-semibold uppercase tracking-wide text-base-content/50">{{ day }}</h2>
<span class="h-px flex-1 bg-base-300"></span>
<span class="text-xs tabular-nums text-base-content/40">{{ events|length }} events</span>
</div>
<c-lb.timeline direction="vertical" compact snap>
{% for event in events %}
<c-lb.timeline.item
variant="{{ event.tone }}"
icon="{{ event.icon }}"
{% if forloop.first %}first{% endif %}
{% if forloop.last %}last{% endif %}>
<c-slot name="endSlot">
<c-activity-feed.event :event=event />
</c-slot>
</c-lb.timeline.item>
{% endfor %}
</c-lb.timeline>
</section>
<c-lb.card border id="digest-card" class="bg-base-200/60">
<c-lb.card.body class="gap-4 p-5">
<c-lb.badge variant="primary" style="soft" size="sm" icon="rmx.sparkling">Morning digest</c-lb.badge>
<h2 class="text-lg font-semibold tracking-tight text-balance">
The same feed, in your inbox at 07:00.
</h2>
<p class="text-sm text-base-content/70">
Arden replays the night's events into one email: what renewed, what churned, and the
accounts your team should call before lunch. Nothing else.
</p>
<c-lb.button variant="primary" modifier="block" icon.end="rmx.arrow-right">
Turn on the digest
</c-lb.button>
<p class="text-xs text-base-content/50">
Beacon Labs sends it to four people. You can mute it for a day without losing the log.
</p>
</c-lb.card.body>
</c-lb.card>
<c-vars :event="{}" />
<c-lb.timeline.end box class="mb-3 w-full border border-base-300 bg-base-100 p-4">
<div id="event-{{ event.id }}" class="flex flex-col gap-1.5 text-left">
<div class="flex items-baseline justify-between gap-4">
<p class="text-sm font-medium">{{ event.title }}</p>
{% if event.amount %}
<span class="shrink-0 whitespace-nowrap text-sm font-semibold tabular-nums {% if event.tone == 'error' %}text-error{% elif event.tone == 'warning' %}text-warning{% elif event.tone == 'success' %}text-success{% else %}text-base-content{% endif %}">
{{ event.amount }}
</span>
{% endif %}
</div>
<p class="text-sm text-base-content/60">{{ event.detail }}</p>
<p class="text-xs text-base-content/40 tabular-nums">{{ event.time }} · {{ event.actor }}</p>
</div>
</c-lb.timeline.end>
<c-vars today="" />
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-base-300 pb-5">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<c-arden.mark size="xs" />
<span class="text-sm font-medium text-base-content/50">Arden · Beacon Labs</span>
</div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">What changed</h1>
<p class="text-sm text-base-content/60">
Every upgrade, payment and risk flag, newest first. {{ today }}.
</p>
</div>
<div class="flex items-center gap-2">
<c-lb.badge style="soft" size="sm" icon="rmx.time">Last 3 days</c-lb.badge>
<c-lb.button size="sm" btnStyle="outline" icon="rmx.mail">Email me this</c-lb.button>
</div>
</header>
<c-vars :stats="[]" />
<c-lb.stat.group id="activity-stats" class="w-full border border-base-300 bg-base-100">
{% for stat in stats %}
<c-lb.stat>
<c-lb.stat.figure variant="{{ stat.tone }}" icon="{{ stat.icon }}" />
<c-lb.stat.title>{{ stat.label }}</c-lb.stat.title>
<c-lb.stat.value id="stat-{{ stat.id }}" class="tabular-nums">{{ stat.value }}</c-lb.stat.value>
<c-lb.stat.desc class="tabular-nums">{{ stat.caption }}</c-lb.stat.desc>
</c-lb.stat>
{% endfor %}
</c-lb.stat.group>
<c-vars :team="[]" />
<c-lb.card border class="bg-base-100">
<c-lb.card.body class="gap-4 p-5">
<div>
<h2 class="font-semibold tracking-tight">Who is on the book</h2>
<p class="text-xs text-base-content/50 mt-0.5">
Three people, one ledger. Every event above carries the name of whoever caused it.
</p>
</div>
<ul id="team-list" class="flex flex-col divide-y divide-base-200">
{% for member in team %}
<li class="flex items-center gap-3 py-2.5">
<c-lb.avatar
placeholder
size="sm"
rounded="full"
bgColor="{{ member.tone }}"
initials="{{ member.initials }}" />
<div class="min-w-0 flex-1 leading-tight">
<p class="truncate text-sm font-medium">{{ member.name }}</p>
<p class="text-xs text-base-content/50">{{ member.role }}</p>
</div>
<c-lb.badge style="soft" size="xs">{{ member.note }}</c-lb.badge>
</li>
{% endfor %}
</ul>
</c-lb.card.body>
</c-lb.card>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
The stats say what moved; the log says why. Pairing them is the whole idea of the block — a KPI with no audit trail is a number nobody trusts.
-
One `c-lb.timeline` per day, one `c-lb.timeline.item` per event. The rail, the connectors and the icon slot come for free; the block only supplies the time on the left and the event card on the right.
-
An upgrade is `success`, a downgrade is `error`, a risk flag is `warning`. The event carries a semantic tone and the template maps it to a theme token, so the feed reads correctly in every daisyUI theme without a single fixed colour.
Compact KPI grid
A dense KPI board with inline sparklines — eight metrics, a segment table, and not one byte of JavaScript
-
components
-
arden
-
-
templates
-
pages
-
<main class="container mx-auto max-w-6xl px-4 py-10 flex flex-col gap-6">
<c-compact-kpi.header :period=period :synced=synced />
<section id="kpi-grid" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{% for kpi in kpis %}
<c-compact-kpi.tile :kpi=kpi />
{% endfor %}
</section>
<section class="grid gap-6 lg:grid-cols-3 items-start">
<c-compact-kpi.segments :segments=segments class="lg:col-span-2" />
<c-compact-kpi.accounts :accounts=accounts />
</section>
<c-compact-kpi.footnote :synced=synced />
</main>
<c-vars :accounts="[]" />
<c-lb.card border class="bg-base-100">
<c-lb.card.body class="gap-4 p-5">
<div>
<h2 class="font-semibold tracking-tight">Largest accounts</h2>
<p class="text-xs text-base-content/50 mt-0.5">Top five by MRR, with today's health.</p>
</div>
<ul id="top-accounts" class="flex flex-col divide-y divide-base-200">
{% for account in accounts %}
<li class="flex items-center gap-3 py-2.5">
<c-lb.avatar
placeholder
size="xs"
rounded="lg"
bgColor="{{ account.tone }}"
initials="{{ account.name|slice:':2'|upper }}" />
<div class="min-w-0 flex-1 leading-tight">
<p class="truncate text-sm font-medium">{{ account.name }}</p>
<p class="text-xs text-base-content/50">{{ account.plan }} · {{ account.health }}</p>
</div>
<span class="text-sm font-medium tabular-nums">{{ account.mrr }}</span>
</li>
{% endfor %}
</ul>
</c-lb.card.body>
</c-lb.card>
<c-vars synced="" />
<footer id="kpi-footnote" class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-base-300 bg-base-200/40 px-4 py-3">
<p class="text-xs text-base-content/60 max-w-2xl">
Figures reconcile against Stripe and Chargebee at {{ synced }}. Anything booked after the
close lands in tomorrow's numbers, so the board and the ledger never disagree.
</p>
<div class="flex items-center gap-2">
<c-lb.badge style="soft" size="xs" icon="rmx.time">Trailing 12 months on every spark</c-lb.badge>
<c-lb.badge style="soft" size="xs" icon="rmx.flashlight">No JavaScript on this page</c-lb.badge>
</div>
</footer>
<c-vars period="" synced="" />
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-base-300 pb-5">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<c-arden.mark size="xs" />
<span class="text-sm font-medium text-base-content/50">Arden · Revenue</span>
</div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Close of {{ period }}</h1>
<p class="text-sm text-base-content/60">
Eight numbers the revenue meeting always asks for, reconciled overnight.
</p>
</div>
<div class="flex items-center gap-2">
<c-lb.badge variant="success" style="soft" size="sm" icon="rmx.refresh">
Synced {{ synced }}
</c-lb.badge>
<c-lb.button size="sm" btnStyle="outline" icon="rmx.file-download">Export</c-lb.button>
</div>
</header>
<c-vars :segments="[]" class="" />
<c-lb.card border class="bg-base-100 {{ class }}">
<c-lb.card.body class="gap-4 p-5">
<div>
<h2 class="font-semibold tracking-tight">Where the money moved</h2>
<p class="text-xs text-base-content/50 mt-0.5">
Net new is expansion less contraction and churn, by segment.
</p>
</div>
<c-lb.table size="sm" id="segment-table">
<thead>
<tr>
<th class="text-left">Segment</th>
<th class="text-right">Accounts</th>
<th class="text-right">MRR</th>
<th class="text-right">Net new</th>
<th class="text-right">Churn</th>
</tr>
</thead>
<tbody>
{% for segment in segments %}
<tr id="segment-{{ segment.name|slugify }}">
<td class="font-medium">{{ segment.name }}</td>
<td class="text-right tabular-nums">{{ segment.accounts }}</td>
<td class="text-right tabular-nums">{{ segment.mrr }}</td>
<td class="text-right tabular-nums {% if segment.tone == 'error' %}text-error{% else %}text-success{% endif %}">
{{ segment.net_new }}
</td>
<td class="text-right">
<c-lb.badge variant="{{ segment.tone }}" style="soft" size="xs" class="tabular-nums">
{{ segment.churn }}
</c-lb.badge>
</td>
</tr>
{% endfor %}
</tbody>
</c-lb.table>
</c-lb.card.body>
</c-lb.card>
<c-vars points="" tone="primary" />
<svg
viewBox="0 0 100 32"
preserveAspectRatio="none"
role="presentation"
aria-hidden="true"
class="h-8 w-full {% if tone == 'error' %}text-error{% elif tone == 'success' %}text-success{% else %}text-primary{% endif %}">
<polygon points="{{ points }} 100,32 0,32" fill="currentColor" class="opacity-10" />
<polyline
points="{{ points }}"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke" />
</svg>
<c-vars :kpi="{}" />
<article id="kpi-{{ kpi.id }}" class="rounded-lg border border-base-300 bg-base-100 p-4 flex flex-col gap-3">
<div class="flex items-start justify-between gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-base-content/50">{{ kpi.label }}</span>
{% if kpi.tone == "error" %}
<c-lb.badge variant="error" style="soft" size="xs" icon="rmx.arrow-down" class="tabular-nums">{{ kpi.delta }}</c-lb.badge>
{% elif kpi.tone == "success" %}
<c-lb.badge variant="success" style="soft" size="xs" icon="rmx.arrow-up" class="tabular-nums">{{ kpi.delta }}</c-lb.badge>
{% else %}
<c-lb.badge variant="primary" style="soft" size="xs" class="tabular-nums">{{ kpi.delta }}</c-lb.badge>
{% endif %}
</div>
<p class="text-2xl font-semibold tracking-tight tabular-nums leading-none">{{ kpi.value }}</p>
<c-compact-kpi.spark :points=kpi.points :tone=kpi.tone />
<p class="text-xs text-base-content/50 tabular-nums">{{ kpi.caption }}</p>
</article>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
Eight KPIs, eight sparklines and two tables, and the page downloads nothing. Reactivity in labb is opt-in: no signal, no action, no bundle.
-
The trend line is a polyline drawn from points the view already had. It takes its colour from `currentColor`, so a theme token on the wrapper decides how it renders — and it recolours with the theme like everything else.
-
`tabular-nums` on every figure, in the tiles and in both tables. It is the cheapest thing on this page and the one people feel: columns of money that do not shuffle sideways as the digits change.
First run
The empty dashboard, designed on purpose — a day-one workspace with a setup path instead of a wall of zeroes
-
components
-
arden
-
-
templates
-
pages
-
<main class="container mx-auto max-w-6xl px-4 py-10 flex flex-col gap-6">
<c-first-run.header :workspace=workspace />
<c-first-run.empty />
<section class="grid gap-6 lg:grid-cols-3 items-start">
<c-first-run.setup
class="lg:col-span-2"
:steps=steps
:progress=progress
:steps_done=steps_done
:steps_total=steps_total />
<div class="flex flex-col gap-6">
<c-first-run.upcoming :items=first_looks />
<c-first-run.help />
</div>
</section>
<c-first-run.placeholders :placeholders=placeholders :sources=sources />
</main>
<c-lb.card border id="first-run-empty" class="overflow-hidden bg-base-100">
<div class="grid gap-0 lg:grid-cols-5">
<div class="lg:col-span-3">
<c-lb.card.body class="gap-5 p-8">
<c-lb.badge variant="primary" style="soft" size="sm" icon="rmx.plug">
One connection away
</c-lb.badge>
<h2 class="text-3xl font-semibold tracking-tight text-balance">
Your revenue graph starts with one connection.
</h2>
<p class="text-base-content/70 max-w-lg">
There is nothing here yet, and on day one there shouldn't be. Connect the
system that already bills your customers and Arden backfills 24 months of
invoices, renewals and cancellations overnight. Most workspaces are looking
at their MRR curve inside four minutes.
</p>
<div class="flex flex-wrap items-center gap-3">
<c-lb.button variant="primary" icon="rmx.plug" icon.end="rmx.arrow-right">
Connect billing
</c-lb.button>
<c-lb.button btnStyle="outline" icon="rmx.file-upload">
Import a CSV instead
</c-lb.button>
</div>
<p class="text-xs text-base-content/50">
Not ready to connect production?
<a class="link link-hover font-medium" href="#sample-data">
Load a sample workspace
</a>
and delete it whenever you like — it never touches your billing account.
</p>
</c-lb.card.body>
</div>
<div class="lg:col-span-2 flex items-center justify-center border-t border-base-300 bg-base-200 p-8 lg:border-l lg:border-t-0">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex size-16 items-center justify-center rounded-2xl bg-base-100/70 shadow-sm">
<c-lbi.rmx.line-chart w="32" h="32" class="text-primary" />
</div>
<p class="text-sm font-medium text-base-content/80 max-w-[24ch]">
Every chart on this page fills itself in the moment your first invoice lands.
</p>
</div>
</div>
</div>
</c-lb.card>
<c-vars workspace="" />
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-base-300 pb-5">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<c-arden.mark size="xs" />
<span class="text-sm font-medium text-base-content/50">Arden · {{ workspace }}</span>
</div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Revenue</h1>
<p class="text-sm text-base-content/60">
Nothing has been billed through this workspace yet.
</p>
</div>
<c-lb.badge style="soft" size="sm" icon="rmx.seedling">Day one</c-lb.badge>
</header>
<c-lb.card border id="first-run-help" class="bg-base-200/60">
<c-lb.card.body class="gap-4 p-5">
<div class="flex items-center gap-3">
<c-lb.avatar placeholder size="md" rounded="full" bgColor="secondary" initials="MC" />
<div class="leading-tight">
<p class="text-sm font-medium">Mira Chen</p>
<p class="text-xs text-base-content/50">Onboarding, Arden</p>
</div>
</div>
<p class="text-sm text-base-content/70">
"Bring your billing export and I will map it with you. Most teams are reconciled
against their last close in under half an hour."
</p>
<c-lb.button btnStyle="outline" size="sm" modifier="block" icon="rmx.calendar-check">
Book 20 minutes with Mira
</c-lb.button>
<c-lb.button btnStyle="ghost" size="sm" modifier="block" icon="rmx.book-open">
Read the setup guide
</c-lb.button>
</c-lb.card.body>
</c-lb.card>
<c-vars :placeholders="[]" :sources="[]" />
<section id="sample-data" class="flex flex-col gap-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="font-semibold tracking-tight">The board you are about to have</h2>
<p class="text-xs text-base-content/50 mt-0.5">
These four tiles are the dashboard's home screen. They stay empty until the
first sync, and they will not guess.
</p>
</div>
<c-lb.button size="sm" btnStyle="ghost" icon="rmx.sparkling">Fill them with sample data</c-lb.button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{% for tile in placeholders %}
<article id="placeholder-{{ tile.id }}" class="flex flex-col gap-3 rounded-lg border border-dashed border-base-300 bg-base-200/30 p-4">
<span class="text-xs font-medium uppercase tracking-wide text-base-content/50">{{ tile.label }}</span>
<p class="text-2xl font-semibold tabular-nums leading-none text-base-content/30" aria-hidden="true">—</p>
<div class="h-8 rounded bg-base-300/40" aria-hidden="true"></div>
<p class="text-xs text-base-content/40">{{ tile.caption }}</p>
</article>
{% endfor %}
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-lg border border-base-300 bg-base-200/40 px-4 py-3">
<span class="text-xs font-medium text-base-content/60">Arden reads from</span>
{% for source in sources %}
<span class="flex items-center gap-2 text-sm text-base-content/70">
<c-component is="lbi" n="{{ source.icon }}" w="16" h="16" />
{{ source.name }}
</span>
{% endfor %}
<span class="text-xs text-base-content/40">Read-only keys. Arden never writes to your billing system.</span>
</div>
</section>
<c-vars :steps="[]" progress="0" steps_done="0" steps_total="4" class="" />
<c-lb.card border class="bg-base-100 {{ class }}">
<c-lb.card.body class="gap-4 p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="font-semibold tracking-tight">Set up Arden</h2>
<p class="text-xs text-base-content/50 mt-0.5">
Four steps, and only one of them needs your finance team.
</p>
</div>
<c-lb.badge style="soft" size="sm" class="tabular-nums">
{{ steps_done }} of {{ steps_total }} done
</c-lb.badge>
</div>
<c-lb.progress id="setup-progress" variant="primary" value="{{ progress }}" class="w-full" />
<ul id="setup-steps" class="flex flex-col divide-y divide-base-200">
{% for step in steps %}
<li id="step-{{ step.id }}" class="flex items-start gap-3 py-3">
{% if step.state == "done" %}
<c-lb.avatar placeholder size="sm" rounded="full" bgColor="success" icon="rmx.check" />
{% elif step.state == "next" %}
<c-lb.avatar placeholder size="sm" rounded="full" bgColor="primary" initials="{{ forloop.counter }}" />
{% else %}
<c-lb.avatar placeholder size="sm" rounded="full" bgColor="neutral" initials="{{ forloop.counter }}" />
{% endif %}
<div class="min-w-0 flex-1 leading-tight">
<p class="text-sm font-medium {% if step.state == 'done' %}text-base-content/50 line-through{% endif %}">
{{ step.title }}
</p>
<p class="text-xs text-base-content/50 mt-0.5">{{ step.detail }}</p>
</div>
{% if step.state == "next" %}
<c-lb.button size="sm" variant="primary" icon.end="rmx.arrow-right">{{ step.cta }}</c-lb.button>
{% elif step.state == "todo" %}
<c-lb.button size="sm" btnStyle="ghost">{{ step.cta }}</c-lb.button>
{% else %}
<c-lb.badge variant="success" style="soft" size="sm" icon="rmx.check">Done</c-lb.badge>
{% endif %}
</li>
{% endfor %}
</ul>
</c-lb.card.body>
</c-lb.card>
<c-vars :items="[]" />
<c-lb.card border class="bg-base-100">
<c-lb.card.body class="gap-4 p-5">
<div>
<h2 class="font-semibold tracking-tight">What lands first</h2>
<p class="text-xs text-base-content/50 mt-0.5">
In the order Arden can answer them, once the sync finishes.
</p>
</div>
<ul id="first-looks" class="flex flex-col gap-4">
{% for item in items %}
<li class="flex items-start gap-3">
<span class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<c-component is="lbi" n="{{ item.icon }}" w="16" h="16" />
</span>
<div class="leading-tight">
<p class="text-sm font-medium">{{ item.title }}</p>
<p class="text-xs text-base-content/50 mt-0.5">{{ item.detail }}</p>
</div>
</li>
{% endfor %}
</ul>
</c-lb.card.body>
</c-lb.card>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
Day one is the state every product hits and almost no template ships. This one says what is missing, why that is expected, what happens when you fix it, and how long it takes — then gives you exactly one thing to click.
-
The panel that carries the empty state is a `base-200` fill with a `base-300` hairline. No asset to redistribute, no licence to inherit, and it recolours in every daisyUI theme instead of only looking right in one.
-
The four tiles you will live in are shown dashed and empty, each saying what it is waiting for. Zeroes would be a lie — an empty MRR tile reading $0 looks like a business that lost all its revenue, not one that has not connected billing yet.
Revenue overview
A revenue dashboard whose charts are signals — server-driven series switching, live theming, and a metric streaming in over SSE
import time
from collections import OrderedDict
from decimal import Decimal
from labb.contrib.blocks import lm, render_page
from labb.reactivity import SSEResponse, patch_signal
from labb.signals import Signals, Str
LbCustomer = lm("LbCustomer")
LbEvent = lm("LbEvent")
LbInvoice = lm("LbInvoice")
LbMember = lm("LbMember")
LbWorkspace = lm("LbWorkspace")
INDEX = "lb/dashboard/overview/pages/index.html"
MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
SERIES = OrderedDict(
[
("billed", "Billed revenue"),
("movement", "MRR movement"),
]
)
STREAM_TICKS = 10
TICK_SECONDS = 0.25
class OverviewSignals(Signals):
series = Str(path="series", default="billed")
def _money(value):
return f"${Decimal(value or 0):,.0f}"
def _month_label(date):
return f"{MONTHS[date.month - 1]} {date.year}"
def _month_axis(invoices):
"""Ordered month labels the whole page charts against."""
seen = OrderedDict()
for invoice in invoices:
seen.setdefault((invoice.issued_on.year, invoice.issued_on.month), None)
return [f"{MONTHS[m - 1]} {y}" for (y, m) in sorted(seen)]
def _billed_series(invoices, labels):
totals = {label: Decimal(0) for label in labels}
for invoice in invoices:
if invoice.status == "paid":
totals[_month_label(invoice.issued_on)] += invoice.amount
return {
"data": {
"labels": [label.split(" ")[0] for label in labels],
"datasets": [
{
"label": "Collected",
"data": [float(totals[label]) for label in labels],
"backgroundColor": "primary",
}
],
}
}
def _movement_series(events, labels):
won = {label: Decimal(0) for label in labels}
lost = {label: Decimal(0) for label in labels}
for event in events:
label = _month_label(event.occurred_at)
if label not in won:
continue
if event.mrr_delta > 0:
won[label] += event.mrr_delta
else:
lost[label] += -event.mrr_delta
return {
"data": {
"labels": [label.split(" ")[0] for label in labels],
"datasets": [
{
"label": "New and expansion",
"data": [float(won[label]) for label in labels],
"backgroundColor": "success",
},
{
"label": "Churned and contracted",
"data": [float(-lost[label]) for label in labels],
"backgroundColor": "error",
},
],
}
}
def _cohort_series(customers):
cohorts = OrderedDict()
for customer in sorted(customers, key=lambda c: c.signed_up_on):
year = customer.signed_up_on.year
total, kept = cohorts.get(year, (0, 0))
cohorts[year] = (total + 1, kept + (0 if customer.status == "churned" else 1))
return {
"data": {
"labels": [str(year) for year in cohorts],
"datasets": [
{
"label": "Still paying (%)",
"data": [
round(kept / total * 100, 1) for total, kept in cohorts.values()
],
"backgroundColor": "accent",
}
],
}
}
def _plan_series(customers):
mix = OrderedDict()
for customer in customers:
if customer.status == "churned" or customer.plan is None:
continue
mix[customer.plan.name] = mix.get(customer.plan.name, Decimal(0)) + customer.mrr
return {
"data": {
"labels": list(mix),
"datasets": [{"label": "MRR", "data": [float(v) for v in mix.values()]}],
}
}
def _context(request):
signals = OverviewSignals(request)
series = signals.series if signals.series in SERIES else "billed"
workspace = LbWorkspace.objects.select_related("plan").first()
customers = list(LbCustomer.objects.select_related("plan").all())
invoices = list(LbInvoice.objects.all())
events = list(LbEvent.objects.select_related("customer").all())
labels = _month_axis(invoices)
active = [c for c in customers if c.status == "active"]
churned = [c for c in customers if c.status == "churned"]
mrr = sum((c.mrr for c in active), Decimal(0))
latest = labels[-1] if labels else ""
net_new = sum(
(e.mrr_delta for e in events if _month_label(e.occurred_at) == latest),
Decimal(0),
)
at_risk = sorted(
(
c
for c in customers
if c.health in ("at_risk", "watch") and c.status != "churned"
),
key=lambda c: (0 if c.health == "at_risk" else 1, -c.mrr),
)[:5]
at_risk_mrr = sum((c.mrr for c in at_risk), Decimal(0))
for customer in at_risk:
customer.mrr_display = _money(customer.mrr)
movements = [e for e in events if e.mrr_delta != 0][:6]
for event in movements:
event.delta_display = _money(abs(event.mrr_delta))
return {
"signals": signals,
"series": series,
"series_choices": SERIES,
"workspace": workspace,
"seats": LbMember.objects.count(),
"period": latest,
"mrr": _money(mrr),
"net_new": _money(abs(net_new)),
"net_new_up": net_new >= 0,
"arr": _money(mrr * 12),
"active_count": len(active),
"customer_count": len(customers),
"churn_rate": round(len(churned) / len(customers) * 100, 1) if customers else 0,
"churned_count": len(churned),
"at_risk": at_risk,
"at_risk_mrr": _money(at_risk_mrr),
"movements": movements,
"revenue_chart": (
_billed_series(invoices, labels)
if series == "billed"
else _movement_series(events, labels)
),
"cohort_chart": _cohort_series(customers),
"plan_chart": _plan_series(customers),
}
def index(request):
return render_page(request, INDEX, _context(request), title="Revenue overview")
def cash_stream(request):
"""Settle today's open invoices one by one, live, onto the collected-today card.
Real rows, not a random walk: each tick banks the next open invoice, so the
number the card lands on is the number the table would show.
"""
invoices = list(
LbInvoice.objects.filter(status="open").order_by("due_on")[:STREAM_TICKS]
)
def generate():
collected = Decimal(0)
for count, invoice in enumerate(invoices, start=1):
collected += invoice.amount
yield patch_signal(
{"collected": {"total": _money(collected), "count": count}}
)
time.sleep(TICK_SECONDS)
return SSEResponse(generate())
from django.urls import path
from . import views
app_name = "block_dashboard_overview"
urlpatterns = [
path("", views.index, name="index"),
path("cash-stream/", views.cash_stream, name="cash_stream"),
]
<c-lbr.signals
id="overview-charts"
$series="{{ series }}"
:$revenueChart=revenue_chart
:$cohortChart=cohort_chart
:$planChart=plan_chart />
<c-lbr.signals id="overview-live" ifmissing $collected.total="$0" $collected.count="0" />
<c-lb.chart color="base-content" legend />
<c-lbr.get to="block_dashboard_overview:cash_stream" on="init" />
<c-lb.drawer
drawerId="overview-sidebar"
class="min-h-screen lg:drawer-open"
sideClass="z-50 lg:z-40 is-drawer-close:overflow-visible lg:overflow-visible">
<c-slot name="sideContent">
<c-arden.sidebar drawerId="overview-sidebar" />
</c-slot>
<main class="relative min-h-screen bg-base-200/35">
<c-lb.drawer.toggle
for="overview-sidebar"
class="btn btn-square btn-xs absolute left-3 top-3 z-40 border border-base-300 bg-base-100 shadow-sm lg:hidden"
aria-label="Toggle sidebar">
<c-lbi n="rmx.menu" w="14" h="14" />
</c-lb.drawer.toggle>
<div class="container mx-auto max-w-6xl px-4 pb-8 pt-14 sm:px-6 sm:pt-16 lg:px-10 lg:py-8 flex flex-col gap-6">
<c-overview.header :workspace=workspace :period=period />
<c-overview.kpis
:mrr=mrr
:arr=arr
:net_new=net_new
:net_new_up=net_new_up
:active_count=active_count
:customer_count=customer_count
:churn_rate=churn_rate
:churned_count=churned_count />
<section class="grid gap-6 lg:grid-cols-3">
<c-overview.panel
class="lg:col-span-2"
title="Revenue"
note="Cash collected against invoices issued, by month.">
<c-slot name="actions">
<c-overview.switcher :choices=series_choices :active=series />
</c-slot>
<c-lb.chart.bar id="revenue-chart" data="$revenueChart" height="260" legend="bottom" />
</c-overview.panel>
<c-overview.panel title="Plan mix" note="Where the {{ mrr }} of MRR sits today.">
<c-lb.chart.doughnut id="plan-chart" data="$planChart" height="260" legend="bottom" />
</c-overview.panel>
</section>
<section class="grid gap-6 lg:grid-cols-3">
<c-overview.panel
title="Cohort retention"
note="Share of each signup year still paying."
class="lg:col-span-1">
<c-lb.chart.bar id="cohort-chart" data="$cohortChart" height="220" legend="none" />
</c-overview.panel>
<c-overview.panel
class="lg:col-span-2"
title="Accounts to watch"
note="{{ at_risk_mrr }} of MRR sitting on accounts flagged at risk or past due.">
<c-overview.watchlist :accounts=at_risk />
</c-overview.panel>
</section>
<c-overview.panel title="Movements" note="Every change to the book, newest first.">
<c-overview.movements :events=movements />
</c-overview.panel>
</div>
</main>
</c-lb.drawer>
<c-vars :workspace="None" period="" />
<header class="flex flex-wrap items-end justify-between gap-4">
<div class="flex items-center gap-3">
<c-arden.mark size="sm" />
<div>
<h1 class="text-2xl font-semibold tracking-tight">Revenue overview</h1>
<p class="text-sm text-base-content/60">
{{ workspace.name }} · {{ workspace.plan.name }} · {{ period }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<c-lb.badge style="soft" size="sm" icon="rmx.time">Synced 2 min ago</c-lb.badge>
<c-lb.button
id="theme-toggle"
btnStyle="ghost"
size="sm"
icon="rmx.contrast"
data-on:click="document.documentElement.dataset.theme = (document.documentElement.dataset.theme === 'labb-dark' ? 'labb-light' : 'labb-dark')">
Theme
</c-lb.button>
</div>
</header>
<c-vars
mrr="" arr="" net_new="" :net_new_up="True"
:active_count="0" :customer_count="0"
:churn_rate="0" :churned_count="0" />
<c-lb.stat.group class="w-full border border-base-300 bg-base-100">
<c-lb.stat>
<c-lb.stat.figure variant="primary" icon="rmx.line-chart" />
<c-lb.stat.title>MRR</c-lb.stat.title>
<c-lb.stat.value class="tabular-nums">{{ mrr }}</c-lb.stat.value>
<c-lb.stat.desc class="flex items-center gap-1">
{% if net_new_up %}
<c-lb.badge variant="success" style="soft" size="xs" icon="rmx.arrow-up">{{ net_new }}</c-lb.badge>
{% else %}
<c-lb.badge variant="error" style="soft" size="xs" icon="rmx.arrow-down">{{ net_new }}</c-lb.badge>
{% endif %}
net new this month
</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.figure variant="secondary" icon="rmx.building" />
<c-lb.stat.title>Paying accounts</c-lb.stat.title>
<c-lb.stat.value class="tabular-nums">{{ active_count }}</c-lb.stat.value>
<c-lb.stat.desc>of {{ customer_count }} tracked · {{ arr }} ARR</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.figure variant="warning" icon="rmx.alert" />
<c-lb.stat.title>Logo churn</c-lb.stat.title>
<c-lb.stat.value class="tabular-nums">{{ churn_rate }}%</c-lb.stat.value>
<c-lb.stat.desc>{{ churned_count }} accounts lost to date</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.figure variant="success" icon="rmx.pulse" />
<c-lb.stat.title>Collected today</c-lb.stat.title>
<c-overview.metric />
<c-lb.stat.desc class="flex items-center gap-1">
<c-lb.badge variant="success" style="soft" size="xs">live</c-lb.badge>
<span class="tabular-nums" data-text="$collected.count"></span> invoices settled
</c-lb.stat.desc>
</c-lb.stat>
</c-lb.stat.group>
<c-lb.stat.value
id="collected-today"
variant="success"
class="tabular-nums"
data-text="$collected.total" />
<c-vars :events="[]" />
{% if events %}
<ul class="flex flex-col divide-y divide-base-200">
{% for event in events %}
<li id="movement-{{ event.pk }}" class="flex items-center gap-3 py-2.5">
<c-lb.avatar
placeholder
size="xs"
rounded="lg"
bgColor="{% if event.mrr_delta > 0 %}success{% else %}error{% endif %}"
initials="{{ event.customer.company|slice:':2'|upper }}" />
<div class="min-w-0 flex-1 leading-tight">
<p class="truncate text-sm">{{ event.label }}</p>
<p class="text-xs text-base-content/50">
{{ event.get_kind_display }} · {{ event.occurred_at|date:"j M Y" }}
</p>
</div>
{% if event.mrr_delta > 0 %}
<span class="tabular-nums text-sm font-medium text-success">+{{ event.delta_display }}</span>
{% else %}
<span class="tabular-nums text-sm font-medium text-error">−{{ event.delta_display }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="py-8 text-center text-sm text-base-content/50">No movements in this period.</p>
{% endif %}
<c-vars title="" note="" actions="" class="" />
<c-lb.card border class="bg-base-100 {{ class }}">
<c-lb.card.body class="gap-4 p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="font-semibold tracking-tight">{{ title }}</h2>
{% if note %}<p class="text-xs text-base-content/50 mt-0.5">{{ note }}</p>{% endif %}
</div>
{{ actions }}
</div>
{{ slot }}
</c-lb.card.body>
</c-lb.card>
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li>
<a href="#" class="active is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview">
<c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Overview</span>
</a>
</li>
<li>
<a href="#" class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers">
<c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Customers</span>
</a>
</li>
<li>
<a href="#" class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Invoices">
<c-lbi n="rmx.file-list-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Invoices</span>
</a>
</li>
<li>
<a href="#" class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports">
<c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Reports</span>
</a>
</li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
<c-vars :choices="{}" active="" />
<div class="flex items-center gap-1 rounded-field bg-base-200 p-1">
{% for key, label in choices.items %}
<c-lbr.get
to="block_dashboard_overview:index"
tag="span"
before="$series = '{{ key }}'">
<c-lb.button
data-lbb-start
id="series-{{ key }}"
size="xs"
btnStyle="ghost"
behavior="{% if key == active %}active{% endif %}">
{{ label }}
</c-lb.button>
</c-lbr.get>
{% endfor %}
</div>
<c-vars :accounts="[]" />
{% if accounts %}
<div class="overflow-x-auto">
<c-lb.table size="sm">
<thead>
<tr class="text-base-content/50">
<th>Account</th>
<th>Health</th>
<th class="text-right">MRR</th>
<th class="text-right">Renews</th>
</tr>
</thead>
<tbody>
{% for account in accounts %}
<tr id="watch-{{ account.pk }}">
<td>
<div class="flex items-center gap-3">
<c-lb.avatar
placeholder
size="xs"
rounded="lg"
bgColor="{% if account.health == 'at_risk' %}error{% else %}warning{% endif %}"
initials="{{ account.company|slice:':2'|upper }}" />
<div class="leading-tight">
<div class="font-medium">{{ account.company }}</div>
<div class="text-xs text-base-content/50">
{{ account.contact_name }} · {{ account.seats }} seats
</div>
</div>
</div>
</td>
<td>
{% if account.health == 'at_risk' %}
<c-lb.badge variant="error" style="soft" size="sm">At risk</c-lb.badge>
{% else %}
<c-lb.badge variant="warning" style="soft" size="sm">Watch</c-lb.badge>
{% endif %}
{% if account.status == 'past_due' %}
<c-lb.badge style="outline" size="sm">Past due</c-lb.badge>
{% endif %}
</td>
<td class="text-right tabular-nums font-medium">{{ account.mrr_display }}</td>
<td class="text-right tabular-nums text-base-content/60">
{{ account.renews_on|date:"j M Y"|default:"—" }}
</td>
</tr>
{% endfor %}
</tbody>
</c-lb.table>
</div>
{% else %}
<div class="flex flex-col items-center gap-2 py-10 text-center">
<c-lb.avatar placeholder icon="rmx.check" bgColor="success" size="sm" rounded="full" />
<p class="text-sm font-medium">Nothing to chase.</p>
<p class="text-xs text-base-content/50">Every account is paying and healthy.</p>
</div>
{% endif %}
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
Pass a signal to a chart’s data prop to update the existing chart when the value changes. You keep the chart configuration in the component and the data in the view.
-
The control updates the selected range before it sends its GET request. The page view reads that signal, builds the matching series, and returns the next chart data.
-
The live metric arrives through a small SSE view that patches its own signal. Keeping it separate means a range change can refresh the dashboard without resetting the live value.
-
Chart series use semantic theme tokens instead of fixed colours. Changing the page theme gives the charts the same palette as the rest of the interface.
Split charts
A two-column analytics dashboard whose 7/30/90-day switch redraws every chart from one client signal — no server, no chart endpoint
-
components
-
arden
-
split_charts
-
-
templates
-
pages
-
{{ ranges|json_script:"arden-ranges" }}
<script>
function ardenRange(key) {
return JSON.parse(document.getElementById('arden-ranges').textContent)[key];
}
</script>
<c-lbr.signals id="split-charts-view" :$view=view />
<c-lb.chart color="base-content" legend />
<c-lb.drawer drawerId="reports-sidebar" class="min-h-screen lg:drawer-open" sideClass="z-50 lg:z-40 is-drawer-close:overflow-visible lg:overflow-visible">
<c-slot name="sideContent"><c-arden.sidebar active="reports" drawerId="reports-sidebar" /></c-slot>
<main class="relative min-h-screen bg-base-200/35">
<c-lb.drawer.toggle for="reports-sidebar" class="btn btn-square btn-xs absolute left-3 top-3 z-40 border border-base-300 bg-base-100 shadow-sm lg:hidden" aria-label="Toggle sidebar">
<c-lbi n="rmx.menu" w="14" h="14" />
</c-lb.drawer.toggle>
<div class="container mx-auto max-w-6xl px-4 pb-8 pt-14 sm:px-6 sm:pt-16 lg:px-10 lg:py-8 flex flex-col gap-6">
<c-split-charts.header />
<c-split-charts.summary />
<section class="grid gap-6 lg:grid-cols-3 items-start">
<c-split-charts.panel
class="lg:col-span-2"
title="Net MRR"
note="Where the book sat at the end of each period.">
<c-lb.chart.line id="mrr-chart" data="$view.mrr" height="280" legend="none" />
</c-split-charts.panel>
<c-split-charts.panel
title="Gross new MRR by source"
note="New logos, expansion and win-backs.">
<c-lb.chart.doughnut id="source-chart" data="$view.source" height="280" legend="bottom" />
</c-split-charts.panel>
</section>
<section class="grid gap-6 lg:grid-cols-3 items-start">
<c-split-charts.panel
class="lg:col-span-2"
title="Movement"
note="Expansion against churn and contraction, same period.">
<c-lb.chart.bar id="movement-chart" data="$view.movement" height="260" legend="bottom" />
</c-split-charts.panel>
<c-split-charts.panel
title="Accounts to watch"
note="Unchanged by the range switch — risk does not have a window.">
<c-split-charts.watchlist :accounts=watchlist />
</c-split-charts.panel>
</section>
<c-split-charts.footnote />
</div>
</main>
</c-lb.drawer>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
<footer id="split-charts-footnote" class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-base-300 bg-base-200/40 px-4 py-3">
<p class="text-xs text-base-content/60 max-w-2xl">
All three windows ship with the page. Switching the range swaps one signal object, and
the charts redraw in place — there is no analytics endpoint behind this dashboard and
nothing goes over the wire when you click.
</p>
<c-lb.badge style="soft" size="xs" icon="rmx.flashlight">Zero requests on switch</c-lb.badge>
</footer>
<header class="flex flex-wrap items-end justify-between gap-4 border-b border-base-300 pb-5">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<c-arden.mark size="xs" />
<span class="text-sm font-medium text-base-content/50">Arden · Analytics</span>
</div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Revenue movement</h1>
<p id="range-note" class="text-sm text-base-content/60 max-w-xl" data-text="$view.note">
Thirty days to 9 July 2026. The step on 22 June is the Ironvale Group renewal.
</p>
</div>
<c-lb.join id="range-switch" class="p-1 bg-base-200 rounded-lg">
<c-lb.button
data-lbb-start
type="button"
class="join-item"
size="sm"
variant="primary"
btnStyle="$view.d7Style:ghost"
data-on:click="$view = ardenRange('7d')"
>7 days</c-lb.button>
<c-lb.button
type="button"
class="join-item"
size="sm"
variant="primary"
btnStyle="$view.d30Style:"
data-on:click="$view = ardenRange('30d')"
>30 days</c-lb.button>
<c-lb.button
type="button"
class="join-item"
size="sm"
variant="primary"
btnStyle="$view.d90Style:ghost"
data-on:click="$view = ardenRange('90d')"
>90 days</c-lb.button>
</c-lb.join>
</header>
<c-vars title="" note="" class="" />
<c-lb.card border class="bg-base-100 {{ class }}">
<c-lb.card.body class="gap-4 p-5">
<div>
<h2 class="font-semibold tracking-tight">{{ title }}</h2>
<p class="text-xs text-base-content/50 mt-0.5">{{ note }}</p>
</div>
{{ slot }}
</c-lb.card.body>
</c-lb.card>
<c-lb.stat.group id="range-summary" class="w-full border border-base-300 bg-base-100">
<c-lb.stat>
<c-lb.stat.figure variant="primary" icon="rmx.line-chart" />
<c-lb.stat.title>Net new MRR</c-lb.stat.title>
<c-lb.stat.value class="tabular-nums">
<span>$</span><span id="summary-net-new" data-text="$view.netNew">16,740</span>
</c-lb.stat.value>
<c-lb.stat.desc class="flex items-center gap-1">
<c-lb.badge variant="success" style="soft" size="xs" icon="rmx.arrow-up" class="tabular-nums">
<span data-text="$view.netNewDelta">+4.2%</span>
</c-lb.badge>
<span data-text="$view.label">Last 30 days</span>
</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.figure variant="success" icon="rmx.arrow-up" />
<c-lb.stat.title>Expansion</c-lb.stat.title>
<c-lb.stat.value class="tabular-nums">
<span>$</span><span id="summary-expansion" data-text="$view.expansion">38,100</span>
</c-lb.stat.value>
<c-lb.stat.desc>Upgrades and seats added</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.figure variant="error" icon="rmx.arrow-down" />
<c-lb.stat.title>Churn and contraction</c-lb.stat.title>
<c-lb.stat.value class="tabular-nums">
<span>$</span><span id="summary-churn" data-text="$view.churn">21,360</span>
</c-lb.stat.value>
<c-lb.stat.desc>Lost logos and downgrades</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.figure variant="secondary" icon="rmx.building" />
<c-lb.stat.title>New logos</c-lb.stat.title>
<c-lb.stat.value id="summary-logos" class="tabular-nums" data-text="$view.logos">11</c-lb.stat.value>
<c-lb.stat.desc>First invoice paid in period</c-lb.stat.desc>
</c-lb.stat>
</c-lb.stat.group>
<c-vars :accounts="[]" />
<ul id="watchlist" class="flex flex-col divide-y divide-base-200">
{% for account in accounts %}
<li class="flex items-center gap-3 py-2.5">
<c-lb.avatar
placeholder
size="xs"
rounded="lg"
bgColor="{{ account.tone }}"
initials="{{ account.name|slice:':2'|upper }}" />
<div class="min-w-0 flex-1 leading-tight">
<p class="truncate text-sm font-medium">{{ account.name }}</p>
<p class="text-xs text-base-content/50">{{ account.reason }}</p>
</div>
<div class="text-right leading-tight">
<p class="text-sm font-medium tabular-nums">{{ account.mrr }}</p>
<p class="text-xs text-base-content/40">{{ account.plan }}</p>
</div>
</li>
{% endfor %}
</ul>
Learn from this block
-
Every window the dashboard can show ships with the page, and one signal holds the one currently on screen — its labels, its summary numbers and all three of its chart datasets. There is no analytics view behind this block.
-
`data="$view.mrr"` is the whole reactive-chart API. It is a signal *path*, never an expression — that is the rule that shapes this block.
-
Because a reactive prop can only read a path, the display values live *inside* the signal. The click handler replaces the object wholesale, so three charts, four stats and the caption all land on the new range in one assignment.
-
The active range is not a class the JavaScript toggles — it is a prop bound to the signal, with the server-rendered fallback after the colon. The page is correct before Datastar loads, and labb recomputes the daisyUI classes in the browser.
-
No chart on this page names a colour. The datasets are labelled `primary`, `success` and `error`, resolved from CSS custom properties at draw time, so the charts recolour with the theme — which is why a block can never ship a fixed hex.