Skip to content

Period picker

4 min read · Updated 3 Nov 2026

A period picker sets the time range the page follows: month to date, quarter to date, year to date, the last 12 months or a custom range, optionally with a comparison period. The period is an applied selection on the date field, as Periods and date ranges describes. Build it as a preset control behind a header button, or mount a date-picker object.

Period picker: Preset periods and an explicit range make the time scope visible.

Illustrative example with synthetic data. Preset periods and an explicit range make the time scope visible.

  1. Does the page show a comparison period, or name the period in the header? Use the preset control.
  2. Do readers pick arbitrary dates from a calendar more often than a preset? Use the date picker.
  3. Otherwise, use the preset control.
Control Where Use cases Choose another when
Preset control A header button opening a popover A monthly review against last year; switching between MTD, QTD and YTD Readers pick dates from a calendar more than presets
Date picker A host in the filter bar or filter panel An operations log searched by date range The page needs a comparison, or the period’s name in the header

Use one per page: both select on the same date field, and a second selection on a field replaces the first.

<button class="period-trigger" type="button" popovertarget="period-menu">
<svg aria-hidden="true"></svg><span class="control__label" id="period-label">Last 12 months</span>
</button>
<div class="period-menu" id="period-menu" popover aria-label="Period">
<fieldset>
<legend>Period</legend>
<label><input type="radio" name="period" value="mtd" /> Month to date</label>
<label><input type="radio" name="period" value="qtd" /> Quarter to date</label>
<label><input type="radio" name="period" value="ytd" /> Year to date</label>
<label><input type="radio" name="period" value="l12m" checked /> Last 12 months</label>
<label><input type="radio" name="period" value="custom" /> Custom range</label>
</fieldset>
<fieldset class="period-menu__custom" disabled>
<legend>Custom range</legend>
<label>From <input type="date" name="from" /></label>
<label>To <input type="date" name="to" /></label>
</fieldset>
<fieldset>
<legend>Compare with</legend>
<label><input type="radio" name="compare" value="none" checked /> No comparison</label>
<label><input type="radio" name="compare" value="previous" /> Previous period</label>
<label><input type="radio" name="compare" value="lastYear" /> Same period last year</label>
</fieldset>
</div>

An auto popover closes on Escape and outside clicks. Omit Compare with when the page has no comparison figures. Enable the custom-range fieldset only while Custom range is checked.

The menu reads --surface, --ink and --line; the trigger takes the shared geometry in Header and controls.

.period-menu {
box-sizing: border-box;
width: 280px;
padding: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
}
.period-menu fieldset {
display: grid;
gap: 4px;
margin: 0 0 8px;
padding: 0;
border: 0;
}
.period-menu label {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
}
/* Under the trigger, right edges aligned; without anchor positioning the popover stays centred. */
@supports (position-area: bottom) {
.period-trigger {
anchor-name: --period-trigger;
}
.period-menu {
position-anchor: --period-trigger;
position-area: bottom span-left;
position-try-fallbacks: flip-inline;
inset: auto;
margin: 4px 0 0;
}
}
type Preset = 'mtd' | 'qtd' | 'ytd' | 'l12m';
type Comparison = 'previous' | 'lastYear';
// months: a preset's length, used to step back one period. Absent for a custom range.
interface Period {
from: Date;
to: Date;
months?: number;
}
const iso = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// Keeps the day inside the target month: 31 March minus one month is the last day of February.
function addMonths(date: Date, months: number): Date {
const first = new Date(date.getFullYear(), date.getMonth() + months, 1);
const lastDay = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
return new Date(first.getFullYear(), first.getMonth(), Math.min(date.getDate(), lastDay));
}
const presets: Record<Preset, { label: string; months: number; start: (today: Date) => Date }> = {
mtd: { label: 'Month to date', months: 1, start: (t) => new Date(t.getFullYear(), t.getMonth(), 1) },
qtd: {
label: 'Quarter to date',
months: 3,
start: (t) => new Date(t.getFullYear(), t.getMonth() - (t.getMonth() % 3), 1),
},
ytd: { label: 'Year to date', months: 12, start: (t) => new Date(t.getFullYear(), 0, 1) },
// This month to date and the 11 whole months before it.
l12m: { label: 'Last 12 months', months: 12, start: (t) => new Date(t.getFullYear(), t.getMonth() - 11, 1) },
};
function presetPeriod(preset: Preset, today: Date): Period {
const { months, start } = presets[preset];
return { from: start(today), to: today, months };
}
function comparisonPeriod(period: Period, comparison: Comparison): Period {
if (comparison === 'lastYear') return { from: addMonths(period.from, -12), to: addMonths(period.to, -12) };
if (period.months !== undefined) {
return { from: addMonths(period.from, -period.months), to: addMonths(period.to, -period.months) };
}
// A custom range: the same number of days, ending the day before it starts.
const days = Math.round((period.to.getTime() - period.from.getTime()) / 86_400_000) + 1;
const to = new Date(period.from.getFullYear(), period.from.getMonth(), period.from.getDate() - 1);
return { from: new Date(to.getFullYear(), to.getMonth(), to.getDate() - days + 1), to };
}

For 28 September 2026, quarter to date is 1 July to 28 September; the previous period is 1 April to 28 June; the same period last year is 1 July to 28 September 2025.

  • Select it on the date field in the page’s group, so it waits for the apply bar like any other filter. The value format is in Periods and date ranges: numbers, not the strings iso() returns. Turn each calendar day into its UTC bound, because getTime() on these local dates shifts it by the reader’s time zone:

    const values = [Date.parse(`${iso(period.from)}T00:00:00.000Z`), Date.parse(`${iso(period.to)}T23:59:59.999Z`)];
  • Anchor presets on the latest date in the data when the data stops before today; read it with a MAX query on the date field at boot.

  • Label the trigger from the applied state, not from the menu: the preset’s name while the date field’s applied selection is the one the control wrote, Custom range for any other, and All dates for none.

  • Send dates to measures that declare them, as Parameters describes, on the queries you build. Re-query on applied changes only; see Re-query your own objects only on applied changes.

// Parameter names are the measure's own; read what each measure declares from the metadata.
declare const revenueComparisonKey: string;
declare const period: Period;
const comparison = comparisonPeriod(period, 'lastYear');
const calculations = [
{
itemKey: revenueComparisonKey,
namedParameters: {
formula: {},
filter: {
'[Start date].[date]': iso(period.from),
'[End date].[date]': iso(period.to),
'[Comparison start].[date]': iso(comparison.from),
'[Comparison end].[date]': iso(comparison.to),
},
},
},
];

Leave the date field’s selection out of that query’s filters; otherwise it restricts the rows the comparison reads too. A group holds one selection per field and kind, so the comparison period cannot also be a selection; see App limitations. Show comparison figures from queries you build, for example on a KPI card.

On a mounted object, a measure’s Defined parameter takes a value through setMeasureDefinedParameter with { type: 'static', value }. Check which types publish it in the operations reference, and check the results.

A date-picker object writes its own selection on its date field. Configure it as in Configure the visualization, and import @astrato-io/visualizations/styles.css.

Choose inputRange for a compact control that opens its calendar when clicked, or inputSingle for one date. range always shows the full calendar in the page, whatever pickerState says, so use it only where you have planned room for one.

{
"version": 1,
"type": "date-picker",
"data": { "field": { "fieldKey": "<date field key>", "ref": "orderDate" } },
"settings": {
"pickerType": "inputRange",
"quickSelect": false,
"responsive": false,
"timePickerVisibility": "never",
"weekStart": "1"
}
}
// The app calls this before mounting.
async function configurePeriodPresets(): Promise<void> {
const applied = await applyRuntimeOperations({
runtime,
instanceId: 'period@bar',
availableFields,
operationRequests: [
{
id: 'presets',
name: 'setDatePickerVisibleDatePresets',
payload: ['Current MTD', 'Current QTD', 'Current YTD', 'Last 12 Months'],
},
{ id: 'rolling', name: 'setDatePickerCustomDatePresets', payload: [{ rangeType: 'month', rangeValue: 6 }] },
],
});
// Check applied.results as in Check operation results.
}
  • quickSelect: false keeps its choice pending until Apply.
  • responsive: false lets you size the host; see Header and controls.
  • weekStart is '1' for Monday and '0' for Sunday (0.5.1).
  • Previous … presets select the previous period instead of the current one; they are not a comparison.
  • Mount it in the filter bar or panel, not in a popover: where the calendar renders is not documented.
  • The applied selection carries a range, not a preset name, so describe the range in the header’s scope line.

The full list is in the date-picker operations.

The period, like every selection, is lost on reload; see Limitations to design around.