Pivot Calendar
Calendar component that pivots time-based statistics across four layouts — full month, days of a week, days of a month, or months of a year — with grouping, tooltips, and local or remote aggregation.
Installation
pnpm dlx @docyrus/cli add @docyrus/ui-pivot-calendarpnpm add date-fnsnpx shadcn@latest add button checkbox scroll-area tabs tooltipOverview
PivotCalendar represents statistical totals on top of a calendar surface. It supports up to four measures per cell rendered as a 2×1 or 2×2 micro-grid with hover tooltips, an optional grouping category sidebar (used as filters in the month view, as row axis on the pivot grid views), and four visual layouts:
| View | Layout | Buckets | Use it for |
|---|---|---|---|
month-calendar | Standard month grid | Per day (yyyy-MM-dd) | Glanceable monthly overview |
days-of-week | Pivot grid · groups × days of selected week | Per day (yyyy-MM-dd) | Weekly comparison across categories |
days-of-month | Pivot grid · groups × days of selected month, scrolls horizontally | Per day (yyyy-MM-dd) | Detailed monthly comparison |
months-of-year | Pivot grid · groups × months of selected year | Per month (yyyy-MM) | Long-term yearly trend |
Calculation modes
The mode prop controls where aggregation happens.
Local mode
Pass raw rows. The component buckets and aggregates each measure on the client based on getDate and the optional groupBy.getId:
<PivotCalendar
mode="local"
data={timeEntries}
getDate={row => row.date}
measures={[
{ id: 'planned', label: 'Planned', aggregate: 'sum', getValue: r => r.duration_planned, formatValue: formatHours },
{ id: 'logged', label: 'Logged', aggregate: 'sum', getValue: r => r.duration_logged, formatValue: formatHours }
]}
groupBy={{
id: 'user',
label: 'User',
getId: row => row.user.id,
getGroup: row => ({ id: row.user.id, label: row.user.name, image: { signed_url: row.user.photo } })
}}
/>Remote mode
Pass pre-aggregated cells keyed by bucket and group. The component only renders, never calculates:
<PivotCalendar
mode="remote"
cells={cellsFromServer} // [{ bucket: '2026-05-06', groupId: 'u1', values: [...] }, ...]
measures={measures}
groupBy={{ id: 'user', label: 'User', getId: row => row.user.id }}
groups={users}
/>The bucket key must match the active view: yyyy-MM-dd for day-based views (month-calendar, days-of-week, days-of-month) and yyyy-MM for months-of-year.
Drill down on stat clicks
PivotCalendar exposes an onCellClick callback that fires whenever the user clicks a measure tile (in the calendar month view) or a value cell (in any pivot grid view). Tiles with a zero value stay non-interactive; tiles with a value render as <button> elements with hover / focus / "Click to drill down" tooltip styling whenever a handler is wired.
<PivotCalendar
mode="remote"
cells={cells}
measures={measures}
groupBy={{ id: 'user', label: 'User', getId: row => row.user.id }}
groups={users}
onCellClick={(info) => {
console.log(info.bucket, info.bucketStart, info.bucketEnd);
console.log(info.measure.label, info.value, info.formattedValue);
console.log(info.groupId, info.group?.label);
}}
/>The component itself is intentionally agnostic about what to do with the click — it does not ship a drilldown dialog. Pair it with useDocyrusPivotCalendar and its buildDrilldownQuery helper for Docyrus-backed apps, or build your own dialog around any data source.
IPivotCalendarCellClick
| Field | Type | Description |
|---|---|---|
bucket | string | Bucket key (yyyy-MM-dd or yyyy-MM). |
bucketStart | Date | Inclusive start of the bucket's range. |
bucketEnd | Date | Inclusive end of the bucket's range (end-of-day or end-of-month). |
view | TPivotCalendarView | View active at the time of the click. |
groupId | string | null | Group id when groupBy is configured. null for the "Totals" row or ungrouped totals. |
group | IPivotCalendarGroup | undefined | Resolved group entry when available. |
measure | IPivotCalendarMeasure<TData> | Measure descriptor for the clicked tile. |
value | number | Raw aggregated value. |
formattedValue | string | Pre-formatted display value (e.g. 1.5h). |
Usage
import {
PivotCalendar,
type IPivotCalendarMeasure,
type IPivotCalendarRemoteCell,
type TPivotCalendarMode
} from '@docyrus/ui/components/pivot-calendar';
const measures: Array<IPivotCalendarMeasure<TimeEntry>> = [
{ id: 'planned', label: 'Planned', aggregate: 'sum', getValue: r => r.duration_planned, formatValue: s => `${(s/3600).toFixed(1)}h` },
{ id: 'logged', label: 'Logged', aggregate: 'sum', getValue: r => r.duration_logged, formatValue: s => `${(s/3600).toFixed(1)}h` }
];
<PivotCalendar
mode="local"
data={data}
getDate={row => row.date}
measures={measures}
groupBy={{ id: 'user', label: 'User', getId: row => row.user.id }}
groups={users}
defaultView="month-calendar"
/>Sizes
| Size | Description |
|---|---|
sm | Compact — min-h-[480px] |
default | Default — min-h-[600px] |
lg | Tall — min-h-[720px] |
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'local' | 'remote' | 'local' | Calculation mode. |
data | Array<TData> | — | Raw rows. Required when mode='local'. |
cells | Array<IPivotCalendarRemoteCell> | — | Pre-aggregated cells. Required when mode='remote'. |
getDate | (row: TData) => Date | string | — | Reads the date for a row. Required when mode='local'. |
measures | Array<IPivotCalendarMeasure<TData>> | — | Measures to display. Up to 4 are rendered per cell. |
groupBy | IPivotCalendarGroupBy<TData> | — | Optional grouping category. Enables the side panel + pivot row axis. |
groups | Array<IPivotCalendarGroup> | — | Group catalog (required for mode='remote' with groupBy). |
defaultView | TPivotCalendarView | 'month-calendar' | Initial view. |
view | TPivotCalendarView | — | Controlled view value. |
onViewChange | (view: TPivotCalendarView) => void | — | Called when view changes. |
defaultDate | Date | new Date() | Initial reference date. |
date | Date | — | Controlled reference date. |
onDateChange | (date: Date) => void | — | Called when reference date changes. |
defaultSelectedGroupIds | Array<string> | All | Initial selected groups. |
selectedGroupIds | Array<string> | — | Controlled group selection. |
onSelectedGroupIdsChange | (ids: Array<string>) => void | — | Called when group selection changes. |
visibleViews | Array<TPivotCalendarView> | All | Views shown in the toolbar tabs. |
hideViewSwitcher | boolean | false | Hide the view switcher tabs. |
hideSidebar | boolean | false | Hide the group side panel (only used by month-calendar). |
title | ReactNode | range label | Custom toolbar title. |
onCellClick | (info: IPivotCalendarCellClick<TData>) => void | — | Fires when the user clicks a stat tile or pivot value cell. Tiles become interactive (hover ring + focus outline + drill-down hint) only when this prop is set. |
size | 'sm' | 'default' | 'lg' | 'default' | Min height variant. |
className | string | — | Extra wrapper classes. |
Components
| Component | Description |
|---|---|
PivotCalendar | Public root component |
pivotCalendarVariants | CVA helper for the wrapper container |
usePivotCalendarController | Standalone controller hook (advanced compositions) |
Type Exports
| Type | Description |
|---|---|
PivotCalendarProps<TData> | Component prop shape |
PivotCalendarRootProps<TData> | Root component (props + size variant) |
IPivotCalendarController<TData> | Controller returned by the hook |
IPivotCalendarMeasure<TData> | Measure descriptor |
IPivotCalendarMeasureValue | A measure result { measureId, value } |
IPivotCalendarGroup | Group entry used in the sidebar / row axis |
IPivotCalendarGroupBy<TData> | Grouping dimension descriptor |
IPivotCalendarRemoteCell | Pre-aggregated cell { bucket, groupId?, values } |
IPivotCalendarCellClick<TData> | Payload emitted by onCellClick when a stat tile is clicked |
TPivotCalendarView | View id |
TPivotCalendarMode | 'local' | 'remote' |
TPivotCalendarAggregate | 'sum' | 'count' | 'avg' | 'min' | 'max' |
TPivotCalendarBucketKind | 'day' | 'weekday' | 'month' |
Type Reference
IPivotCalendarMeasure
| Field | Type | Description |
|---|---|---|
id | string | Unique measure id |
label | string | Tooltip label |
shortLabel | string | Compact label shown in the cell (defaults to label) |
aggregate | TPivotCalendarAggregate | Aggregation strategy used in local mode |
getValue | (row: TData) => number | null | undefined | Reads the numeric value from a row |
formatValue | (value: number) => string | Formats the displayed value (e.g. 1.5h) |
color | string | CSS color of the dot accent |
description | string | Extra context shown in the tooltip |
IPivotCalendarRemoteCell
| Field | Type | Description |
|---|---|---|
bucket | string | yyyy-MM-dd (day views) or yyyy-MM (month view) |
groupId | string | null | Group id when groupBy is configured |
values | Array<IPivotCalendarMeasureValue> | One entry per measure |
TPivotCalendarView
'month-calendar' \| 'days-of-week' \| 'days-of-month' \| 'months-of-year'