Skip to content

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 < periodTo

Reaching 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: Timezone
param periodFrom: instant
param 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.