Widgets are the contents of a dashboard section, of a detail page's widgets tab,
and of a drawer's widget tab. Each builder returns a `WidgetConfig`; every
`query` runs on the server with a `WidgetContext`. In a detail tab and in a
drawer the record being viewed is `ctx.row`; a drawer renders its widgets as
static cards (see [Drawers](/docs/reference/drawer#drawertabwidgets)).

```ts excerpt
import { dashboard, metric, statGroup, table } from "@flowpanel/kit";
import { areaChart } from "@flowpanel/kit/charts";

export const overview = dashboard({
  path: "/",
  label: "Overview",
  dateRange: { preset: "last30d" },
  sections: [
    {
      label: "Revenue",
      widgets: [
        metric("MRR", async ({ db, dateRange }) => mrr(db, dateRange), {
          format: "currency",
          span: 3,
          drilldown: "/admin/invoices",
        }),
        areaChart("Revenue over time", async ({ db }) => revenueSeries(db), {
          x: "day",
          y: ["subscriptions", "one-off"],
          stacked: true,
          span: 9,
        }),
      ],
    },
  ],
});
```

## WidgetContext

What every widget `query` receives.

**WidgetContext** — Server-side context passed to every widget `query` function.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `db` | `DB` | yes | Your database client, exactly as handed to the adapter. |
| `session` | `Session \| null` | yes |  |
| `dateRange` | `object` | yes | Range chosen in the dashboard's date picker, already resolved to dates. |
| `req` | `object` | yes |  |
| `row` | `Record<string, unknown>` | no | Row of the record being viewed when the widget sits in a detail tab. |
| `href` | `function` | yes | Mount-aware link builder; never hand-type `"/admin/…"` in a config. |
| `query` | `function` | yes | Memoises `fn` for the current request so two widgets share one query. A
failure is memoised too — the request does not retry under the same key. |
| `labels` | `object` | yes | The admin's resolved chrome strings, so a widget never hard-codes English. |
| `sql` | `function` | yes | One read-only statement, as a tagged template. Every interpolated value is
bound as a parameter, so `${input}` can never change the statement.
`Date` binds as an ISO-8601 string and `bigint` as decimal text. Throws when
the configured adapter implements no `sql` capability.

Runs exactly the statement you write: no tenant scope, no access rule and no
soft-delete filter are applied. Add those predicates yourself, or use
`count` and resource reads, which do. |
| `count` | `function` | yes | How many rows of a registered resource match `where`, through the same role,
access and tenant-scope checks as any other cross-resource read. A resource
the current session may not read counts `0`. |

`href` builds a link under the admin's own `basePath`, so a config never
hand-types `/admin/…`. `href("orders")` is the list, `href("orders", id)` one
record, and the third argument adds the list page's own query keys —
`{ filter: { status: "paid" } }` becomes `?f_status=paid` and `{ tab: "activity" }`
becomes `?tab=activity`.

`query(key, fn)` memoises for the request. Two widgets that need the same figure
pass the same key and the query runs once; the memo is per page render, so the
next request queries again. The memo holds the promise, so a failure is shared
too — the request does not retry under that key.

`labels` is the admin's resolved `LabelsConfig`, which is where a widget's own
wording should come from. `row` is present only when the widget sits in a detail
tab, and holds that record's projected fields.

```ts excerpt
metric("Paid orders", async (ctx) => ({
  value: await ctx.query("paid-orders", () => countPaid(ctx.db, ctx.dateRange)),
  href: ctx.href("orders", undefined, { filter: { status: "paid" } }),
}));
```

## Shared option types

`Span` is `1 | 2 | 3 | 4 | 6 | 8 | 12` — the widget's width in the section's
12-column grid.

`NumericFormat` is `"number" | "currency" | "percent" | "bytes" | "duration"`.

`Tone` is `"default" | "accent" | "ok" | "warn" | "err" | "info" | "muted"`.

Every widget accepts `realtime`, a channel name or list. When one fires, that
widget re-runs its query — the rest of the dashboard stays put.

## metric

A single big-number widget.

```ts
export function metric(label: string, query: (ctx: WidgetContext) => Promise<number | string | MetricResult>, options?: MetricOptions): MetricWidget;
```

**MetricOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `icon` | `string` | no | Rendered as-is next to the label. Any string — an emoji, a ligature. No icon-name lookup exists. |
| `format` | `NumericFormat` | no | How the number is rendered. |
| `sublabel` | `string` | no | Small caption under the value. |
| `delta` | `function` | no | Period-over-period change, queried alongside the value. |
| `sparkline` | `function` | no | Trend line drawn inside the card. |
| `tone` | `Tone` | no | Semantic color of the value. |
| `drilldown` | `string` | no | Turns the whole card into a link to this path. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |

`icon` is handed to the metric card as a React child and rendered verbatim.
Metric icons are deliberately free-form: pass an emoji or a ligature your own
icon font resolves; a Lucide name renders as literal text. This differs from
resource, action and command icons, which use the serializable `IconName`
registry described under [resource icons](/docs/reference/resources#icons).

A `metric()` query may return a bare number or string, or a `MetricResult`
carrying the presentation the value itself implies. A result field wins over the
matching static option, which stays as the fallback: `MetricResult.href` renders
as the card's `drilldown`, and `MetricResult.sublabel` as its caption.

### MetricResult

**MetricResult** — What a `metric()` query may return instead of a bare value.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `number \| string` | yes |  |
| `tone` | `Tone` | no |  |
| `sublabel` | `string` | no |  |
| `delta` | `object` | no |  |
| `href` | `string` | no | Turns the whole card into a link to this path. |

### MetricDelta

**MetricDelta** — Period-over-period change shown under a metric.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `number` | yes | Fractional change — `0.12` renders as +12%. |
| `vs` | `string` | yes | What the comparison is against, e.g. `"prior period"`. |
| `goodWhen` | `"down" \| "up"` | no | Which direction is good news. `"down"` colours a fall green. |

`goodWhen: "down"` colours a fall green — for churn, cost or latency a drop is
the good news.

## table

A list-of-rows widget on a dashboard.

```ts
export function table<R = unknown>(options: TableWidgetOptions<R>): TableWidget;
```

Point it at a registered `resource` to reuse that resource's columns, or supply
your own `query` and `columns`.

`R` is inferred from `query`, so `columns` is checked against the row that query
returns — a misspelled column is a compile error. A column entry is either a bare
key, which is `keyof R & string` once `R` is known and plain `string` while it is
not, or a `WidgetColumn` object naming and formatting a column the query produced
itself. A bare key on the `resource` path keeps that resource's declared header
and format; an object overrides them.

**TableWidgetOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | no | Card heading. |
| `resource` | `string` | no | Registered resource to pull rows and columns from. |
| `query` | `function` | no | Supply rows yourself instead of reading a resource. |
| `columns` | `array` | no | Columns to show. Defaults to the resource's own list columns. A bare key
takes the resource's declared header, or a humanised one; give an object to
name and format a column the query produced itself. |
| `rowKey` | `RowKey<R>` | no | Which field identifies a row. Defaults to the resource's own key, and to
`"id"` for a `query`. A row without one renders but is never acted on. |
| `rowHref` | `function` | no | Turn each row into a link. Return `null` to leave a row inert. |
| `seeAll` | `boolean \| string` | no | Link the card heading to the resource's list. `true` needs a `resource`;
a string is used as the href verbatim. |
| `limit` | `number` | no | Row cap. An explicit value caps both paths; the default applies to the
`resource` read, while a `query`'s own rows are left alone unless you set it. |
| `emptyState` | `ReactNode` | no | Rendered in place of the table body when there are zero rows. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |

### WidgetColumn

**WidgetColumn** — A column a widget's own `query` produced, named and formatted by the config.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `field` | `RowKey<R>` | yes |  |
| `label` | `string` | no |  |
| `format` | `ColumnFormat` | no |  |
| `align` | `"right" \| "center" \| "left"` | no |  |
| `width` | `number \| string` | no |  |

`rowKey` names the field that identifies a row. It defaults to the resource's own
key on the `resource` path and to `"id"` for a `query`; a row without a value
there still renders but never acts.

`rowHref` turns rows into links. It runs on the server, once per row, and
returning `null` leaves that row inert. `seeAll: true` links the card heading to
the resource's list through `ctx.href`; a string is used as the href verbatim.

`emptyState` replaces the table body when there are no rows — a string becomes the
empty state's title, any other node is rendered in the card instead of the table.
With none set the admin's `labels.widget.empty` is used. `limit` caps the rows: it
defaults to 10 on the `resource` path and applies to a `query`'s own rows only
when you set it.

```ts excerpt
table({
  resource: "orders",
  label: "Recent orders",
  columns: ["id", { field: "total", label: "Total", format: "money", align: "right" }],
  rowHref: (row, ctx) => ctx.href("orders", row.id),
  seeAll: true,
  limit: 5,
});
```

## statGroup

A row of small stats (count + label) — denser than a grid of `metric()` cards.

```ts
export function statGroup(options: StatGroupOptions): StatGroupWidget;
```

**StatGroupOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | no | Card heading. |
| `stats` | `array` | yes | Rows of the group, in order. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the queries when any of these channels fire. |

### StatItem

**StatItem** — One row of a `statGroup`.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | yes |  |
| `value` | `function \| object \| boolean \| bigint \| number \| string \| null \| undefined` | yes | A literal, or a function resolved per request. The record, in a detail tab, is `ctx.row`. |
| `format` | `NumericFormat` | no |  |
| `tone` | `Tone` | no |  |

`StatValue` is `string | number | boolean | bigint | Date | null | undefined`.
Keeping the literal side explicit preserves full `WidgetContext` autocomplete
inside async `value` resolvers. A `Date` is rendered through the admin's
`formatting` — its `locale` and `timeZone` — like a table cell, so no resolver
needs to format one by hand.

```ts excerpt
statGroup({
  label: "Account",
  span: 6,
  stats: [
    { label: "Plan", value: async (ctx) => planOf(ctx.row) },
    { label: "Joined", value: async (ctx) => joinedAt(ctx.row) },
    { label: "Open invoices", value: async (ctx) => ctx.count("invoices", { status: "open" }) },
  ],
});
```

## Cards

Five small cards cover most of what a founder dashboard shows between its metrics
and its tables. Each takes counts and labels only; the card derives the geometry,
formats values through the admin's `formatting` — locale *and* currency, so one
dashboard never prints two currencies for the same money — and marks a `tone` on
the item it belongs to.

### stat

One number and a label. A `metric()` without the delta and sparkline machinery.

```ts
export function stat(label: string, value: StatWidget["value"], options?: StatOptions): StatWidget;
```

One number with a label, without `metric()`'s delta and sparkline machinery. The
value is a literal or a resolver.

**StatOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `hint` | `string` | no | Small caption under the value. |
| `href` | `string` | no | Turns the whole card into a link to this path. |
| `tone` | `Tone` | no | Semantic color of the value. |
| `format` | `NumericFormat` | no | How a numeric value is rendered. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |

#### StatResult

A resolver may return a `StatResult` instead of a bare value, so a card computed
from data carries its own tone, caption and link — the same arrangement
`MetricResult` gives `metric()`. A result field wins over the matching static
option, which stays as the fallback. A `Date` is still a value, not a result.

**StatResult** — What a `stat()` resolver may return instead of a bare value, so a card computed
from data can carry its own tone, caption and link — `MetricResult` for `stat()`.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `StatValue` | yes |  |
| `tone` | `Tone` | no | Semantic color of the value. |
| `hint` | `string` | no | Small caption under the value. |
| `href` | `string` | no | Turns the whole card into a link to this path. |

```ts excerpt
stat("Open invoices", async (ctx) => {
  const filter = { customerId: ctx.row?.id, status: "open" };
  const value = await ctx.count("invoices", filter);
  return { value, href: ctx.href("invoices", undefined, { filter }), tone: value > 0 ? "warn" : "ok" };
});
```

### kv

A label/value card — the facts about one thing rather than a table of many.

```ts
export function kv(options: KvOptions): KvWidget;
```

The facts about one thing. Each item's `format` accepts a `ColumnFormat` or a
`NumericFormat`, resolved on the server so the card renders a finished string.

**KvOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | no | Card heading. |
| `items` | `array` | yes | Rows of the card, in order. |
| `columns` | `2 \| 1` | no | Rows per grid line. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the queries when any of these channels fire. |

#### KvItem

**KvItem** — One labelled row of a `kv()` card.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | yes |  |
| `value` | `function \| object \| boolean \| bigint \| number \| string \| null \| undefined` | yes | A literal, or a function resolved per request. |
| `format` | `"duration" \| "bytes" \| "percent" \| "currency" \| object \| "money" \| "badge" \| "number"` | no |  |
| `tone` | `Tone` | no |  |
| `href` | `string` | no | Renders the value as a link to this path. |

### bars

A ranked breakdown, sized against the largest value.

```ts
export function bars(options: BarsOptions): BarsWidget;
```

A ranked breakdown. The query returns labels and values; the card sizes every bar
against the largest one.

**BarsOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | no | Card heading. |
| `query` | `function` | yes |  |
| `format` | `NumericFormat` | no | How bar values are rendered. |
| `emptyState` | `string` | no | Shown instead of the bars when the query returns nothing. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |

#### BarRow

**BarRow** — One bar of a `bars()` card. The card sizes it against the largest value.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | yes |  |
| `value` | `number` | yes |  |
| `href` | `string` | no |  |
| `tone` | `Tone` | no |  |

### funnel

Stages with their share of the first step and the drop-off from the one before.

```ts
export function funnel(options: FunnelOptions): FunnelWidget;
```

Stages in order. Give counts only: the card computes each step's share of the
first step and its drop-off from the one before.

**FunnelOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | no | Card heading. |
| `query` | `function` | yes |  |
| `format` | `NumericFormat` | no | How step values are rendered. |
| `emptyState` | `string` | no | Shown instead of the steps when the query returns nothing. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |

#### FunnelStep

**FunnelStep** — One stage of a `funnel()`. Give counts only; the card derives the rest.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | yes |  |
| `value` | `number` | yes |  |
| `href` | `string` | no |  |

### list

A short feed of lines, each with optional trailing detail.

```ts
export function list(options: ListOptions): ListWidget;
```

A short feed — latest signups, failing jobs — where a table would be too much.

**ListOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | no | Card heading. |
| `query` | `function` | yes |  |
| `emptyState` | `string` | no | Shown instead of the lines when the query returns nothing. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |

#### ListRow

**ListRow** — One line of a `list()` card.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `text` | `string` | yes |  |
| `meta` | `string` | no | Trailing detail — a timestamp, a count, an owner. |
| `tone` | `Tone` | no |  |
| `href` | `string` | no |  |

```ts excerpt
import { bars, funnel, kv, list, stat } from "@flowpanel/kit";

const widgets = [
  stat("Active trials", async (ctx) => countTrials(ctx.db), { hint: "ends within 7 days" }),
  kv({
    label: "This account",
    items: [
      { label: "Plan", value: "Pro" },
      { label: "Balance", value: async (ctx) => balance(ctx.db), format: "money" },
    ],
  }),
  bars({ label: "Revenue by plan", query: async (ctx) => revenueByPlan(ctx.db), format: "currency" }),
  funnel({ label: "Signup", query: async (ctx) => signupSteps(ctx.db) }),
  list({ label: "Latest signups", query: async (ctx) => latestSignups(ctx.db) }),
];
```

## custom

Drop a fully-custom React component into a dashboard section.

```ts
export function custom<P>(Component: ComponentType<P>, props: P | ((ctx: WidgetContext) => Promise<P>), options?: CustomOptions): CustomWidget;
```

Renders your own component in the grid. `props` may be a value or a function
resolved on the server per request, which is how you feed it query results.

**CustomOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-render when any of these channels fire. |
| `frame` | `boolean` | no | Wrap the component in the standard widget card. |

## Charts

Chart builders live in `@flowpanel/kit/charts`, which is a separate entry point
so the recharts bundle never loads for admins that use no charts.

```ts excerpt
import { areaChart, barChart, lineChart, pieChart } from "@flowpanel/kit/charts";
```

Each takes `(label, query, options)`, where `query` returns an array of rows.
The row type is inferred from `query`, so `x` / `y` (and `category` / `value` on
a pie) must name keys of that row — a typo is a compile error. A `query` typed
as `unknown[]` leaves them as plain strings.

### ChartOptionsBase

Shared by area, bar and line charts.

**ChartOptionsBase** — Charts are defined in
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `x` | `RowKey<R>` | yes | Row key plotted on the x-axis. |
| `y` | `array \| RowKey<R>` | yes | Row key(s) plotted on the y-axis. An array draws one series per key. |
| `height` | `number` | no | Chart height in px. |
| `format` | `NumericFormat` | no | How y-values are rendered in ticks and tooltips. |
| `tooltip` | `"compact" \| "default" \| false` | no | `false` omits the `<Tooltip>` entirely. |
| `drilldown` | `string` | no | Wraps the whole chart card in a link, mirroring MetricCard's whole-card drilldown. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |
| `bucket` | `ChartBucket` | no | X-axis aggregation bucket. |

`ChartBucket` is `"minute" | "hour" | "day" | "week" | "month" | "year" | "auto"`.

### areaChart

```ts
export function areaChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: AreaChartOptions<R>): AreaChartWidget;
```

**AreaChartOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `stacked` | `boolean` | no | Stack multiple series instead of overlaying them. |
| `smooth` | `boolean` | no | Draw curved rather than straight segments. |
| `x` | `RowKey<R>` | yes | Row key plotted on the x-axis. |
| `y` | `array \| RowKey<R>` | yes | Row key(s) plotted on the y-axis. An array draws one series per key. |
| `height` | `number` | no | Chart height in px. |
| `format` | `NumericFormat` | no | How y-values are rendered in ticks and tooltips. |
| `tooltip` | `"compact" \| "default" \| false` | no | `false` omits the `<Tooltip>` entirely. |
| `drilldown` | `string` | no | Wraps the whole chart card in a link, mirroring MetricCard's whole-card drilldown. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |
| `bucket` | `ChartBucket` | no | X-axis aggregation bucket. |

### barChart

```ts
export function barChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: BarChartOptions<R>): BarChartWidget;
```

**BarChartOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `stacked` | `boolean` | no | Stack multiple series instead of grouping them side by side. |
| `horizontal` | `boolean` | no | Lay bars out horizontally. |
| `x` | `RowKey<R>` | yes | Row key plotted on the x-axis. |
| `y` | `array \| RowKey<R>` | yes | Row key(s) plotted on the y-axis. An array draws one series per key. |
| `height` | `number` | no | Chart height in px. |
| `format` | `NumericFormat` | no | How y-values are rendered in ticks and tooltips. |
| `tooltip` | `"compact" \| "default" \| false` | no | `false` omits the `<Tooltip>` entirely. |
| `drilldown` | `string` | no | Wraps the whole chart card in a link, mirroring MetricCard's whole-card drilldown. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |
| `bucket` | `ChartBucket` | no | X-axis aggregation bucket. |

### lineChart

```ts
export function lineChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: LineChartOptions<R>): LineChartWidget;
```

**LineChartOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `smooth` | `boolean` | no | Draw curved rather than straight segments. |
| `markers` | `boolean` | no | Show a dot at every data point. |
| `x` | `RowKey<R>` | yes | Row key plotted on the x-axis. |
| `y` | `array \| RowKey<R>` | yes | Row key(s) plotted on the y-axis. An array draws one series per key. |
| `height` | `number` | no | Chart height in px. |
| `format` | `NumericFormat` | no | How y-values are rendered in ticks and tooltips. |
| `tooltip` | `"compact" \| "default" \| false` | no | `false` omits the `<Tooltip>` entirely. |
| `drilldown` | `string` | no | Wraps the whole chart card in a link, mirroring MetricCard's whole-card drilldown. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |
| `bucket` | `ChartBucket` | no | X-axis aggregation bucket. |

### pieChart

```ts
export function pieChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: PieChartOptions<R>): PieChartWidget;
```

Pie charts take `category` / `value` instead of `x` / `y`. The slice legend is on
unless you pass `showLegend: false`.

**PieChartOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `category` | `RowKey<R>` | yes | Row key naming each slice. |
| `value` | `RowKey<R>` | yes | Row key holding each slice's magnitude. |
| `donut` | `boolean` | no | Cut a hole in the middle. |
| `showLegend` | `boolean` | no | Show the slice legend. |
| `height` | `number` | no | Chart height in px. |
| `span` | `Span` | no | Width in the dashboard's 12-column grid. |
| `drilldown` | `string` | no | Wraps the whole chart card in a link, mirroring MetricCard's whole-card drilldown. |
| `realtime` | `array \| string` | no | Re-run the query when any of these channels fire. |
| `colors` | `Record<string, string>` | no | Pin specific slices to a color, keyed by category value. |
| `format` | `NumericFormat` | no | How slice values are rendered in the tooltip. |
| `tooltip` | `"compact" \| "default" \| false` | no | `false` omits the `<Tooltip>` entirely. |

## Series helpers

Two pure helpers for the shape a dashboard query usually needs. Bucketing itself
stays in SQL, where the dialect belongs.

A range of the same length ending where `range` begins. The shift is elapsed
time, not calendar days, so the comparison window for a week that crosses a DST
boundary starts an hour off the wall clock.

```ts
export function previousRange(range: { from: Date; to: Date; }): { from: Date; to: Date; };
```

The range of the same length ending where the given one begins — the comparison
period a `MetricDelta` is measured against. The shift is elapsed time, not calendar
days: the window before a week that crosses a DST boundary starts an hour off the
wall clock.

Turn sparse grouped rows into one point per day across `range`, so a chart
draws a flat stretch where nothing happened instead of skipping the day.
Bucketing is done in `timeZone`, which defaults to UTC. There is one bucket per
calendar day the range touches, both ends included, so the `last7d` preset —
`now - 7d … now` — yields eight buckets whose first is a partial day.

```ts
export function fillDays<R>(rows: R[], range: { from: Date; to: Date; }, opts: { dateField: keyof R & string; valueField: keyof R & string; timeZone?: string; }): Array<{ date: string; value: number; }>;
```

Turns sparse grouped rows into one point per day across the range, so a chart
draws a flat stretch where nothing happened instead of skipping the day. Days are
bucketed in `timeZone`, which defaults to UTC. There is one bucket per calendar day
the range touches, both ends included, so the `last7d` preset — `now - 7d … now` —
yields eight buckets and the first one is a partial day. Narrow the range, or slice
the result, if a chart should show exactly seven bars.

```ts excerpt
import { fillDays, previousRange } from "@flowpanel/kit";

areaChart("Signups", async ({ db, dateRange }) => {
  const rows = await signupsByDay(db, dateRange);
  return fillDays(rows, dateRange, { dateField: "day", valueField: "total" });
}, { x: "date", y: "value" });

const before = previousRange(dateRange);
```

## Dashboards

Register a dashboard route under `/admin<path>`.

```ts
export function dashboard(config: DashboardConfig): DashboardConfig;
```

Register an arbitrary page under `/admin<path>` that renders a custom React component.

```ts
export function page(config: PageConfig): PageConfig;
```

Both are identity functions — they exist so the object literal is checked
against its type at the call site and reads as a builder alongside `resource()`
and `queue()`.

**DashboardConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | `string` | yes | Path under `basePath`. `"/"` makes this the admin's landing page. |
| `label` | `string` | yes | Nav entry and page title. |
| `icon` | `IconName` | no | Serializable Lucide icon rendered in navigation and the command palette. |
| `dateRange` | `object` | no | Adds a date picker whose value reaches every widget query. |
| `realtime` | `array \| string` | no | SSE channel(s) the whole dashboard subscribes to, independent of any widget. |
| `refresh` | ``${number}m` \| `${number}s`` | no | Re-render the whole dashboard on an interval, e.g. `"60s"` or `"5m"`, and
show how old the numbers are next to the date picker. Paused while the tab
is hidden. |
| `sections` | `array` | yes | Widget bands, rendered top to bottom. |
| `actions` | `array` | no | Optional top-bar action buttons rendered in the dashboard page header. |
| `hideActionsBar` | `boolean` | no | Hide the default `DashboardActionsBar`. |
| `requireRole` | `function \| array \| string` | no | Restrict access to this dashboard. |

`refresh` is `"<n>s"` or `"<n>m"`. It re-renders the whole dashboard on that
interval, pauses while the browser tab is hidden, and shows how old the numbers
are next to the date picker from `labels.widget.updated`.

### SectionConfig

**SectionConfig** — A titled band of widgets on a dashboard.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | no | Section heading. Omit for an untitled band. |
| `description` | `string` | no | Supporting copy under the heading. |
| `columns` | `12 \| 6 \| 4 \| 3 \| 2 \| 1` | no | Grid columns the widgets lay out across. |
| `widgets` | `array` | yes |  |

### DateRangeConfig

**DateRangeConfig** — Date picker offered above a dashboard.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `preset` | `DateRangePreset` | no | Range selected on first load. |
| `default` | `object` | no | Explicit range used when no preset is given. |

`DateRangePreset` is `"today" | "yesterday" | "last7d" | "last30d" | "MTD" | "QTD" | "YTD"`.

### ResolvedDateRange

**ResolvedDateRange** — The active range, as every widget query receives it.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | `object` | yes |  |
| `to` | `object` | yes |  |
| `preset` | `"custom" \| "YTD" \| "QTD" \| "MTD" \| "last30d" \| "last7d" \| "yesterday" \| "today"` | yes | `"custom"` once the operator picks their own dates. |

### PageConfig

**PageConfig** — User page registered under `<basePath><path>` with a sidebar nav entry.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | `string` | yes | Path under `basePath`. |
| `label` | `string` | yes | Nav entry and page title. |
| `icon` | `IconName` | no | Serializable Lucide icon rendered in navigation and the command palette. |
| `component` | `ComponentType<Record<string, never>>` | no | Server or client React component rendered at `<basePath><path>`. |
| `href` | `string` | no | External href used when `component` is not provided. |
| `requireRole` | `function \| array \| string` | no | Restrict access to this page. |
