Skip to main content
Urbicon UI
Back to Recipes

Trace Drawer

A "How was this calculated?" drawer for an aggregated figure. It traces the value through every intermediate formula down to the receipts and meter readings it came from, one indented level per step.

Built with Drawer Card Button Badge

Live preview

StatementPage.svelte

Click the question under the figure and follow the value down, formula by formula, to the receipts it came from.

Heating cost statement unit 4

Billing period 2024

Allocated heating costs €1,855.47
<script lang="ts">
  import { Badge, Button, Card, Drawer } from '@urbicon-ui/blocks';

  interface TraceNode {
    label: string;
    /** Preformatted, unit included: the engine that calculates knows € from kWh. */
    value: string;
    /** How the children combine into this value. Display text; nothing evaluates it. */
    formula?: string;
    /** The document a leaf cites: a receipt, an invoice, a meter reading. */
    reference?: string;
    children?: TraceNode[];
  }

  // Aggregates carry a formula and children; leaves carry a reference.
  const trace: TraceNode = {
    label: 'Heating costs unit 4',
    value: '€1,855.47',
    formula: 'heating cost pool · WMZ_unit4 / WMZ_total',
    children: [
      {
        label: 'Heating cost pool',
        value: '€3,105.03',
        formula: 'pool · (1 − hot-water share)',
        children: [/* … the fuel and maintenance receipts, and the hot-water split … */]
      },
      {
        label: 'WMZ share unit 4',
        value: '59.76%',
        formula: 'heat meter consumption / Σ of all units',
        children: [
          { label: 'WMZ unit 4', value: '12,450 kWh', reference: 'Meter reading 2024-12-31' },
          { label: 'WMZ total', value: '20,838 kWh' }
        ]
      }
    ]
  };

  let drawerOpen = $state(false);
</script>

<!-- One snippet for every level: a node renders its row, formula and source,
     then its children through itself. -->
{#snippet traceNode(node: TraceNode, depth: number)}
  <div
    class={[
      'flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1',
      depth === 0 ? 'text-base font-semibold' : 'text-sm'
    ]}
  >
    <span class={['text-text-primary', depth > 0 && 'font-medium']}>{node.label}</span>
    <span class={['text-text-primary tabular-nums', depth > 0 && 'font-semibold']}>
      {node.value}
    </span>
  </div>
  {#if node.formula}
    <p class="text-text-tertiary mt-1 font-mono text-xs break-words">{node.formula}</p>
  {/if}
  {#if node.reference}
    <Badge variant="soft" intent="neutral" size="xs" class="mt-2">{node.reference}</Badge>
  {/if}
  {#if node.children?.length}
    <ul class="border-border-subtle mt-3 space-y-3 border-l pl-3">
      {#each node.children as child (child.label)}
        <li>{@render traceNode(child, depth + 1)}</li>
      {/each}
    </ul>
  {/if}
{/snippet}

<!-- The statement tile that carries the trigger; put it wherever the figure
     lives in your page. -->
<div class="w-full max-w-md">
  <Card variant="elevated">
    <h3 class="text-text-primary text-base font-semibold">Heating cost statement unit 4</h3>
    <p class="text-text-tertiary mt-1 text-xs">Billing period 2024</p>
    <div class="mt-4 flex items-baseline justify-between gap-4">
      <span class="text-text-secondary text-sm">Allocated heating costs</span>
      <span class="text-text-primary text-2xl font-bold tabular-nums">{trace.value}</span>
    </div>
    <Button
      intent="primary"
      variant="outlined"
      class="mt-4 w-full"
      onclick={() => (drawerOpen = true)}
    >
      How was this calculated?
    </Button>
  </Card>
</div>

<Drawer
  bind:open={drawerOpen}
  title="How was {trace.value} calculated?"
  placement="right"
  size="lg"
>
  {@render traceNode(trace, 0)}

  {#snippet footer()}
    <div class="flex w-full justify-end gap-2">
      <!-- Stand-in: hand the finished trace to your PDF export. -->
      <Button intent="neutral" variant="outlined">Export as PDF</Button>
      <Button intent="primary" onclick={() => (drawerOpen = false)}>Close</Button>
    </div>
  {/snippet}
</Drawer>

Two decisions

Lines, not boxes

The obvious rendering is a box per node, and four levels deep it fails: every level spends a border and its padding on both sides, so the receipts at the bottom sit in a sliver of the drawer. Here the list alone carries the nesting, one border-l guide line and pl-3 of indent per level. And since no level pads the right side, every value keeps the same right edge: the whole trace reads as one column of figures.

Values arrive formatted

value is a string because the trace mixes units: euros, percentages, kilowatt-hours. Only the calculation that produced a step knows its unit and precision, so format where you calculate and hand the drawer a finished trace; it never parses a figure. formula follows the same rule: display text that names what happened, and nothing evaluates it.