periods
Module Export
periods ( from: instant | date, to: instant | date, granularity: Granularity, zone: Timezone) => [{ start: instant, end: instant }]Every calendar period overlapping a range, aligned to local calendar boundaries.
Remarks
The bucketing primitive: given a range, it produces the calendar periods covering it, each as a self-describing record. Use it to build a time series — a chart of orders per month, revenue per week, activity per hour.
Unlike the other functions in this module, periods is called in prefix position with
all its arguments together, because a range is a single thing rather than a value being
transformed.
Bounds and tiling
start is inclusive and end is exclusive, and the end of each period equals the
start of the next. A row therefore belongs to exactly one bucket when tested with
>= start and < end — no gaps, no double-counting.
Alignment
Periods align to the calendar, not to the range. The first period is the one containing the earlier endpoint, so it may begin before it; the last may end after the later endpoint.
This is what makes buckets meaningful. A “last 90 days” range beginning 15 August yields an August bucket, not a 15 August – 14 September bucket that would be mislabelled “August”. The first and last buckets are genuinely partial, and a caller presenting them should say so.
Argument order
The endpoints describe an interval, so their order carries no meaning: the earlier value is always treated as the start, and reversing the arguments produces an identical set. The result is consequently never empty — any two instants yield at least the period containing them.
Limit
A range is capped at 10 000 periods, which bounds memory and response size — hourly buckets over a century would be nearly a million records.
Exceeding the cap truncates rather than failing, keeping the earliest periods in chronological order. Truncation is visible in the result, because the last period ends before the upper bound you asked for:
let buckets = periods (periodFrom, periodTo, 'hour', zone)let truncated = (buckets last).end < periodToReaching the cap generally means the granularity is too fine for the range. A coarser one will cover it.
Examples
import 'calendar'
from periods (2026-01-01, 2026-03-15, 'month', 'UTC')
// Returns:[ { start = 2026-01-01T00:00:00Z, end = 2026-02-01T00:00:00Z }, { start = 2026-02-01T00:00:00Z, end = 2026-03-01T00:00:00Z }, { start = 2026-03-01T00:00:00Z, end = 2026-04-01T00:00:00Z }]Across a daylight saving transition
Each boundary is resolved against the timezone database individually, so a series spanning a transition stays aligned to real calendar months:
import 'calendar'
from periods (2026-08-15T10:00:00Z, 2026-11-20T10:00:00Z, 'month', 'Europe/Stockholm')
// Returns:[ { start = 2026-07-31T22:00:00Z, end = 2026-08-31T22:00:00Z }, // August, UTC+2 { start = 2026-08-31T22:00:00Z, end = 2026-09-30T22:00:00Z }, // September, UTC+2 { start = 2026-09-30T22:00:00Z, end = 2026-10-31T23:00:00Z }, // October, +2 -> +1 { start = 2026-10-31T23:00:00Z, end = 2026-11-30T23:00:00Z } // November, UTC+1]October is 31 days and one hour long, because the clocks went back on the 25th. That falls out of resolving each edge against the database rather than adding a fixed duration — which is the entire reason this function exists.
Bucketing a series
import 'calendar'import 'iterators'
param zone: Timezoneparam periodFrom: instantparam periodTo: instant
let buckets = periods (periodFrom, periodTo, 'month', zone)
from buckets select b => let rows = orders where o => (o.createdAt >= b.start) and (o.createdAt < b.end) from { start = b.start total = rows select o => o.total sum }Written for clarity. Over a large set, prefer a single pass that walks time-ordered rows against the buckets — both sequences ascend, so assignment is a merge rather than a search, and the source is traversed once rather than once per bucket.