The /cmp/daterange/ package provides DateRangePickerModel, a model for selecting a period of
time and reading it back as concrete dates and filters. Its desktop UI, DateRangePicker in
/desktop/cmp/daterange/, is a compact toolbar control: one trigger button showing the applied
period and its dates, opening a popover with a tab for each way of choosing a period.
Key features:
- One value that can express a preset (MTD, Prev 30 Days, ...), a relative lookback, a calendar month, quarter, or year, or a custom range of dates
- Resolution against a live anchor day, so a persisted "month to date" stays month to date - and rolls over at midnight without app involvement
- A comparable prior range for every selection, for period-over-period comparisons
- Stepping back and forth through periods without a selection losing what it is
- Ready-made
FieldFilterSpecs for filtering a Store, Cube View, or server query - Built-in presets, app-defined presets, and a business-day mode for single-day navigation
- Plain JSON value that persists through
persistWith, including ViewManager views
The model is cross-platform. Only the desktop component exists today.
This guide covers how the pieces fit together and how the selection resolves. For the full list
of configuration keys and component props with their defaults, see the JSDoc on
DateRangePickerConfig and DateRangePickerProps - the source of truth, and what IDE hover and
the Hoist symbol tools surface.
DateRangePickerModel
├── value: DateRangeSelection # The applied period, always normalized
├── anchorDay: DateRangeAnchorDay # How the anchor date is determined - live by default
├── anchorDate, minDate, maxDate # The dates selections resolve against
├── today # The reader's current day - what "Today" means
├── businessDayMode, isBusinessDay # Single-day handling
├── presets: DateRangePreset[] # Offered on the Presets tab
├── tabs: DateRangePickerTab[] # Offered in the popover
├── currentRange: LocalDateRange # Resolved {start, end}
├── priorRange: LocalDateRange # The comparable preceding range, or null
├── currentRangeFilter, priorRangeFilter # FieldFilterSpec[] on `filterField`
├── label, rangeLabel, displayName # Display strings
└── Methods:
├── setValue() # Apply a selection or preset token
├── stepRange() # Previous / next period
├── resolve(), parseValue() # Work with selections other than the applied one
└── setAnchorDay(), setMaxDate(), setPresets(), setTabs(), ...
DateRangeSelection (plain JSON, discriminated on `kind`)
├── {kind: 'preset', token, offset?}
├── {kind: 'relative', count, unit, snap, offset?}
├── {kind: 'month', year, month}
├── {kind: 'quarter', year, quarter}
├── {kind: 'year', year}
└── {kind: 'custom', start, end} # 'YYYY-MM-DD' strings
DateRangeContext # What selections resolve against
├── anchorDate, today, minDate, maxDate
├── isBusinessDay(date), businessDayMode
└── presets: Record<token, DateRangePreset>
Resolution is pure: resolveDateRange(selection, context) returns {current, prior}. The model
holds the context as observable state and exposes the resolved ranges as derived values, so they
update when the value, the anchor date, or the bounds change.
Construct the model within an app model, render the picker bound to it, and read the resolved range or filters wherever the app queries.
import {DateRangePickerModel} from '@xh/hoist/cmp/daterange';
import {dateRangePicker} from '@xh/hoist/desktop/cmp/daterange';
class ReportModel extends HoistModel {
@managed gridModel = new GridModel({...});
@managed periodModel = new DateRangePickerModel({
filterField: 'tradeDate',
initialValue: 'mtd',
persistWith: {localStorageKey: 'reportPeriod'}
});
constructor() {
super();
this.addReaction({
track: () => this.periodModel.currentRangeFilter,
run: filter => this.gridModel.store.setFilter(filter),
fireImmediately: true
});
}
}
// In the component - the picker finds the model via context lookup.
toolbar(dateRangePicker({showStepButtons: true}), filler(), ...)The value and its derived ranges stay live whether or not a picker is mounted. A locked dashboard widget, for example, can hide its picker but still query by period.
DateRangePickerConfig shapes what the picker offers and how it resolves. The keys most apps
touch:
tabsandpresetsselect which of the four tabs appear and which presets the Presets tab lists, in order. Presets may be built-in tokens, app-definedDateRangePresetobjects, or both.anchorDay,minDate, andmaxDateset the dates everything resolves against - see Anchor Day and Bounds below.filterFieldnames the field forcurrentRangeFilterandpriorRangeFilter.initialValueis the starting selection, given as a selection object or a preset token, and the fallback for missing or invalid persisted state. It defaults to the first configured preset, or a rolling 30 days when there are none.persistWithpersists the value - see Persistence below.
businessDayMode, commitOnChange, dateFormat, singleDayFormat, and isBusinessDay round out
the config. All but the last can be defaulted app-wide via DateRangePickerModel.defaults, as can
anchorDay. The two formats split along what they show: dateFormat (default YYYY-MM-DD) for the
ends of a range, where the year earns its place, and singleDayFormat (default ddd MMM D) for a
single day, where the weekday matters more. Either may be a function of the date - e.g. to add the
year only when it is not the current one.
value is always a normalized DateRangeSelection. Set it with setValue(), which accepts a
selection object or a bare preset token and ignores (with a logged warning) anything that fails
validation: an unknown preset, an out-of-range count, year, or offset, or a malformed date.
| Kind | Shape | Resolves to |
|---|---|---|
preset |
{kind: 'preset', token: 'mtd'} |
Whatever the preset's resolver returns for the current context |
relative |
{kind: 'relative', count: 6, unit: 'months', snap: false} |
A window of count units ending on the anchor date |
month |
{kind: 'month', year: 2026, month: 8} |
The calendar month, clamped to maxDate if it falls inside |
quarter |
{kind: 'quarter', year: 2026, quarter: 3} |
The calendar quarter, clamped likewise |
year |
{kind: 'year', year: 2026} |
The calendar year, clamped likewise |
custom |
{kind: 'custom', start: '2026-08-10', end: '2026-08-20'} |
Exactly those dates |
Preset and relative selections re-resolve as the anchor date moves, and carry an optional
offset when stepped from their natural range - see Stepping. Month, quarter, and year
selections name a fixed period, though one containing maxDate is clamped to it. Custom
selections name fixed dates. All are plain JSON, so the value round-trips through persistence
without custom serialization.
Built-in presets live in dateRangePresets, keyed by token. Offer any subset in any order via
the presets config.
| Token | Resolves to | Label |
|---|---|---|
anchorDay |
The anchor date itself | Today when the anchor is the current day, else As Of |
prevDay |
The day before the anchor - the previous business day in businessDayMode |
Prev Day |
wtd, mtd, qtd, ytd |
Start of the unit containing the anchor, through the anchor | MTD, ... |
prev7Days, prev30Days, prev90Days |
Rolling window ending on the anchor | Prev 7 Days, ... |
prev3Months, prev6Months, prev12Months |
Rolling window ending on the anchor | Prev 3 Months, ... |
prevWeek, prevMonth, prevQuarter, prevYear |
The full unit before the one containing the anchor | The period: Aug 2026, Q2 2026, 2025 |
all |
minDate (or unbounded) through maxDate |
All |
DEFAULT_DATE_RANGE_PRESETS is anchorDay, mtd, qtd, ytd, prev7Days, prev30Days,
prev90Days, prev12Months, prevMonth, and prevYear.
Labels say "Prev" rather than "Last" because the anchor date need not be today: "Prev 7 Days" is
true whatever the anchor is, where "Last 7 Days" quietly claims the window ends now. Only
anchorDay reads differently by context, and when it reads As Of the picker shows its date
wherever the label would otherwise stand alone.
An app-defined preset is a DateRangePreset: a unique token, a label (string or function of
the context), an optional longer name for its row in the picker, a resolve function, optional
resolvePrior and resolveNext for the adjacent ranges, and an optional shiftedLabel for the
preset once stepped (see Stepping). The default adjacent ranges are the preceding and following
ranges of equal length in days.
const FISCAL_YTD: DateRangePreset = {
token: 'fytd',
label: 'FYTD',
name: 'Fiscal Year to Date',
resolve: ({anchorDate}) => ({start: fiscalYearStart(anchorDate), end: anchorDate}),
resolvePrior: ({start, end}) => ({
start: start.subtract(1, 'years'),
end: end.subtract(1, 'years')
})
};
new DateRangePickerModel({presets: [FISCAL_YTD, 'qtd', 'ytd', 'prev90Days']});A relative selection is count units ending on the anchor date. Units are days, weeks,
months, quarters, and years.
- Rolling (
snap: false, the default) is exactlycountunits back from the anchor: 3 months ending Sep 2 starts Jun 3. - Calendar (
snap: true) aligns to unit boundaries and counts the current partial unit as one: 3 calendar months ending Sep 2 starts Jul 1. Snap does not apply to days and is normalized tofalsefor that unit.
priorRange is the comparable range immediately before currentRange, for period-over-period
comparison. It is null when the current range is unbounded.
| Selection | Prior range |
|---|---|
Period-to-date presets (mtd, ytd, ...) |
The same span one unit earlier: MTD on the 12th compares against the 1st through 12th of last month |
Previous-unit presets (prevMonth, ...) |
The unit before that |
| Lookbacks in weeks, months, quarters, or years | The same span count units earlier, matching the equivalent presets |
Lookbacks in days, and custom |
The preceding window of equal length in days |
Single days in businessDayMode |
The previous business day |
month, quarter, year |
The previous month, quarter, or year, clamped the same way if the current one is |
The same logic drives stepping, so the prior period is always one step back. An app that wants
the prior MTD as the selected value can set {kind: 'preset', token: 'mtd', offset: -1}.
With filterField configured, currentRangeFilter and priorRangeFilter return
FieldFilterSpec[]: a >= filter for a bounded start and a <= filter for a bounded end. An
unbounded edge produces no filter, so the all preset with no minDate yields a single <=
filter. Pass the array anywhere a FilterLike is accepted, or send it to the server as part of a
query body. getRangeFilter(range, field) builds filters for any range and field.
stepRange(steps) moves the applied range by one period per step: -1 for the previous period,
1 for the next. No selection changes kind:
- Preset and relative selections adjust their
offset, stepping through their own prior- and next-range logic - a lookback in months steps by months, MTD steps to the prior MTD, a single day steps by business day inbusinessDayMode. Negative offsets are earlier periods. Positive ones are later, and reachable only whenmaxDateallows dates beyond the anchor, since the natural range already ends there. The offset is omitted from the value when zero. A stepped selection is still live:mtdat offset-1becomes the new prior MTD when the month turns. - Month, quarter, and year selections step by calendar unit.
- Custom selections step by their length in days, or by business day when a single day in
businessDayMode.
Steps clamp to minDate and maxDate. canStepBack and canStepForward drive the component's
step buttons, and the trigger's left and right arrow keys.
Once stepped, the trigger's dates locate the range, so the label describes only its shape: a
rolling window reads as its length (7 Days, 3 Months), a previous-unit preset as the period it
now covers (Jul 2026), and a to-date preset as its name with the signed offset (MTD −1,
MTD +1) - its length is set by the calendar, not the selection, so a length alone would
mislead. Single days read from the anchor in the T-1 idiom: Today −1, Today −2, or As Of −1,
whether the walk began from anchorDay or prevDay. App-defined presets control this via
shiftedLabel; the default appends the offset.
anchorDay determines the date that relative and to-date selections resolve against, exposed as
anchorDate. It is live by default: the model re-evaluates it every few seconds, and everything
derived from it - ranges, labels, filters, step enablement - follows the day over as midnight
passes, with no app code.
anchorDay |
Anchor date |
|---|---|
'localDay' (default) |
The current day in the browser's time zone, kept current |
'appDay' |
The current day in the app's time zone (LocalDate.currentAppDay()), kept current |
A LocalDate |
That day, pinned. Never moves, never snapped |
() => LocalDate |
Re-evaluated every few seconds and whenever observables it reads change. Must be pure and cheap |
The function form covers as-of dates with their own rule. A desk whose day rolls to the next business day at an evening cutoff, for example:
anchorDay: () => {
const now = moment(),
day = LocalDate.today(),
afterCutoff = now.hour() >= 18;
let ret = afterCutoff ? day.nextDay() : day;
while (!ret.isWeekday) ret = ret.nextDay();
return ret;
}The anchorDay preset labels itself Today only when the anchor date is the reader's current day
- the browser's, always, whatever zone the anchor is drawn from. Otherwise it reads
As Of, and the trigger shows the date. So on the Friday evening above the picker readsAs Of | 2026-09-07until Monday morning, when it readsToday. Likewise a user whose browser is a day ahead of an'appDay'anchor seesAs Ofwith the app's date rather than a "Today" that is not theirs.
businessDayMode is for users who think in calendar-length windows but stand on business
days. It has two effects and no others: a live anchorDay ('localDay' or 'appDay') snaps back
to the most recent business day per isBusinessDay, and single-day selections step by business
day. Seven days is still seven days, and a month is still a month. A pinned or computed
anchorDay is honored verbatim - a month-end that falls on a Sunday stays the 31st.
Nothing beyond maxDate is selectable: later months and days are disabled in the popover, and
month and year selections spanning maxDate are clamped to it, so the current year covers Jan 1
through the anchor. It still reads as its year, 2026 - YTD is the preset, which steps to the
same partial span a year earlier, where a year pick steps by whole years. maxDate defaults to
anchorDate. Set it later to allow future dates, or set minDate to bound the past.
Pass persistWith to persist the value under dateRangePicker.value by default. Set path to
disambiguate multiple pickers sharing one provider, or persistValue: false to skip persistence
while keeping the options for future aspects.
new DateRangePickerModel({
persistWith: {viewManagerModel: this.viewManagerModel, path: 'detailPeriod'}
});A persisted value that is missing or fails validation - a preset since removed, for example -
falls back to defaultValue rather than carrying a previous value over.
| Property | Example | Use |
|---|---|---|
label |
MTD, Prev 6 Months, Aug 2026, Custom |
The trigger |
rangeLabel |
2026-08-01 ▸ 2026-09-02, Fri Sep 4 |
The trigger's dates, per dateFormat. A single day reads as one date, per singleDayFormat. |
displayName |
MTD, August 2026, the dates for a custom range or As Of |
Panel titles - the label, with months spelled out and unnamed periods as their dates |
The desktop component. It finds its DateRangePickerModel via context lookup or an explicit
model prop, and supports Hoist layout props. DateRangePickerProps is small: showStepButtons
adds previous and next buttons wired to stepRange(), styleButtonAsInput (default true) matches
the trigger to GroupingChooser's input styling or renders an outlined button, intent re-keys
the popover's selection accent, and buttonProps, footerNote, popoverPosition, and
showRange tune the rest.
Preset and month/year picks apply on click and close the popover. Relative and custom picks are
drafts until Apply, and Cancel, Escape, or a click outside discards them. With commitOnChange,
drafts apply as they change and Apply and Cancel are omitted.
A model configured with one tab renders without the rail, and its popover shrinks to fit.
A trigger sized by its content never truncates. A stretched trigger (flex: 1, or an explicit
width) measures itself and drops the dates when it is too narrow to show them, leaving the
period label alone. Whenever the dates are not shown, a custom range - and the anchor day when it
reads As Of - shows its dates in place of the uninformative label. The full label and dates
remain available in the trigger's tooltip. With the trigger focused, the left and right arrow keys
step the period.
Block classes are xh-date-range-picker (the control: trigger and step buttons) and
xh-date-range-picker-popover. The picker's own colors and key sizes come from
--xh-date-range-picker-* variables, declared in the Date Range Picker block of
styles/vars.scss, each with an unprefixed override hook. The ones an app is most likely to set:
body.xh-app {
--date-range-picker-popover-width: 720px; // default 640px
--date-range-picker-accent: var(--xh-intent-success); // selection accent, default primary
--date-range-picker-date-font-family: var(--xh-font-family); // default the mono font
}get stats() {
const {currentRangeFilter, priorRangeFilter, priorRange} = this.periodModel;
return {
current: this.sumMatching(currentRangeFilter),
prior: priorRange ? this.sumMatching(priorRangeFilter) : null
};
}@computed
get query() {
const {start, end} = this.periodModel.currentRange;
return {startDay: start, endDay: end, ...otherParams};
}Keep unbounded presets such as all out of the configured presets if the endpoint requires
both dates, or handle a null edge in the query.
new DateRangePickerModel({
tabs: ['period'],
initialValue: {kind: 'month', year: 2026, month: 1}
});new DateRangePickerModel({
anchorDay: 'appDay',
businessDayMode: true,
isBusinessDay: d => d.isWeekday && !XH.holidayService.isHoliday(d),
presets: ['anchorDay', 'prevDay', 'wtd', 'mtd', 'qtd', 'ytd', 'prevMonth']
});On a Saturday this resolves everything as of Friday, and the step buttons walk anchorDay from
Friday to Thursday and back.
filterFieldis required for filters. The filter getters throw without it. UsecurrentRangedirectly when the app builds its own query.- Invalid values are ignored, not thrown.
setValue()logs a warning and keeps the current value. CheckvalidateValue()first when the input is untrusted. - Presets are per model. A persisted preset token that is not in the model's
presetsfails validation and falls back to the default. Keep the configured list stable across versions, or accept that users will see the default after a change. allcan be unbounded. WithoutminDate, its start isnull,priorRangeisnull, andcurrentRangeFilterhas one entry.- A computed
anchorDayruns often. It is re-evaluated every few seconds. Keep it to clock reads and observable reads - derive anything expensive once, store it on an observable, and read that.
/data/-FieldFilterSpecand applying filters to Stores/cmp/viewmanager/- persisting the value within saved views/cmp/grouping/and/cmp/filter/- sibling chooser models with the same popover-and-model pattern/utils/-LocalDateandTimer