---
title: "Calendars"
framework: react
version: "2.1.2"
---

# Calendars

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

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):

```typescript
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

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`:

```typescript
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

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:

```typescript
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:

```typescript
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

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.

| Category | Fragments |
| --- | --- |
| Chronological | `year`, `halfYear`, `quarter`, `month`, `week`, `day`, `hour`, `minute` |
| Cyclic | `monthOfYear`, `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](#datetime-columns).

## Using Calendar Fragments in Widgets

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

```typescript
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.

#### Time Data

```tsx
"use client";

import type {
  AgDataSourcesDefinition,
  AgFieldDefinition,
  AgRelationDefinition,
  AgReportState,
  AgStudioMode,
} from "ag-studio";
import type { AgStudioRef } from "ag-studio-react";
import { AgStudio } from "ag-studio-react";
import React, { StrictMode, useCallback, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";

function buildSalesData() {
  const rows: { order_date: string; amount: number; region: string }[] = [];
  const regions = ["North", "South", "East", "West"];
  let rng = 42;
  const rand = () => {
    rng = (rng * 1664525 + 1013904223) & 0x7fffffff;
    return rng / 0x7fffffff;
  };

  for (let year = 2022; year <= 2024; ++year) {
    for (let month = 1; month <= 12; ++month) {
      const daysInMonth = new Date(year, month, 0).getDate();
      for (let day = 1; day <= daysInMonth; ++day) {
        const mm = String(month).padStart(2, "0");
        const dd = String(day).padStart(2, "0");
        rows.push({
          order_date: `${year}-${mm}-${dd}`,
          amount: Math.round(200 + rand() * 800 * (1 + month * 0.05)),
          region: regions[Math.floor(rand() * regions.length)],
        });
      }
    }
  }
  return rows;
}

function getData(): AgDataSourcesDefinition {
  return {
    sources: [
      {
        id: "sales",
        name: "Sales",
        data: buildSalesData(),
        fields: [
          {
            id: "order_date",
            name: "Order Date",
            format: "dateFormat",
          } satisfies AgFieldDefinition,
          {
            id: "amount",
            name: "Revenue",
            format: "currencyFormat",
          } satisfies AgFieldDefinition,
          {
            id: "region",
            name: "Region",
            format: "textFormat",
            cardinality: "low",
          } satisfies AgFieldDefinition,
        ],
      },
    ],
    relationships: [
      {
        id: "sales-calendar",
        source: { tableId: "sales", fieldId: "order_date" },
        target: { calendarId: "calendar" },
      },
    ] satisfies AgRelationDefinition[],
    calendars: [
      {
        id: "calendar",
        label: "Calendar",
        range: {
          from: { type: "date", value: "2022-01-01" },
          to: { type: "date", value: "2024-12-31" },
        },
        fragments: ["year", "quarter", "month", "monthOfYear", "dayOfWeek"],
      },
    ],
  };
}

const StudioExample = () => {
  const studioRef = useRef<AgStudioRef>(null);
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const data = useMemo(() => getData(), []);
  const mode = useMemo<AgStudioMode>(() => "edit", []);
  const initialState = useMemo<AgReportState>(
    () => ({
      selectedPageId: "by-year",
      pages: [
        // Page 1: Annual trend - calendar::year (chronological)
        {
          id: "by-year",
          widgets: {
            "year-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "calendar::year" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              format: { title: { enabled: true, text: "Revenue by Year" } },
            },
            "year-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "calendar::year" },
                  { id: "sales.amount", aggregation: "sum" },
                  { id: "sales.amount", aggregation: "count" },
                  { id: "sales.amount", aggregation: "avg" },
                ],
              },
              sort: [{ field: { id: "calendar::year" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "year-chart": { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 20 },
            "year-grid": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 20 },
          },
        },

        // Page 2: Monthly trend - calendar::month (chronological, one row per calendar month)
        {
          id: "by-month",
          widgets: {
            "month-chart": {
              type: "line-chart",
              dataMapping: {
                categoryKey: [{ id: "calendar::month" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              format: { title: { enabled: true, text: "Revenue by Month" } },
            },
            "month-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "calendar::month" },
                  { id: "sales.amount", aggregation: "sum" },
                  { id: "sales.amount", aggregation: "count" },
                ],
              },
              sort: [{ field: { id: "calendar::month" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "month-chart": { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 20 },
            "month-grid": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 20 },
          },
        },

        // Page 3: Seasonality - cyclic fragments fold all years together
        {
          id: "seasonality",
          widgets: {
            "moy-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "calendar::monthOfYear" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              sort: [
                { field: { id: "calendar::monthOfYear" }, direction: "asc" },
              ],
              format: {
                title: {
                  enabled: true,
                  text: "Revenue by Month of Year (all years)",
                },
              },
            },
            "dow-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "calendar::dayOfWeek" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              sort: [
                { field: { id: "calendar::dayOfWeek" }, direction: "asc" },
              ],
              format: {
                title: {
                  enabled: true,
                  text: "Revenue by Day of Week (all years)",
                },
              },
            },
          },
          widgetLayout: {
            "moy-chart": { xTrack: 0, yTrack: 0, xSpan: 12, ySpan: 20 },
            "dow-chart": { xTrack: 12, yTrack: 0, xSpan: 12, ySpan: 20 },
          },
        },
      ],
      panels: {
        filters: { collapsed: true },
        edit: { collapsed: true },
      },
    }),
    [],
  );

  const setPage = useCallback((pageId: string) => {
    const api = studioRef.current!.api;
    api.setState({ ...api.getState(), selectedPageId: pageId });
  }, []);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <div className="example-controls">
          <div className="controls-row">
            <button onClick={() => setPage("by-year")}>By Year</button>
            <button onClick={() => setPage("by-month")}>By Month</button>
            <button onClick={() => setPage("seasonality")}>Seasonality</button>
          </div>
        </div>

        <AgStudio
          ref={studioRef}
          style={studioStyle}
          className="my-studio-container"
          data={data}
          initialState={initialState}
          mode={mode}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <StudioExample />
  </StrictMode>,
);
```

[Live example: Time Data](https://www.ag-grid.com/studio/examples/calendars/time-data-example/reactFunctionalTs/)

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

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

| Fragment | Category | Default format | Notes |
| --- | --- | --- | --- |
| `year` | chronological | `yyyy` |  |
| `halfYear` | chronological | `H1 yyyy` | H1 = Jan - Jun, H2 = Jul - Dec |
| `quarter` | chronological | `"Q"qq yyyy` |  |
| `month` | chronological | `mmm yyyy` |  |
| `week` | chronological | `"W"ww yyyy` | ISO week (Mon start) |
| `day` | chronological | `dd/mm/yyyy` (or locale equivalent) |  |
| `hour` | chronological | `dd/mm/yyyy hh:00` (or locale equivalent) | Datetime columns only |
| `minute` | chronological | `dd/mm/yyyy hh:mm` (or locale equivalent) | Datetime columns only |
| `monthOfYear` | cyclic | `mmmm` | Renders as month name (January - December) |
| `dayOfWeek` | cyclic | `ddd` | ISO (1 = Mon) |
| `monthOfQuarter` | cyclic | `0` | 1 - 3 |
| `weekOfMonth` | cyclic | `0` | 1 - 5 |
| `halfOfYear` | cyclic | `H1` | H1 or H2, all years folded together |
| `hourOfDay` | cyclic | `0` | 0 - 23, datetime columns only |

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

```typescript
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.

#### All Fragments

```tsx
"use client";

import React, {
  useCallback,
  useMemo,
  useRef,
  useState,
  StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgStudio, AgStudioRef } from "ag-studio-react";
import {
  AgDataEngine,
  AgDataSourcesDefinition,
  AgReportState,
  AgStudioApi,
  AgStudioMode,
  AgStudioProperties,
} from "ag-studio";
import { getData } from "./data.tsx";

const StudioExample = () => {
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const [data, setData] = useState<AgDataSource>(getData());
  const initialState = useMemo<AgReportState>(() => {
    return {
      selectedPageId: "by-year",
      pages: [
        {
          id: "by-year",
          widgets: {
            "year-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "calendar::year" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              format: { title: { enabled: true, text: "Revenue by Year" } },
            },
            "year-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "calendar::year" },
                  { id: "sales.amount", aggregation: "sum" },
                  { id: "sales.amount", aggregation: "count" },
                  { id: "sales.amount", aggregation: "avg" },
                ],
              },
              sort: [{ field: { id: "calendar::year" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "year-chart": { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 20 },
            "year-grid": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 20 },
          },
        },
      ],
      panels: {
        filters: {
          collapsed: true,
        },
        edit: {
          collapsed: true,
        },
      },
    };
  }, []);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <AgStudio
          style={studioStyle}
          className="my-studio-container"
          data={data}
          initialState={initialState}
          mode={"edit"}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <StudioExample />
  </StrictMode>,
);
```

[Live example: All Fragments](https://www.ag-grid.com/studio/examples/calendars/all-fragments/reactFunctionalTs/)

## Custom Bucket Types

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 `id`s in a calendar's `fragments` array like any built-in fragment.

```typescript
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`:

```typescript
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`:

```typescript
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.

#### Custom Bucket Types

```tsx
"use client";

import type {
  AgDataSourcesDefinition,
  AgFieldDefinition,
  AgRelationDefinition,
  AgReportState,
  AgStudioMode,
} from "ag-studio";
import { createBuckets } from "ag-studio";
import type { AgStudioRef } from "ag-studio-react";
import { AgStudio } from "ag-studio-react";
import React, { StrictMode, useCallback, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";

function buildSalesData() {
  const rows: { order_date: string; amount: number }[] = [];
  let rng = 7;
  const rand = () => {
    rng = (rng * 1664525 + 1013904223) & 0x7fffffff;
    return rng / 0x7fffffff;
  };

  // Deterministic, literal-bounded range.
  for (let year = 2022; year <= 2024; ++year) {
    for (let month = 1; month <= 12; ++month) {
      const daysInMonth = new Date(year, month, 0).getDate();
      for (let day = 1; day <= daysInMonth; day += 3) {
        const mm = String(month).padStart(2, "0");
        const dd = String(day).padStart(2, "0");
        rows.push({
          order_date: `${year}-${mm}-${dd}`,
          amount: Math.round(100 + rand() * 400),
        });
      }
    }
  }
  return rows;
}

// Biweekly pay periods anchor to a fixed reference date rather than any calendar boundary.
const PAY_PERIOD_EPOCH_DAY = Math.floor(Date.UTC(2022, 0, 3) / 86_400_000);
const MS_PER_DAY = 86_400_000;

function getData(): AgDataSourcesDefinition {
  return {
    sources: [
      {
        id: "sales",
        name: "Sales",
        data: buildSalesData(),
        fields: [
          {
            id: "order_date",
            name: "Order Date",
            format: "dateFormat",
          } satisfies AgFieldDefinition,
          {
            id: "amount",
            name: "Revenue",
            format: "currencyFormat",
          } satisfies AgFieldDefinition,
        ],
      },
    ],
    relationships: [
      {
        id: "sales-payroll-calendar",
        source: { tableId: "sales", fieldId: "order_date" },
        target: { calendarId: "payroll" },
      },
    ] satisfies AgRelationDefinition[],
    calendars: [
      {
        id: "payroll",
        label: "Payroll Calendar",
        range: {
          from: { type: "date", value: "2020-01-01" },
          to: { type: "date", value: "2030-12-31" },
        },
        // One built-in fragment id and one custom fragment id, side by side.
        fragments: ["year", "payPeriod"],
      },
    ],
    // Registers `payPeriod` alongside the built-in bucket vocabulary.
    buckets: createBuckets({
      additionalTypes: [
        {
          id: "payPeriod",
          longName: "Pay Period",
          shortName: "pay prd",
          grain: "day",
          // Three components -> `encode`/`decode` are required to produce a
          // composite, sequential integer key.
          components: ["year", "month", "day"],
          // 14-day pay periods counted from a fixed reference date - they don't
          // align to any calendar boundary, so no built-in fragment fits.
          encode: ([year, month, day]) => {
            const dayIndex = Math.floor(
              Date.UTC(year, month - 1, day) / MS_PER_DAY,
            );
            return Math.floor((dayIndex - PAY_PERIOD_EPOCH_DAY) / 14);
          },
          decode: (payPeriod) => {
            const dayIndex = PAY_PERIOD_EPOCH_DAY + payPeriod * 14;
            const date = new Date(dayIndex * MS_PER_DAY);
            return [
              date.getUTCFullYear(),
              date.getUTCMonth() + 1,
              date.getUTCDate(),
            ];
          },
          format: () => (payPeriod: number) => `Pay Period ${payPeriod}`,
        },
      ],
    }),
  };
}

const StudioExample = () => {
  const studioRef = useRef<AgStudioRef>(null);
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const data = useMemo(() => getData(), []);
  const mode = useMemo<AgStudioMode>(() => "edit", []);
  const initialState = useMemo<AgReportState>(
    () => ({
      selectedPageId: "calendar-year",
      pages: [
        // Page 1: built-in fragment - one row per calendar year.
        {
          id: "calendar-year",
          widgets: {
            "calendar-year-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "payroll::year" },
                  { id: "sales.amount", aggregation: "sum" },
                ],
              },
              sort: [{ field: { id: "payroll::year" }, direction: "asc" }],
              format: {
                title: { enabled: true, text: "Revenue by Calendar Year" },
              },
            },
          },
          widgetLayout: {
            "calendar-year-grid": {
              xTrack: 0,
              yTrack: 0,
              xSpan: 24,
              ySpan: 20,
            },
          },
        },
        // Page 2: custom fragment registered via `createBuckets({ additionalTypes: [...] })`.
        {
          id: "pay-period",
          widgets: {
            "pay-period-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "payroll::payPeriod" },
                  { id: "sales.amount", aggregation: "sum" },
                ],
              },
              sort: [{ field: { id: "payroll::payPeriod" }, direction: "asc" }],
              format: {
                title: { enabled: true, text: "Revenue by 14-Day Pay Period" },
              },
            },
            "pay-period-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "payroll::payPeriod" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              sort: [{ field: { id: "payroll::payPeriod" }, direction: "asc" }],
              format: {
                title: { enabled: true, text: "Revenue by 14-Day Pay Period" },
              },
            },
          },
          widgetLayout: {
            "pay-period-grid": { xTrack: 0, yTrack: 0, xSpan: 10, ySpan: 20 },
            "pay-period-chart": { xTrack: 10, yTrack: 0, xSpan: 14, ySpan: 20 },
          },
        },
      ],
      panels: {
        filters: { collapsed: true },
        edit: { collapsed: true },
      },
    }),
    [],
  );

  const setPage = useCallback((pageId: string) => {
    const api = studioRef.current!.api;
    api.setState({ ...api.getState(), selectedPageId: pageId });
  }, []);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <div className="example-controls">
          <div className="controls-row">
            <button onClick={() => setPage("calendar-year")}>
              Calendar Year (built-in)
            </button>
            <button onClick={() => setPage("pay-period")}>
              Pay Period (custom bucket)
            </button>
          </div>
        </div>

        <AgStudio
          ref={studioRef}
          style={studioStyle}
          className="my-studio-container"
          data={data}
          initialState={initialState}
          mode={mode}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <StudioExample />
  </StrictMode>,
);
```

[Live example: Custom Bucket Types](https://www.ag-grid.com/studio/examples/calendars/custom-bucket-example/reactFunctionalTs/)

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

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:

```typescript
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:

```typescript
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

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`:

```typescript
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

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`:

```typescript
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.

#### Adopted Calendar

```tsx
"use client";

import type {
  AgDataSourcesDefinition,
  AgFieldDefinition,
  AgRelationDefinition,
  AgReportState,
  AgStudioMode,
} from "ag-studio";
import type { AgStudioRef } from "ag-studio-react";
import { AgStudio } from "ag-studio-react";
import React, { StrictMode, useCallback, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";

// Simulated pre-built date table - the kind you might already have from a BI tool.
// The table owner controls every column; Studio adopts it as-is.
//
// This table stores a packed integer `yyyymmdd` (e.g. 20220401) as a warehouse key
// alongside a raw month-of-year column. The calendar uses an accessor to unpack
// year and month from `yyyymmdd`, and reads month_of_year directly.
function buildDimDate() {
  const rows: { date_key: string; yyyymmdd: number; month_of_year: number }[] =
    [];
  for (let year = 2022; year <= 2024; ++year) {
    for (let month = 1; month <= 12; ++month) {
      const mm = String(month).padStart(2, "0");
      rows.push({
        date_key: `${year}-${mm}-01`,
        yyyymmdd: year * 10000 + month * 100 + 1, // e.g. 20220401
        month_of_year: month,
      });
    }
  }
  return rows;
}

// Sales data with a seasonal pattern: Q4 peaks, summer trough.
function buildSalesData() {
  const SEASONAL = [
    0.7, 0.75, 0.85, 0.9, 0.95, 0.8, 0.75, 0.8, 0.9, 1.0, 1.2, 1.4,
  ];
  let rng = 42;
  const rand = () => {
    rng = (rng * 1664525 + 1013904223) & 0x7fffffff;
    return rng / 0x7fffffff;
  };

  const rows: { order_date: string; amount: number; category: string }[] = [];
  const categories = ["Hardware", "Software", "Services"];

  for (let year = 2022; year <= 2024; ++year) {
    for (let month = 1; month <= 12; ++month) {
      const mm = String(month).padStart(2, "0");
      const base = 5000 * SEASONAL[month - 1] * (1 + (year - 2022) * 0.15);
      for (const category of categories) {
        rows.push({
          order_date: `${year}-${mm}-01`,
          amount: Math.round(base + rand() * 1000),
          category,
        });
      }
    }
  }
  return rows;
}

function getData(): AgDataSourcesDefinition {
  return {
    sources: [
      {
        id: "dimDate",
        name: "Date Table",
        data: buildDimDate(),
        fields: [
          {
            id: "date_key",
            name: "Date Key",
            format: "dateFormat",
          } satisfies AgFieldDefinition,
          {
            id: "yyyymmdd",
            name: "YYYYMMDD",
            format: "integerFormat",
          } satisfies AgFieldDefinition,
          {
            id: "month_of_year",
            name: "Month of Year",
            format: "integerFormat",
          } satisfies AgFieldDefinition,
        ],
      },
      {
        id: "sales",
        name: "Sales",
        data: buildSalesData(),
        fields: [
          {
            id: "order_date",
            name: "Order Date",
            format: "dateFormat",
          } satisfies AgFieldDefinition,
          {
            id: "amount",
            name: "Revenue",
            format: "currencyFormat",
          } satisfies AgFieldDefinition,
          {
            id: "category",
            name: "Category",
            format: "textFormat",
            cardinality: "low",
          } satisfies AgFieldDefinition,
        ],
      },
    ],
    relationships: [
      {
        id: "sales-dimdate",
        source: { tableId: "sales", fieldId: "order_date" },
        target: { calendarId: "calendar" },
      },
    ] satisfies AgRelationDefinition[],
    calendars: [
      {
        id: "calendar",
        label: "Calendar",
        range: {
          sourceId: "dimDate",
          mappings: [
            // Packed integer (e.g. 20220401) → year + month via accessor.
            // The accessor unpacks both components from a single column, avoiding
            // the need for separate year_num / year_month columns.
            {
              fieldId: "yyyymmdd",
              components: ["year", "month"],
              accessor: (row: any) => [
                Math.floor(row.yyyymmdd / 10000),
                Math.floor((row.yyyymmdd % 10000) / 100),
              ],
            },
            // Direct column: month_of_year is already a plain integer (1-12).
            { fieldId: "month_of_year", components: ["monthOfYear"] },
          ],
        },
      },
    ],
  };
}

const StudioExample = () => {
  const studioRef = useRef<AgStudioRef>(null);
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const data = useMemo(() => getData(), []);
  const mode = useMemo<AgStudioMode>(() => "view", []);
  const initialState = useMemo<AgReportState>(
    () => ({
      selectedPageId: "by-year",
      pages: [
        // Page 1: Annual trend - calendar::year (chronological, unique per year)
        {
          id: "by-year",
          widgets: {
            "year-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "calendar::year" }],
                legendKey: [{ id: "sales.category" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              format: { title: { enabled: true, text: "Revenue by Year" } },
            },
            "year-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "calendar::year" },
                  { id: "sales.amount", aggregation: "sum" },
                ],
              },
              sort: [{ field: { id: "calendar::year" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "year-chart": { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 20 },
            "year-grid": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 20 },
          },
        },

        // Page 2: Monthly trend - calendar::month (chronological, unique per calendar month)
        {
          id: "by-month",
          widgets: {
            "month-chart": {
              type: "line-chart",
              dataMapping: {
                categoryKey: [{ id: "calendar::month" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              format: { title: { enabled: true, text: "Revenue by Month" } },
            },
            "month-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "calendar::month" },
                  { id: "sales.amount", aggregation: "sum" },
                ],
              },
              sort: [{ field: { id: "calendar::month" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "month-chart": { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 20 },
            "month-grid": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 20 },
          },
        },

        // Page 3: Seasonality - calendar::monthOfYear (cyclic, folds all years together)
        {
          id: "seasonality",
          widgets: {
            "moy-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "calendar::monthOfYear" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              sort: [
                { field: { id: "calendar::monthOfYear" }, direction: "asc" },
              ],
              format: {
                title: {
                  enabled: true,
                  text: "Revenue by Month of Year (all years combined)",
                },
              },
            },
          },
          widgetLayout: {
            "moy-chart": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 20 },
          },
        },

        // Page 4: Raw date table - shows the dimDate source columns directly
        {
          id: "date-table",
          widgets: {
            "dimdate-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "dimDate.date_key" },
                  { id: "dimDate.yyyymmdd" },
                  { id: "dimDate.month_of_year" },
                ],
              },
              sort: [{ field: { id: "dimDate.date_key" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "dimdate-grid": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 20 },
          },
        },
      ],
      panels: {
        filters: { collapsed: true },
      },
    }),
    [],
  );

  const setPage = useCallback((pageId: string) => {
    const api = studioRef.current!.api;
    api.setState({ ...api.getState(), selectedPageId: pageId });
  }, []);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <div className="example-controls">
          <div className="controls-row">
            <button onClick={() => setPage("by-year")}>By Year</button>
            <button onClick={() => setPage("by-month")}>By Month</button>
            <button onClick={() => setPage("seasonality")}>Seasonality</button>
            <button onClick={() => setPage("date-table")}>Date Table</button>
          </div>
        </div>

        <AgStudio
          ref={studioRef}
          style={studioStyle}
          className="my-studio-container"
          data={data}
          initialState={initialState}
          mode={mode}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <StudioExample />
  </StrictMode>,
);
```

[Live example: Adopted Calendar](https://www.ag-grid.com/studio/examples/calendars/adopted-calendar-example/reactFunctionalTs/)

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:

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

#### String Calendar

```tsx
"use client";

import type {
  AgDataSourcesDefinition,
  AgFieldDefinition,
  AgRelationDefinition,
  AgReportState,
  AgStudioMode,
} from "ag-studio";
import type { AgStudioRef } from "ag-studio-react";
import { AgStudio } from "ag-studio-react";
import React, { StrictMode, useCallback, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";

// Simulated pre-built date table where date fields are stored as "YYYY-MM" strings -
// a common format in spreadsheet exports and some SQL warehouses.
function buildDimDate() {
  const rows: { year_month: string; month_of_year: number }[] = [];
  for (let year = 2022; year <= 2024; ++year) {
    for (let month = 1; month <= 12; ++month) {
      rows.push({
        year_month: `${year}-${String(month).padStart(2, "0")}`,
        month_of_year: month,
      });
    }
  }
  return rows;
}

function buildSalesData() {
  const SEASONAL = [
    0.7, 0.75, 0.85, 0.9, 0.95, 0.8, 0.75, 0.8, 0.9, 1.0, 1.2, 1.4,
  ];
  let rng = 42;
  const rand = () => {
    rng = (rng * 1664525 + 1013904223) & 0x7fffffff;
    return rng / 0x7fffffff;
  };

  const rows: { order_date: string; amount: number; category: string }[] = [];
  const categories = ["Hardware", "Software", "Services"];

  for (let year = 2022; year <= 2024; ++year) {
    for (let month = 1; month <= 12; ++month) {
      const mm = String(month).padStart(2, "0");
      const base = 5000 * SEASONAL[month - 1] * (1 + (year - 2022) * 0.15);
      for (const category of categories) {
        rows.push({
          order_date: `${year}-${mm}-01`,
          amount: Math.round(base + rand() * 1000),
          category,
        });
      }
    }
  }
  return rows;
}

function getData(): AgDataSourcesDefinition {
  return {
    sources: [
      {
        id: "dimDate",
        name: "Date Table",
        data: buildDimDate(),
        fields: [
          {
            id: "year_month",
            name: "Year-Month",
            format: "textFormat",
          } satisfies AgFieldDefinition,
          {
            id: "month_of_year",
            name: "Month of Year",
            format: "integerFormat",
          } satisfies AgFieldDefinition,
        ],
      },
      {
        id: "sales",
        name: "Sales",
        data: buildSalesData(),
        fields: [
          {
            id: "order_date",
            name: "Order Date",
            format: "dateFormat",
          } satisfies AgFieldDefinition,
          {
            id: "amount",
            name: "Revenue",
            format: "currencyFormat",
          } satisfies AgFieldDefinition,
          {
            id: "category",
            name: "Category",
            format: "textFormat",
            cardinality: "low",
          } satisfies AgFieldDefinition,
        ],
      },
    ],
    relationships: [
      {
        id: "sales-dimdate",
        source: { tableId: "sales", fieldId: "order_date" },
        target: { calendarId: "calendar" },
      },
    ] satisfies AgRelationDefinition[],
    calendars: [
      {
        id: "calendar",
        label: "Calendar",
        range: {
          sourceId: "dimDate",
          mappings: [
            // String "YYYY-MM" → year + month via split/parseInt.
            {
              fieldId: "year_month",
              components: ["year", "month"],
              accessor: (row: any) => {
                const [y, m] = (row.year_month as string).split("-");
                return [parseInt(y, 10), parseInt(m, 10)];
              },
            },
            // Direct column: month_of_year is already a plain integer (1-12).
            { fieldId: "month_of_year", components: ["monthOfYear"] },
          ],
        },
      },
    ],
  };
}

const StudioExample = () => {
  const studioRef = useRef<AgStudioRef>(null);
  const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
  const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
  const data = useMemo(() => getData(), []);
  const mode = useMemo<AgStudioMode>(() => "view", []);
  const initialState = useMemo<AgReportState>(
    () => ({
      selectedPageId: "by-year",
      pages: [
        {
          id: "by-year",
          widgets: {
            "year-chart": {
              type: "bar-chart-grouped",
              dataMapping: {
                categoryKey: [{ id: "calendar::year" }],
                legendKey: [{ id: "sales.category" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              format: { title: { enabled: true, text: "Revenue by Year" } },
            },
            "year-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "calendar::year" },
                  { id: "sales.amount", aggregation: "sum" },
                ],
              },
              sort: [{ field: { id: "calendar::year" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "year-chart": { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 20 },
            "year-grid": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 20 },
          },
        },
        {
          id: "by-month",
          widgets: {
            "month-chart": {
              type: "line-chart",
              dataMapping: {
                categoryKey: [{ id: "calendar::month" }],
                valueKey: [{ id: "sales.amount", aggregation: "sum" }],
                tooltipKey: [],
              },
              format: { title: { enabled: true, text: "Revenue by Month" } },
            },
            "month-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "calendar::month" },
                  { id: "sales.amount", aggregation: "sum" },
                ],
              },
              sort: [{ field: { id: "calendar::month" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "month-chart": { xTrack: 0, yTrack: 0, xSpan: 14, ySpan: 20 },
            "month-grid": { xTrack: 14, yTrack: 0, xSpan: 10, ySpan: 20 },
          },
        },
        {
          id: "date-table",
          widgets: {
            "dimdate-grid": {
              type: "grid",
              dataMapping: {
                cols: [
                  { id: "dimDate.year_month" },
                  { id: "dimDate.month_of_year" },
                ],
              },
              sort: [{ field: { id: "dimDate.year_month" }, direction: "asc" }],
            },
          },
          widgetLayout: {
            "dimdate-grid": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 20 },
          },
        },
      ],
      panels: {
        filters: { collapsed: true },
      },
    }),
    [],
  );

  const setPage = useCallback((pageId: string) => {
    const api = studioRef.current!.api;
    api.setState({ ...api.getState(), selectedPageId: pageId });
  }, []);

  return (
    <div style={containerStyle}>
      <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
        <div className="example-controls">
          <div className="controls-row">
            <button onClick={() => setPage("by-year")}>By Year</button>
            <button onClick={() => setPage("by-month")}>By Month</button>
            <button onClick={() => setPage("date-table")}>Date Table</button>
          </div>
        </div>

        <AgStudio
          ref={studioRef}
          style={studioStyle}
          className="my-studio-container"
          data={data}
          initialState={initialState}
          mode={mode}
        />
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root")!);
root.render(
  <StrictMode>
    <StudioExample />
  </StrictMode>,
);
```

[Live example: String Calendar](https://www.ag-grid.com/studio/examples/calendars/string-calendar-example/reactFunctionalTs/)

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.
