# Dashboard period

> A dashboard’s period and what it compares with, in one picker

Status: beta. Added in Components 2.16.0.

A dashboard’s period and what it compares with, in one picker.

## Import

```tsx
import { DashboardPeriod } from '@oztix/roadie-components/dashboard-period'
```

## Examples

### Default

One button shows the period and, when there is one, the dates it compares with: “This month 1 to 31 Oct 2026 vs 1 to 30 Sept 2026”. It opens a [DateRangePicker](https://ticketsolutionsptyltd.github.io/roadie/components/date-range-picker.md) that waits for Apply, because each change refetches every card. Its presets look ahead 30 and 90 days, back 30 days and 12 months, and over this financial year. Under the range, the Compare switch turns the comparison on, and Previous period or Previous year picks it, with the dates it covers below. Apply sends the period and its comparison together. The value is `{ range, compare }`, and no comparison leaves `compare` out. `compareOptions` changes the choices: see below.

```tsx
function Default() {
  const [period, setPeriod] = useState({ range: 'this-month', compare: 'previous-period' })
  return (
    <div className='grid gap-4'>
      <DashboardPeriod value={period} onValueChange={setPeriod} />
      <p className='text-sm text-subtle'>Value: {JSON.stringify(period)}</p>
    </div>
  )
}

render(<Default />)
```

### States

`readOnly` shows the period and comparison with a lock, for a dashboard whose period the page sets, such as one show’s on-sale window. `disabled` turns it off.

```tsx
{/* Default */}
<DashboardPeriod defaultValue={{ range: 'last-month', compare: 'previous-period' }} />
{/* Read only */}
<DashboardPeriod readOnly defaultValue={{ range: { start: '2026-11-02', end: '2026-11-29' }, compare: 'previous-year' }} />
{/* Disabled */}
<DashboardPeriod disabled defaultValue={{ range: 'last-month', compare: 'previous-period' }} />
```

### With custom dates

Add `'custom'` to `compareOptions`, and Custom dates opens a range of its own, starting from the previous period’s dates. The comparison is then `{ start, end }`. Leave it out, as by default, and custom dates the app sets still show as Custom dates, without a picker, so switching away and back keeps them.

```tsx
function Custom() {
  const [period, setPeriod] = useState({
    range: { start: '2026-11-02', end: '2026-11-08' },
    compare: { start: '2025-11-03', end: '2025-11-09' }
  })
  return (
    <div className='grid gap-4'>
      <DashboardPeriod
        today='2026-10-07'
        value={period}
        onValueChange={setPeriod}
        compareOptions={['none', 'previous-period', 'previous-year', 'custom']}
      />
      <p className='text-sm text-subtle'>Value: {JSON.stringify(period)}</p>
    </div>
  )
}

render(<Custom />)
```

### With your own comparisons

`compareOptions` lists the choices in order. Roadie’s are `'previous-period'`, `'previous-year'` and `'custom'`. The app’s own are `{ value, label, description? }`, such as similar venues. Choosing one sets `compare` to its `value`, which comes back as it is: Roadie works out no dates for it, shows its `description` where the dates go, and the button reads “vs similar venues”. `'none'` adds the Compare switch. Leave it out, as here, and nothing turns the comparison off once one is chosen. The default is `['none', 'previous-period', 'previous-year']`. An empty list, or `['none']` alone, leaves the comparison out of the picker. A comparison the value holds that the list leaves out still shows among the choices. If you hoist the list, add `as const`, so TypeScript keeps your values.

```tsx
function Benchmark() {
  const [period, setPeriod] = useState({ range: 'last-month', compare: 'similar' })
  return (
    <div className='grid gap-4'>
      <DashboardPeriod
        today='2026-10-07'
        value={period}
        onValueChange={setPeriod}
        compareOptions={[
          'previous-period',
          'previous-year',
          'custom',
          { value: 'similar', label: 'Similar venues', description: 'Venues of a like size in Melbourne' }
        ]}
      />
      <p className='text-sm text-subtle'>Value: {JSON.stringify(period)}</p>
    </div>
  )
}

render(<Benchmark />)
```

### With presets

`presets` replaces the period’s list, as on DateRangePicker. A sales dashboard reports on periods to date, so it takes the picker’s own `dateRangePresets`.

```tsx
<DashboardPeriod
  defaultValue={{ range: { period: 'month', offset: 0, toDate: true }, compare: 'previous-period' }}
  presets={dateRangePresets}
/>
```

### With fixed presets

A preset can be fixed dates, such as a last 30 days that ends yesterday, worked out by the app. When the period is one of them, the button shows its label with its dates, as it does for relative presets, and the phone’s list shows its dates beside it.

```tsx
// Wed 7 Oct 2026. The app's data ends yesterday.
const PRESETS = [
  { value: { direction: 'next', amount: 30, unit: 'day' } },
  { value: { start: '2026-09-07', end: '2026-10-06' }, label: 'Last 30 days' },
  { value: { start: '2025-10-07', end: '2026-10-06' }, label: 'Last 12 months' },
  { value: { start: '2026-07-01', end: '2027-06-30' }, label: 'This financial year' }
]

function Fixed() {
  const [period, setPeriod] = useState({
    range: { start: '2026-09-07', end: '2026-10-06' },
    compare: 'previous-period'
  })
  return (
    <div className='grid gap-4'>
      <DashboardPeriod today='2026-10-07' presets={PRESETS} value={period} onValueChange={setPeriod} />
      <p className='text-sm text-subtle'>Value: {JSON.stringify(period)}</p>
    </div>
  )
}

render(<Fixed />)
```

### With the data’s dates

Pass the same `dataStart`, `dataEnd` and `alignWeekday` you give `resolveComparison`, and the comparison shows the dates the app will fetch, or “Not enough history” or “Nothing to compare” when the data can’t cover them. Here sales data runs to today, so this month compares with the same days of last month, and the previous year lines up weekdays.

```tsx
<DashboardPeriod
  today='2026-10-07'
  dataEnd='2026-10-07'
  alignWeekday
  defaultValue={{ range: 'this-month', compare: 'previous-period' }}
/>
```

### With your own controls

Pass the app’s other controls, such as a channel filter, as `children`, and they sit after the period. On a narrow container they stack.

```tsx
<DashboardPeriod defaultValue={{ range: 'last-month', compare: 'previous-period' }}>
  <Select defaultValue='all'>
    <Select.Trigger size='lg' aria-label='Channel' className='w-full @sm:w-fit gap-2'>
      <Select.Value />
      <Select.Icon />
    </Select.Trigger>
    <Select.Content>
      <Select.Item value='all'>All channels</Select.Item>
      <Select.Item value='online'>Online</Select.Item>
      <Select.Item value='box-office'>Box office</Select.Item>
    </Select.Content>
  </Select>
</DashboardPeriod>
```

### In a dashboard

Give a dashboard description a `period`, and [DashboardView](https://ticketsolutionsptyltd.github.io/roadie/charts/dashboards.md#periods-and-comparisons) shows this toolbar above its sections. Its `onPeriodChange` gets the new period: resolve it with `resolveDateRange` and `resolveComparison`, fetch, and pass back a description with the new `period` and its `history`. A delta marked `comparison: true` follows the comparison. Here sales data starts on 1 March 2026, so month to date has nothing to compare with a year back. Choose the previous period to see the deltas.

```tsx
// Wed 7 Oct 2026 in Melbourne. Sales data runs from 1 March to today.
const TODAY = '2026-10-07'
const SALES_DATA = {
  now: new Date('2026-10-07T02:00:00Z'),
  timeZone: 'Australia/Melbourne',
  dataStart: '2026-03-01',
  dataEnd: TODAY
}

function historyOf({ range, compare }) {
  if (!compare) return undefined
  const { status } = resolveComparison(range, compare, SALES_DATA)
  return status === 'available' ? undefined : status
}

function Sales() {
  const [period, setPeriod] = useState({ range: { period: 'month', offset: 0, toDate: true }, compare: 'previous-year' })
  const history = historyOf(period)
  const spec = {
    version: 1,
    title: 'Sales',
    period: history ? { ...period, history } : period,
    sections: [
      {
        title: 'At a glance',
        cards: [
          { id: 'sold', kind: 'stat', size: 'stat', label: 'Tickets sold', value: 2531, delta: { value: 0.04, format: 'percent', comparison: true } },
          { id: 'gross', kind: 'stat', size: 'stat', label: 'Gross revenue', value: 118400, format: 'compactCurrency', delta: { value: -0.06, format: 'percent', comparison: true } },
          { id: 'orders', kind: 'stat', size: 'stat', label: 'Orders', value: 1288, delta: { value: 96, comparison: true } },
          { id: 'through', kind: 'stat', size: 'stat', label: 'Sell-through', value: 0.77, format: 'percent', delta: { value: 9, format: 'points' }, context: 'Target is 85%' }
        ]
      }
    ]
  }
  return (
    <DashboardView
      spec={spec}
      onPeriodChange={setPeriod}
      periodProps={{
        presets: dateRangePresets,
        today: TODAY,
        timeZone: SALES_DATA.timeZone,
        dataStart: SALES_DATA.dataStart,
        dataEnd: SALES_DATA.dataEnd
      }}
    />
  )
}

render(<Sales />)
```

## Guidelines

- **Previous period follows the calendar.** This month compares with last month, this quarter with last quarter, and month to date with last month to the same day. Rolling ranges and fixed dates compare with the same number of days, ending the day before. Custom comparison dates are used as given.
- **A period to date compares with the same days a period back.** On 7 Oct 2026, this financial year to date, or this financial year with `dataEnd` set to today, compares with 1 Jul to 7 Oct 2025, last financial year to the same day. The same year as fixed dates, `{ start: '2026-07-01', end: '2027-06-30' }` with `dataEnd` set to yesterday, compares with the same number of days just before it instead: 25 Mar to 30 Jun 2026. Pick the form that matches how your data is compared.
- **Give your own comparisons values of their own.** Not `'none'`, `'custom'`, `'previous-period'` or `'previous-year'`: an option with one of those values is left out, with a warning in development. Roadie treats any other `compare` as no comparison of its own: `resolveComparison` refuses it, `DashboardView` hides deltas marked `comparison: true`, and `validateDashboard` warns about them, and about one that reads like Roadie’s, such as `'previous_period'`.
- **Say when there’s nothing to compare.** Pass `dataStart` to `resolveComparison` and to this toolbar. When the comparison starts before the data, the cards say “Not enough history”; when it ends before, “Nothing to compare”. Neither shows a delta.
- **One size, paired with large controls.** The button is a large control: 48px tall, with the comparison's dates on a second line. On the same row, use `size='lg'` Buttons, IconButtons and Selects.
- **Keep the period in the description.** The toolbar shows the description’s `period`, so cards and controls never disagree.

### Compare like with like

**Do**

```tsx
resolveComparison(range, compare, { now, timeZone, dataEnd: today })
```

Pass `dataEnd` for data recorded as it happens, such as sales. A period still in progress then compares with the same days of the period before. Leave it out for dates known ahead, such as shows coming up.

**Don’t**

```tsx
resolveComparison('this-month', 'previous-period', { now, timeZone })
```

Don’t compare a month that’s two days old with a whole month. The delta looks like a fall.

### Pair it with large controls

**Do**

```tsx
<DashboardPeriod defaultValue={{ range: 'last-month', compare: 'previous-year' }}><Button size='lg'>Export</Button></DashboardPeriod>
```

```tsx
<DashboardPeriod value={period} onValueChange={setPeriod}>
  <Button size='lg'>Export</Button>
</DashboardPeriod>
```

Put large Buttons, IconButtons and Selects beside it, so the row shares one height.

**Don’t**

```tsx
<DashboardPeriod defaultValue={{ range: 'last-month', compare: 'previous-year' }}><Button size='sm'>Export</Button></DashboardPeriod>
```

```tsx
<DashboardPeriod value={period} onValueChange={setPeriod}>
  <Button size='sm'>Export</Button>
</DashboardPeriod>
```

Don't set small or medium controls beside it. They sit short of its edge.

### Benchmarks are comparisons

**Do**

```tsx
compareOptions={['previous-period', 'previous-year', { value: 'similar', label: 'Similar venues' }]}
```

Add a benchmark such as similar venues to `compareOptions`, so it sits with the other comparisons and the button names it.

**Don’t**

Don’t put a benchmark in its own toggle beside the period. Two controls then both say what the numbers compare with, and can disagree.

## Accessibility

- The toolbar is a group named “Dashboard period”, or by your `aria-label`.
- The button is named after its period and comparison, such as “Choose dates, This month 1 to 31 Oct 2026 vs 1 to 30 Sept 2026, Period”, which holds the button’s text as shown. It works as [DateRangePicker](https://ticketsolutionsptyltd.github.io/roadie/components/date-range-picker.md) does with `commit='apply'`: Cancel, `Escape` or the drawer’s Close drops changes to both the period and the comparison.
- After the range comes the Compare [Switch](https://ticketsolutionsptyltd.github.io/roadie/components/switch.md), when `compareOptions` has `'none'`, then a [ToggleGroup](https://ticketsolutionsptyltd.github.io/roadie/components/toggle-group.md) named “Compare with”, then the comparison’s dates or your option’s description, which are announced as they change. On a phone the Compare row sits above Apply at the drawer’s foot, so it stays in view on both tabs, and the choices scroll sideways when they don’t fit.
- With Custom dates, a [DateRangePicker](https://ticketsolutionsptyltd.github.io/roadie/components/date-range-picker.md) named “Comparison dates” sits under the choices. It opens over the picker, a drawer over the drawer on a phone, and its dates wait for Apply with the rest.
- Read only, the button shows a lock and doesn’t open.

## API reference

### DashboardPeriod

A dashboard's period, with what it compares with, in one picker.

```tsx
import { DashboardPeriod } from '@oztix/roadie-components/dashboard-period'
```

- `children`: `ReactNode`. The app's own controls, placed after the period.
- `value`: `DashboardPeriodValue<App>`. The period and its comparison. Pair with `onValueChange`.
- `defaultValue`: `DashboardPeriodValue<App>`. The period to start from when uncontrolled. Defaults to the past 30 days, compared with the first of `compareOptions` other than `'custom'`.
- `onValueChange`: `((value: DashboardPeriodValue<App>) => void)`. Called when Apply is pressed with the period and its comparison. Each change refetches the dashboard, so both wait for Apply.
- `presets`: `readonly DateRangePreset[]`. Defaults to `[ { value: { direction: 'next', amount: 30, unit: 'day' } }, { value: { direction: 'next', amount: 90, unit: 'day' } }, { value: { direction: 'past', amount: 30, unit: 'day' } }, { value: { direction: 'past', amount: 12, unit: 'month' } }, { value: { period: 'year', offset: 0, fiscal: true } }
  ]`. The period's presets, as on `DateRangePicker`.
- `compareOptions`: `readonly ("none" | "previous-period" | "previous-year" | "custom" | DashboardPeriodCompareOption<App>)[]`. Defaults to `[
  'none',
  'previous-period',
  'previous-year'
  ] as const`. The Compare choices, in order. `'previous-period'` and `'previous-year'` are Roadie's; `'custom'` picks dates; `{ value, label, description? }` is the app's own, passed through as `compare: value` with no dates, and read on the button as "vs " and the label with its first letter lowercased. `'none'` adds the Compare switch; without it nothing turns the comparison off. A comparison the value holds that the list leaves out still shows. Uncontrolled, the first choice other than `'custom'` starts on. With nothing listed but `'none'`, there is no Compare row. Hoist the list with `as const` so the app's values are kept.
- `dataStart`: `string`. The first day the data holds, as given to `resolveComparison`, so each comparison lists the dates the app will fetch.
- `dataEnd`: `string`. The last day the data holds, as given to `resolveComparison`. Only for data recorded as it happens, such as sales.
- `alignWeekday`: `boolean`. Previous year goes back 52 weeks, as given to `resolveComparison`.
- `readOnly`: `boolean`. Shows the period with a lock, without letting it change.
- `disabled`: `boolean`.
- `emphasis`: `"normal" | "subtle" | "subtler"`. Defaults to `'normal'`.
- `timeZone`: `string`. IANA zone whose calendar decides today. Defaults to the viewer's.
- `today`: `string`. Today as an ISO date. Defaults to today in `timeZone`.
- `weekStart`: `number`. Defaults to `1`.
- `fiscalYearStart`: `number`. Defaults to `7`.
- `locale`: `string`. Defaults to `'en-AU'`.

Docs updated 10 Oct 2026.
