AG Studio: Build dashboards using native components in web apps. Join us for a webinar on 28th July at 2pm UTC+1 Register

React Embedded AnalyticsCalendars

Version 2.1.2

Studio groups and analyses date fields through calendars - named time dimensions declared in the data definition alongside your sources. A calendar owns a set of fragments (Year, Month, Day of Week, …) that appear in the Data Panel and can be used like normal fields.

Declaring a Calendar Copy Link

Add a calendars array to the data definition, alongside sources and relationships. Each calendar declares its id, display label, the date range it covers, and the fragments to expose. Range endpoints are literal date strings, epoch-ms numbers, or field references (with aggregation: 'min' | 'max' to detect the range from a column's data):

const studioProperties = {
    data: {
        sources: [{ id: 'sales', data: [...], fields: [...] }],
        calendars: [
            {
                id: 'calendar',
                label: 'Calendar',
                range: { from: { type: 'date', value: '2022-01-01' }, to: { type: 'date', value: '2024-12-31' } },
                fragments: [
                    'year', 'quarter', 'month', 'week', 'day', 'monthOfYear', 'dayOfWeek'
                ],
            },
        ],
    },
};

Binding Date Columns Copy Link

Bindings are declared as relationships in the data sources definition. To bind a fact date column to a calendar, add a relationship whose target.calendarId matches the calendar's id:

const studioProperties = {
    data: {
        sources: [...],
        relationships: [
            {
                id: 'sales-calendar',
                source: { tableId: 'sales', fieldId: 'order_date' },
                target: { calendarId: 'calendar' },
            },
        ],
        calendars: [
            {
                id: 'calendar',
                label: 'Calendar',
                range: { from: { type: 'date', value: '2022-01-01' }, to: { type: 'date', value: '2024-12-31' } },
                fragments: ['year', 'quarter', 'month'],
            },
        ],
    },
};

When a table has more than one date column, add a separate relationship entry for each column - each relationship binds one column independently.

The grain a binding can reach comes from the bound column's data type. A date column reaches day; a datetime column also reaches hour and minute. A column's grain menu only offers fragments its type supports; referencing a finer fragment directly reports a validation error rather than returning wrong results.

Datetime Columns Copy Link

When binding a datetime column you can truncate it to a coarser grain before it joins the calendar - useful when the time component is noise and you only want to group by day:

const relationships = [
    {
        id: 'events-calendar',
        source: { tableId: 'events', fieldId: 'occurred_at' },  // a datetime column
        target: { calendarId: 'calendar' },
        truncate: 'day',   // drop the time component; group at day grain
    },
];

To group by time of day, declare the sub-day fragments (hour, minute, hourOfDay) on the calendar alongside the date fragments:

const calendar = {
    id: 'calendar',
    label: 'Calendar',
    range: { from: { type: 'date', value: '2024-01-01' }, to: { type: 'date', value: '2024-01-31' } },
    fragments: ['day', 'hour', 'minute', 'hourOfDay'],
};

Sub-day grains generate far more spine rows than date grains (minute grain over a single day is 1,440 rows; over a year, more than half a million). Keep the calendar range tight when exposing hour or minute.

Fragment Types Copy Link

Fragments fall into two categories:

  • Chronological fragments - each value is a distinct point in time. E.g. month produces one row per calendar month (January 2023 and January 2024 are separate rows).
  • Cyclic fragments - values are bucketed together. E.g. all Januaries from any year map to the same bucket.
CategoryFragments
Chronologicalyear, halfYear, quarter, month, week, day, hour, minute
CyclicmonthOfYear, dayOfWeek, monthOfQuarter, weekOfMonth, halfOfYear, hourOfDay

The hour and minute chronological fragments and the hourOfDay cyclic fragment are sub-day grains. They only produce values when the bound column carries a time component - see Datetime Columns.

Using Calendar Fragments in Widgets Copy Link

Reference calendar fragments in dataMapping using the format calendarId::fragmentId:

const studioProperties = {
    initialState: {
        pages: [{
            widgets: {
                'revenue-by-month': {
                    type: 'line-chart',
                    dataMapping: {
                        categoryKey: [{ id: 'calendar::month' }],
                        valueKey: [{ id: 'sales.amount', aggregation: 'sum' }],
                        tooltipKey: [],
                    },
                },
            },
        }],
    },
};

The calendar prefix is the calendar's id. Fragment fields are visible in the data panel under the calendar's name alongside the regular data source tables.

The example above shows three pages:

  • By Year - calendar::year (chronological; one bar per calendar year)
  • By Month - calendar::month (chronological; one line point per calendar month)
  • Seasonality - calendar::monthOfYear and calendar::dayOfWeek (cyclic; all years folded together)

Fragment Reference Copy Link

Each fragment has a default display format. The table below lists every built-in fragment.

FragmentCategoryDefault formatNotes
yearchronologicalyyyy
halfYearchronologicalH1 yyyyH1 = Jan - Jun, H2 = Jul - Dec
quarterchronological"Q"qq yyyy
monthchronologicalmmm yyyy
weekchronological"W"ww yyyyISO week (Mon start)
daychronologicaldd/mm/yyyy (or locale equivalent)
hourchronologicaldd/mm/yyyy hh:00 (or locale equivalent)Datetime columns only
minutechronologicaldd/mm/yyyy hh:mm (or locale equivalent)Datetime columns only
monthOfYearcyclicmmmmRenders as month name (January - December)
dayOfWeekcyclicdddISO (1 = Mon)
monthOfQuartercyclic01 - 3
weekOfMonthcyclic01 - 5
halfOfYearcyclicH1H1 or H2, all years folded together
hourOfDaycyclic00 - 23, datetime columns only

To override the format for a specific fragment, replace the bare string in fragments with an object:

const calendar = {
    id: 'calendar',
    label: 'Calendar',
    range: { from: { type: 'date', value: '2022-01-01' }, to: { type: 'date', value: '2024-12-31' } },
    fragments: [
        'year',
        { unit: 'month', format: 'mmmm yyyy' },   // override: spell out month name
        'monthOfYear',
    ],
}

The format string uses the same Excel-like pattern as field format definitions elsewhere in Studio (d, mmm, yyyy, "Q"q, etc.).

The example below demonstrates all fragments.

Custom Bucket Types Copy Link

The built-in fragments cover the common calendar vocabulary, but some groupings are specific to a business - a biweekly pay period, a retail 4-4-5 period, a fiscal week. Register additional bucket types with createBuckets, then reference their ids in a calendar's fragments array like any built-in fragment.

import { createBuckets } from 'ag-studio';

const PAY_PERIOD_EPOCH_DAY = Math.floor(Date.UTC(2022, 0, 3) / 86_400_000);

const buckets = createBuckets({
    additionalTypes: [
        {
            id: 'payPeriod',
            grain: 'day',
            components: ['year', 'month', 'day'],
            encode: ([year, month, day]) => {
                const dayIndex = Math.floor(Date.UTC(year, month - 1, day) / 86_400_000);
                return Math.floor((dayIndex - PAY_PERIOD_EPOCH_DAY) / 14);
            },
            decode: (payPeriod) => {
                const date = new Date((PAY_PERIOD_EPOCH_DAY + payPeriod * 14) * 86_400_000);
                return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()];
            },
            format: () => (payPeriod) => `Pay Period ${payPeriod}`,
        },
    ],
});

Pass the result as data.buckets, alongside sources and calendars:

const data = {
    sources: [
        /* ... */
    ],
    calendars: [
        {
            id: 'payroll',
            label: 'Payroll Calendar',
            range: { from: { type: 'date', value: '2020-01-01' }, to: { type: 'date', value: '2030-12-31' } },
            fragments: ['year', 'payPeriod'],
        },
    ],
    buckets,
};

A bucket with more than one components entry needs encode and decode to derive a single, sequential integer key from those components - createBuckets throws if either is missing. A single-component bucket (e.g. components: ['month']) needs neither; the raw extracted value is the key.

createBuckets also accepts overrides to change a built-in bucket's format without redeclaring its grain, components, encode, or decode:

const buckets = createBuckets({
    overrides: [{ id: 'month', format: 'mmmm yyyy' }],
});

Custom buckets are available on any date or datetime column, whether or not it is bound to a calendar. Drop the column onto a widget and the custom fragment appears in its grain menu alongside the built-in units - on a calendar-bound column it sits beside the calendar's declared fragments, so binding a column never hides its buckets.

The example above registers payPeriod (14-day periods counted from a fixed reference date, with no alignment to calendar months or years) alongside the built-in year fragment on the same calendar. Both fragments are addressed the same way in dataMapping: payroll::year and payroll::payPeriod.

Adopted Calendars Copy Link

If you already maintain a date table, it can be used as-is without needing to generate a new one.

Declare an adopted calendar by setting range.sourceId to the source ID of the date table. Add a mappings array that declares which field in that table provides which calendar fragment:

const calendar = {
    id: 'calendar',
    label: 'Calendar',
    range: {
        sourceId: 'dimDate',
        mappings: [
            { fieldId: 'year_month', components: ['year', 'month'] },  // e.g. yyyymm integer
            { fieldId: 'month_of_year', components: ['monthOfYear'] },
        ],
    },
}

Studio computes each fragment from the bound fact date column using the same formula as the mapping, so the two sides always agree without an explicit join key.

Sub-year fragment values must be unique within a year - a bare month number of 4 is ambiguous across years. Pair the fragment with year in the same components array. The accessor receives the full source row, so it can read any column - pair from the same packed field, or from two separate columns:

const mappings = [
    // packed: year_month stores yyyymm, e.g. 202304
    {
        fieldId: 'year_month',
        components: ['year', 'month'],
        accessor: (row) => [Math.floor(row.year_month / 100), row.year_month % 100],
    },

    // separate columns: year_num and month_num on the same row
    {
        fieldId: 'month_num',
        components: ['year', 'month'],
        accessor: (row) => [row.year_num, row.month_num],
    },
];

The components order and the accessor's return order match positionally. Two cases to watch:

  • day must include year and month - e.g. a yyyymmdd field unpacked as [year, month, day].
  • week must pair with the ISO week-numbering year, not the calendar year - they diverge in the days around 1 January.

Packed Columns and Accessors Copy Link

When a single field encodes several components (such as a packed yyyymmdd integer), add an accessor function to the mapping. It receives the full data row and returns the component values in the order declared by components:

const calendar = {
    id: 'calendar',
    label: 'Calendar',
    range: {
        sourceId: 'dimDate',
        mappings: [
            {
                fieldId: 'yyyymmdd',
                components: ['year', 'month'],
                accessor: (row) => [
                    Math.floor(row.yyyymmdd / 10000),
                    Math.floor((row.yyyymmdd % 10000) / 100),
                ],
            },
            { fieldId: 'month_of_year', components: ['monthOfYear'] },
        ],
    },
}

Binding to an Adopted Calendar Copy Link

Bind fact date columns to an adopted calendar the same way as a generated calendar - add a relationship with target.calendarId set to the adopted calendar's id:

const relationships = [
    {
        id: 'sales-dimdate',
        source: { tableId: 'sales', fieldId: 'order_date' },
        target: { calendarId: 'calendar' },
    },
],

The calendar type is inferred from its shape - a calendar whose range has a sourceId property is adopted; one without is generated.

The example above uses a pre-built date table with a packed yyyymmdd integer and a month_of_year column. The calendar unpacks year and month from yyyymmdd via an accessor, and reads month_of_year directly. Widgets reference those fragments with the same calendarId::fragmentId syntax as generated calendars. The fourth page shows the raw date table columns for reference.

The same pattern works for string-encoded dates. If the date table stores months as "YYYY-MM" strings, split and parse in the accessor:

{
    fieldId: 'year_month',
    components: ['year', 'month'],
    accessor: (row) => {
        const [y, m] = (row.year_month as string).split('-');
        return [parseInt(y, 10), parseInt(m, 10)];
    },
}

The example above uses a date table where months are stored as "YYYY-MM" strings. The accessor splits the string and returns [year, month] as integers. The month_of_year column is read directly with no accessor needed.