The Problem
Date range selection is fundamental to any time-series analytics platform. Every chart, every dashboard, every report in CoolPlanet's platform needs to answer the question: "What time period are we looking at?"
We tried off-the-shelf solutions and widely-used libraries, but none were fit for purpose. Most date pickers are designed for booking flights or hotel stays, not for pinpointing exact moments in time series data. Our users weren't selecting a checkout date; they were analysing sensor readings at 2:47am on a Tuesday three weeks ago, then stepping forward hour by hour to understand what happened.
The existing solution we had in place was janky and unintuitive. It worked, technically, but it was a constant source of complaints. Users wanted to type "last week", "Q1", or "YTD" and have it just work. They wanted to quickly navigate through time periods with forward/backward buttons. They wanted recent searches remembered so they could reuse common ranges. None of this was possible.
The challenge was getting buy-in. When something technically works, it can be difficult to justify dedicating resources to replacing it. I took it upon myself to investigate how we could solve this platform-wide issue, develop a plan, and build the case for stakeholders to commit the engineering time. Once I had a clear vision of what we could build and how it would improve the user experience across the entire platform, I got the green light to proceed.
Inspiration: Datadog's Druids Design System
Datadog faces the same challenges we do. Their observability platform requires constant time-range adjustments, and they've invested heavily in solving this problem well. Their Druids design system describes the DateRangePicker as a "widespread and unique component", acknowledging that time-scoping is core to their analytics workflow.
I spent considerable time studying their DateRangePicker component, which includes an interactive playground for exploring its behaviour. The component packs a lot of functionality into a compact interface: canned ranges for quick selection, calendar views for precise date picking, and natural language input for power users.
Clicking the selected range reveals a dropdown with pre-canned options, a calendar for specific date selection, and a "More" section that expands to show custom input types. This expanded view essentially teaches users what they can type into the input box, with examples of relative times, fixed dates, and even Unix timestamps.
We spent significant time identifying which patterns were essential for our users and which we could omit.
What We Prioritised
Having studied Datadog's approach, we defined what mattered most for our implementation:
- Natural language input: Let users type expressions like "last week", "Q1", or "YTD" and have them just work.
- Recent searches: Persist up to 20 recent selections in localStorage so power users can quickly reuse common ranges.
- Progressive disclosure: Simple interface by default, with power features discoverable but not overwhelming.
- Playback navigation: Forward/backward buttons to step through time periods, with debouncing to prevent API spam.
- Clear error feedback: Two-phase validation (parse first, then resolve) so users get meaningful error messages at each stage.
- Timezone-agnostic internals: Handle dates without timezone complications until the final resolution step.
The core value was clear: let users express time ranges naturally, remember their recent selections, and provide helpful guidance without overwhelming them.
Architecture
I designed a modular architecture split across five libraries in our Nx monorepo. This separation of concerns made the component maintainable, testable, and extensible.
date-range-picker/ ├── parser/ # Parsimmon-based natural language parsing ├── range-resolver/ # Luxon-powered date resolution ├── machines/ # XState v5 state management ├── ui/ # Presentational React components └── feature/ # Smart container component
parser/ handles the natural language parsing using Parsimmon, a parser combinator library. Small parsers combine to form larger ones, making it easy to add new expression types.
range-resolver/ takes parsed expressions and resolves them to actual dates using Luxon. The key insight here is the two-phase validation approach: first parse the expression, then resolve it to concrete dates. This allows us to give meaningful error messages at each stage.
machines/ contains XState v5 state machines managing dropdown state, input validation, and playback button debouncing. State machines make complex UI interactions explicit and testable.
Parsing: String to Structure
The parser transforms user input strings into structured TypeScript objects. The key output types represent different expression categories:
// Core output types from the parser
type Duration = 'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes';
type ParsedDate = {
type: 'DATE';
value: { years?: number; months?: number; days?: number };
};
type ParsedDateTime = {
type: 'DATETIME';
value: { years?: number; months?: number; days?: number;
hours?: number; minutes?: number };
};
type ParsedRelativeRange = {
type: 'PAST' | 'LAST' | 'THIS';
value: { count: number; duration: Duration };
};
type ParsedRange = {
type: 'RANGE';
value: {
start: ParsedDate | ParsedDateTime | ParsedQuarter;
end: ParsedDate | ParsedDateTime | ParsedQuarter;
};
};
type ParsedQuarter = {
type: 'QUARTER';
value: { number: number; year?: number };
};
type ParsedNamedRange = {
type: 'NAMED_RANGE';
value: 'TODAY' | 'YESTERDAY' | 'LAST_7_DAYS' | 'THIS_WEEK' | ...;
};
type ParsedToDate = {
type: 'TO_DATE';
value: 'YTD' | 'MTD' | 'WTD';
};Simple expressions produce simple structures. A named range like "yesterday" parses directly to a single object:
// Input: "yesterday"
{ type: 'NAMED_RANGE', value: 'YESTERDAY' }
// Input: "last month"
{ type: 'NAMED_RANGE', value: 'LAST_MONTH' }
// Input: "ytd"
{ type: 'TO_DATE', value: 'YTD' }Relative expressions like "last 3 months" capture both the count and the duration unit:
// Input: "last 3 months"
{ type: 'LAST', value: { count: 3, duration: 'months' } }
// Input: "past 5 days"
{ type: 'PAST', value: { count: 5, duration: 'days' } }
// Input: "this week"
{ type: 'THIS', value: { count: 1, duration: 'weeks' } }More complex expressions produce nested structures. A date range like "Q1 2023 - Q4 2023" requires multiple parsers working together:
// Input: "Q1 2023 - Q4 2023"
{
type: 'RANGE',
value: {
start: { type: 'QUARTER', value: { number: 1, year: 2023 } },
end: { type: 'QUARTER', value: { number: 4, year: 2023 } }
}
}
// Input: "15 jan 2022 01:59 - 22nd february 23 22:10"
{
type: 'RANGE',
value: {
start: {
type: 'DATETIME',
value: { years: 2022, months: 1, days: 15, hours: 1, minutes: 59 }
},
end: {
type: 'DATETIME',
value: { years: 2023, months: 2, days: 22, hours: 22, minutes: 10 }
}
}
}The parser uses Parsimmon, a parser combinator library. Small parsers for individual components (year, month, day, hour) combine into medium-level parsers (date, time, datetime), which then combine into high-level parsers (ranges, quarters, relative expressions). The P.alt() combinator tries each parser in sequence, returning the first successful match.
Resolution: Structure to Dates
Once parsed, the structured expression needs to be resolved into concrete dates. This is where the range-resolver library takes over, converting parsed objects into a LocalTimeRange:
// Timezone-agnostic time representation
type LocalDateTime = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
};
// Result of range resolution
type LocalTimeRange = {
start: LocalDateTime;
end: LocalDateTime;
};The resolver takes three inputs: the parsed expression, the current time (as a LocalDateTime), and an optional week start configuration (1=Monday through 7=Sunday). Different expression types resolve differently:
// Assuming now = { year: 2024, month: 3, day: 15, hour: 10, minute: 30 }
// ─────────────────────────────────────────────────────────────────
// Input: "yesterday" (resolves to the full previous day)
// ─────────────────────────────────────────────────────────────────
const parsed = { type: 'NAMED_RANGE', value: 'YESTERDAY' };
const resolved = {
start: { year: 2024, month: 3, day: 14, hour: 0, minute: 0 },
end: { year: 2024, month: 3, day: 15, hour: 0, minute: 0 }
};
// ─────────────────────────────────────────────────────────────────
// Input: "last 7 days" (resolves relative to now)
// ─────────────────────────────────────────────────────────────────
const parsed = { type: 'NAMED_RANGE', value: 'LAST_7_DAYS' };
const resolved = {
start: { year: 2024, month: 3, day: 8, hour: 0, minute: 0 },
end: { year: 2024, month: 3, day: 15, hour: 0, minute: 0 }
};
// ─────────────────────────────────────────────────────────────────
// Input: "Q1 2024" (resolves to calendar quarter boundaries)
// ─────────────────────────────────────────────────────────────────
const parsed = { type: 'QUARTER', value: { number: 1, year: 2024 } };
const resolved = {
start: { year: 2024, month: 1, day: 1, hour: 0, minute: 0 },
end: { year: 2024, month: 4, day: 1, hour: 0, minute: 0 }
};The resolver handles incomplete specifications intelligently. A start date with only year/month snaps to the first day at midnight. An end date with only year snaps to the start of the next year, ensuring the range includes the entire specified period:
// ─────────────────────────────────────────────────────────────────
// Input: "2023" (year-only expands to full year)
// ─────────────────────────────────────────────────────────────────
const parsed = { type: 'DATE', value: { years: 2023 } };
const resolved = {
start: { year: 2023, month: 1, day: 1, hour: 0, minute: 0 },
end: { year: 2024, month: 1, day: 1, hour: 0, minute: 0 }
};
// ─────────────────────────────────────────────────────────────────
// Input: "jan 2024" (month-only expands to full month)
// ─────────────────────────────────────────────────────────────────
const parsed = { type: 'DATE', value: { years: 2024, months: 1 } };
const resolved = {
start: { year: 2024, month: 1, day: 1, hour: 0, minute: 0 },
end: { year: 2024, month: 2, day: 1, hour: 0, minute: 0 }
};LocalDateTime type is deliberately timezone-agnostic. It stores only calendar components, with all Luxon operations performed in UTC internally. This avoids timezone complications in the parsing and resolution phases; the consuming code interprets the dates in its own context.State Management
We opted for XState over simpler alternatives like native React state or reducers. It's a more complex, capable solution, but state machines guarantee behaviour: impossible states are literally impossible, and complicated state-related bugs are easy to track down and resolve (if they're even introduced in the first place).
XState v5 parallel states manage multiple concerns simultaneously:
- Input validation: Tracks parse state, resolution state, and error messages
- Dropdown state: Open/closed, which section is active (canned, recent, help)
- Playback: Forward/backward navigation with 400ms debounce to prevent API spam
The diagram above shows the actual statechart, visualised using XState's visual editor. This tooling is invaluable when developing complex state machines: you can see the entire state space at a glance, trace transitions, and verify that edge cases are handled correctly.
Implementation
I architected the solution and led implementation in partnership with a junior developer I was mentoring. When we first sat down to brainstorm, they were understandably daunted. The brief was essentially "go and rebuild Datadog's date range picker", which felt like an impossible task when viewed as a whole.
But the only way to eat an elephant is one bite at a time. We examined the problem together, breaking it down into distinct parts: parsing, resolution, state management, UI components. Each piece became its own library with a clear interface. Suddenly the impossible task became a series of manageable ones.
The modular architecture meant we could work on different libraries in parallel, and the clear interfaces made code reviews focused and educational. More importantly, my colleague got hands-on practice in exactly this skill: building complicated software incrementally, piece by piece, rather than trying to tackle everything at once. It's a lesson that serves them well beyond this single project.
The reverse-engineering process was equally methodical. I examined Datadog's component behaviour piece by piece, documenting the UX patterns before writing any code. This upfront analysis paid off in a cleaner implementation with fewer iterations.
Impact
The Date Range Picker launched in April 2024 and was deployed across the platform. The reception was immediate: UX complaints stopped and enhancement requests started coming in (a sign that users were actually engaging with the feature rather than avoiding it).
The modular architecture has proven its value. The component has been extended multiple times since launch to support new use cases, each time with minimal changes to the core libraries. The separation of parsing, resolution, and UI concerns means changes in one area don't ripple through the entire codebase.
Most importantly, it's low-maintenance. Once stable, we rarely need to touch it.