openstatus
blockstatus-calendarsource: ok · refreshed
Status Calendar
Monthly calendar view of status history with per-day markers and event hover cards
npx shadcn add "https://openstatus.dev/r/status-calendar.json"free
Where it goes
statscalendar
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
https://openstatus.dev/r/status-types.jsonhttps://openstatus.dev/r/status-i18n.jsonhttps://openstatus.dev/r/status-bar.jsonbuttonhover-cardseparatorskeletonhttps://openstatus.dev/r/use-media-query.json
Files1 · source cached 10/8/2026, 4:07:46 AM
src/components/blocks/status-calendar.tsx
"use client";
import { StatusBarCard } from "@/components/blocks/status-bar";
import { useStatusBlocksLabels } from "@/components/blocks/status-i18n";
import type {
StatusBarData,
StatusEventType,
StatusType,
} from "@/components/blocks/status.types";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import {
type Locale,
addMonths,
format,
isSameDay,
startOfDay,
startOfMonth,
subMonths,
} from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
type FocusEvent,
type ReactNode,
type RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { DayPicker, type DayProps } from "react-day-picker";
type BarEvent = StatusBarData["events"][number];
export interface StatusCalendarMarker {
id: string | number;
/** Day this marker belongs to, in the viewer's local timezone. */
date: Date;
/** Drives the day cell's tinted-fill color (worst-severity wins per day). */
status: Exclude<StatusType, "empty">;
/** Event shape mirrors StatusBarEvent so the popover can render the same row. */
type: StatusEventType;
name: string;
/** Full event range (use the event's true start/end, not the per-day instance). */
from?: Date | null;
to?: Date | null;
isAggregated?: boolean;
href?: string;
}
export interface StatusCalendarProps {
markers: StatusCalendarMarker[];
/** Controlled month. Defaults to the first of the current month. */
month?: Date;
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
/** 0 = Sunday, 1 = Monday. Mockup shows Monday. */
weekStartsOn?: 0 | 1;
/**
* Treat the calendar as status history: render days after the latest event
* (or today, whichever is later) as disabled. Days up to and including that
* boundary — including scheduled future events — stay interactive. Forward
* navigation is still capped at the latest event's month. Defaults to false.
*/
disableFuture?: boolean;
className?: string;
/** Calendar header label on the left side of the chrome. */
title?: ReactNode;
/**
* date-fns Locale used by DayPicker for weekday/month names. Callers should
* pass the locale resolved from their i18n layer; defaults to en-US.
*/
locale?: Locale;
/** Override how a single marker row renders inside the hover card. */
renderMarkerRow?: (marker: StatusCalendarMarker) => ReactNode;
/**
* Restrict which event types drive the calendar (day fills, hover cards, and
* the forward-navigation cap). Markers of any other type are ignored. When
* omitted, all event types are shown.
*/
eventTypes?: StatusEventType[];
}
const SEVERITY_RANK: Record<StatusCalendarMarker["status"], number> = {
error: 3,
degraded: 2,
info: 1,
success: 0,
};
// Hover deepens the same tint rather than swapping to the neutral accent, so
// the severity signal survives the hover state.
const SEVERITY_FILL: Record<StatusCalendarMarker["status"], string> = {
error: "bg-destructive/10 hover:bg-destructive/20 text-destructive",
degraded: "bg-warning/10 hover:bg-warning/20 text-warning",
info: "bg-info/10 hover:bg-info/20 text-info",
success: "bg-success/10 hover:bg-success/20 text-success",
};
function dayKey(date: Date): string {
return format(date, "yyyy-MM-dd");
}
function worstSeverity(
markers: StatusCalendarMarker[],
): StatusCalendarMarker["status"] {
return markers.reduce<StatusCalendarMarker["status"]>((worst, m) => {
return SEVERITY_RANK[m.status] > SEVERITY_RANK[worst] ? m.status : worst;
}, "success");
}
export function StatusCalendar({
markers,
month,
defaultMonth,
onMonthChange,
weekStartsOn = 1,
disableFuture = false,
className,
title,
locale,
renderMarkerRow,
eventTypes,
}: StatusCalendarProps) {
const labels = useStatusBlocksLabels();
const resolvedTitle = title ?? labels.calendarTitle;
const formatMonthYear = useCallback(
(d: Date) => {
// date-fns format reads its global locale (set by DateFnsProvider via
// setDefaultOptions), so this picks up the active language without us
// having to pass it explicitly here.
return format(d, "MMM yyyy", locale ? { locale } : undefined);
},
[locale],
);
const [internalMonth, setInternalMonth] = useState<Date>(() =>
startOfMonth(month ?? defaultMonth ?? new Date()),
);
const currentMonth = month ? startOfMonth(month) : internalMonth;
const setMonth = (next: Date) => {
const normalized = startOfMonth(next);
if (month === undefined) setInternalMonth(normalized);
onMonthChange?.(normalized);
};
// Drop markers whose type the caller opted out of before anything else reads
// them, so excluded types affect neither the fills, the cards, nor nav.
const visibleMarkers = useMemo(() => {
if (!eventTypes) return markers;
const allowed = new Set(eventTypes);
return markers.filter((m) => allowed.has(m.type));
}, [markers, eventTypes]);
const markersByDay = useMemo(() => {
const map = new Map<string, StatusCalendarMarker[]>();
for (const marker of visibleMarkers) {
const key = dayKey(marker.date);
const bucket = map.get(key);
if (bucket) bucket.push(marker);
else map.set(key, [marker]);
}
return map;
}, [visibleMarkers]);
// Cap forward navigation at the latest month that has a marker (or the
// current month, whichever is later). Prevents browsing into empty future
// months when no maintenance/event is scheduled.
const maxMonth = useMemo(() => {
const today = startOfMonth(new Date());
return visibleMarkers.reduce((acc, m) => {
const mm = startOfMonth(m.date);
return mm.getTime() > acc.getTime() ? mm : acc;
}, today);
}, [visibleMarkers]);
const canGoNext = currentMonth.getTime() < maxMonth.getTime();
// Status-history cutoff: disable days strictly after the latest event (or
// today, whichever is later) so the empty trailing future is blocked while
// scheduled future events stay reachable. Null = no blocking.
const maxDay = useMemo(() => {
if (!disableFuture) return null;
const today = startOfDay(new Date());
return visibleMarkers.reduce((acc, m) => {
const d = startOfDay(m.date);
return d.getTime() > acc.getTime() ? d : acc;
}, today);
}, [visibleMarkers, disableFuture]);
// Open state lives at the parent so the popover survives DayPicker remounts.
// Interaction model mirrors `status-bar.tsx` (hover on non-touch, tap pins).
const isTouch = useMediaQuery("(hover: none)");
const containerRef = useRef<HTMLDivElement>(null);
const [activeDayKey, setActiveDayKey] = useState<string | null>(null);
// Via ref, not a `Day` dep: keeps `Day` identity stable so cells don't remount
// every hover. CalendarDay re-renders top-down on setActiveDayKey regardless.
const activeDayKeyRef = useRef(activeDayKey);
useEffect(() => {
activeDayKeyRef.current = activeDayKey;
}, [activeDayKey]);
const [interaction, setInteraction] = useState<
"hover" | "pin" | "focus" | null
>(null);
const interactionRef = useRef(interaction);
useEffect(() => {
interactionRef.current = interaction;
}, [interaction]);
const closeTimerRef = useRef<number | null>(null);
const cancelClose = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const close = useCallback(() => {
cancelClose();
setActiveDayKey(null);
setInteraction(null);
}, [cancelClose]);
// Tap/click pins the card; tapping the pinned day again closes it. This is
// the only open path on touch — Radix HoverCard ignores touch pointers. The
// `interactionRef` lags a tap's focus event, so a focus-then-click on the
// same day still reads "pin" and toggles closed.
const handleClick = useCallback(
(key: string) => {
cancelClose();
setActiveDayKey((prev) => {
if (prev === key && interactionRef.current === "pin") {
setInteraction(null);
return null;
}
setInteraction("pin");
return key;
});
},
[cancelClose],
);
const handleHoverStart = useCallback(
(key: string) => {
if (isTouch) return;
cancelClose();
setActiveDayKey(key);
setInteraction("hover");
},
[isTouch, cancelClose],
);
const handleHoverEnd = useCallback(() => {
if (interactionRef.current !== "hover") return;
closeTimerRef.current = window.setTimeout(() => {
setActiveDayKey(null);
setInteraction(null);
}, 100);
}, []);
const handleFocus = useCallback((key: string) => {
setActiveDayKey(key);
setInteraction("focus");
}, []);
const handleBlur = useCallback(
(e: FocusEvent) => {
const next = e.relatedTarget as Element | null;
// Focus moving into the card (e.g. an event link) shouldn't close it.
if (next?.closest('[data-slot="status-bar-card"]')) return;
close();
},
[close],
);
// Outside pointerdown closes the pinned card. Radix's own dismiss is
// suppressed on touch (see HoverCardContent) and the card is portaled out of
// `containerRef`, so we also keep clicks that land inside the card itself.
useEffect(() => {
if (activeDayKey === null) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Element | null;
if (containerRef.current?.contains(target)) return;
if (target?.closest('[data-slot="status-bar-card"]')) return;
close();
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [activeDayKey, close]);
const renderMarkerRowRef = useRef(renderMarkerRow);
useEffect(() => {
renderMarkerRowRef.current = renderMarkerRow;
}, [renderMarkerRow]);
const Day = useCallback(
(dayProps: DayProps) => (
<CalendarDay
{...dayProps}
markersByDay={markersByDay}
activeDayKeyRef={activeDayKeyRef}
isTouch={isTouch}
onClickDay={handleClick}
onHoverStart={handleHoverStart}
onHoverEnd={handleHoverEnd}
onHoverCardEnter={cancelClose}
onHoverCardLeave={close}
onFocusDay={handleFocus}
onBlurDay={handleBlur}
onClose={close}
renderMarkerRowRef={renderMarkerRowRef}
maxDay={maxDay}
/>
),
[
markersByDay,
isTouch,
handleClick,
handleHoverStart,
handleHoverEnd,
cancelClose,
close,
handleFocus,
handleBlur,
maxDay,
],
);
const dayPickerComponents = useMemo(() => ({ Day }), [Day]);
return (
<div
ref={containerRef}
data-slot="status-calendar"
className={cn(
"bg-card text-card-foreground flex flex-col rounded-lg border",
className,
)}
>
<header className="flex items-center gap-3 px-4 py-3">
<div className="text-sm font-medium">{resolvedTitle}</div>
<div className="text-muted-foreground ml-auto flex items-center gap-1 text-sm">
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Previous month"
onClick={() => setMonth(subMonths(currentMonth, 1))}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-foreground text-center font-mono font-medium tabular-nums">
{formatMonthYear(currentMonth)}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Next month"
disabled={!canGoNext}
onClick={() => setMonth(addMonths(currentMonth, 1))}
>
<ChevronRight className="size-4" />
</Button>
</div>
</header>
<div className="border-t">
<DayPicker
mode="default"
month={currentMonth}
weekStartsOn={weekStartsOn}
showOutsideDays={false}
locale={locale}
className="w-full"
classNames={{
months: "flex flex-col",
month: "flex flex-col w-full",
caption: "hidden",
table: "w-full border-collapse",
head_row: "flex w-full border-b",
head_cell:
"flex-1 text-muted-foreground font-mono font-normal text-[0.7rem] uppercase tracking-wide py-1.5 border-r last:border-r-0",
row: "flex w-full border-b last:border-b-0",
cell: "flex-1 relative p-0 text-center text-sm border-r last:border-r-0 focus-within:relative focus-within:z-20",
day: cn(
"text-foreground/80 inline-flex h-12 w-full items-center justify-center text-sm font-normal transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
),
day_today: "text-foreground font-semibold",
day_outside: "text-muted-foreground/50",
day_disabled: "text-muted-foreground/40 cursor-not-allowed",
day_hidden: "invisible",
}}
components={dayPickerComponents}
/>
</div>
</div>
);
}
interface CalendarDayProps extends DayProps {
markersByDay: Map<string, StatusCalendarMarker[]>;
activeDayKeyRef: RefObject<string | null>;
isTouch: boolean;
onClickDay: (key: string) => void;
onHoverStart: (key: string) => void;
onHoverEnd: () => void;
onHoverCardEnter: () => void;
onHoverCardLeave: () => void;
onFocusDay: (key: string) => void;
onBlurDay: (e: FocusEvent) => void;
onClose: () => void;
renderMarkerRowRef: RefObject<
((marker: StatusCalendarMarker) => ReactNode) | undefined
>;
/** Days strictly after this are disabled; null disables blocking entirely. */
maxDay: Date | null;
}
const CalendarDay = forwardRef<HTMLElement, CalendarDayProps>(
function CalendarDay(
{
date,
displayMonth,
markersByDay,
activeDayKeyRef,
isTouch,
onClickDay,
onHoverStart,
onHoverEnd,
onHoverCardEnter,
onHoverCardLeave,
onFocusDay,
onBlurDay,
onClose,
renderMarkerRowRef,
maxDay,
},
forwardedRef,
) {
// With `mode="default"`, useDayRender returns `isButton: false` for every
// day — DayPicker only generates click handlers under a selection mode.
// We don't need DayPicker's interactivity (we drive our own hover popover),
// so we skip useDayRender entirely and render our own trigger.
const thisDayKey = dayKey(startOfDay(date));
const isOutside =
startOfMonth(date).getTime() !== startOfMonth(displayMonth).getTime();
// Overriding `components.Day` bypasses DayPicker's own hide-outside-days
// logic — we have to drop these cells ourselves so the grid only shows the
// current month.
if (isOutside) {
return (
<div data-day-state="outside" className="bg-muted/30 h-12 w-full" />
);
}
// Past the status-history cutoff: render a dimmed, non-interactive cell.
const isBlocked =
maxDay !== null && startOfDay(date).getTime() > maxDay.getTime();
if (isBlocked) {
return (
<div
ref={forwardedRef as RefObject<HTMLDivElement>}
data-day={thisDayKey}
data-day-state="disabled"
aria-disabled
tabIndex={-1}
className="text-muted-foreground/40 inline-flex h-12 w-full items-center justify-center font-mono text-sm"
>
{format(date, "d")}
</div>
);
}
const isToday = isSameDay(date, new Date());
const dayMarkers = markersByDay.get(thisDayKey) ?? [];
const severity =
dayMarkers.length > 0 ? worstSeverity(dayMarkers) : undefined;
const open = activeDayKeyRef.current === thisDayKey;
const hasMarkers = dayMarkers.length > 0;
// Tinted-field: severity days get a soft fill that owns the hover state,
// empty days fall back to the neutral accent hover.
const dayClass = cn(
"text-foreground/80 inline-flex h-12 w-full items-center justify-center font-mono text-sm font-normal transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
isToday && "text-foreground font-semibold",
severity
? SEVERITY_FILL[severity]
: "hover:bg-accent hover:text-accent-foreground",
hasMarkers && "cursor-pointer",
);
// Days without events render a plain trigger — no HoverCard, no popover.
if (!hasMarkers) {
return (
<div
ref={forwardedRef as RefObject<HTMLDivElement>}
data-day={thisDayKey}
data-day-state="empty"
tabIndex={-1}
className={dayClass}
>
{format(date, "d")}
</div>
);
}
const barItem: StatusBarData = {
day: format(date, "yyyy-MM-dd"),
bar: [],
card: [],
events: dayMarkers.map(markerToBarEvent),
};
const indexByEventId = new Map<BarEvent["id"], number>(
barItem.events.map((e, i) => [e.id, i]),
);
return (
// No `onOpenChange`: open state is fully controlled by our handlers (see
// parent), matching `status-bar.tsx`. Radix's hover/dismiss listeners fire
// into a no-op, so they can't fight the pin/toggle model.
<HoverCard openDelay={0} closeDelay={0} open={open}>
<HoverCardTrigger asChild>
<button
type="button"
ref={forwardedRef as RefObject<HTMLButtonElement>}
data-day={thisDayKey}
data-day-state="interactive"
aria-pressed={open}
className={dayClass}
onClick={() => onClickDay(thisDayKey)}
onMouseEnter={() => onHoverStart(thisDayKey)}
onMouseLeave={onHoverEnd}
onFocus={() => onFocusDay(thisDayKey)}
onBlur={onBlurDay}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
{format(date, "d")}
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="w-auto min-w-40 p-0"
onMouseEnter={onHoverCardEnter}
onMouseLeave={onHoverCardLeave}
// On touch, the opening tap's emulated pointer sequence would trip
// Radix's dismiss-on-outside and close the card immediately. Suppress
// it here; the parent's document listener handles real outside taps.
onPointerDownOutside={(e) => {
if (isTouch) e.preventDefault();
}}
>
<StatusBarCard
item={barItem}
renderEvent={(event, eventIndex) => {
const fn = renderMarkerRowRef.current;
if (!fn) return undefined;
const idx = indexByEventId.get(event.id) ?? 0;
return (
<div key={`${event.id}-${event.type}`}>
{eventIndex > 0 && (
<Separator className="-mx-2 my-2 data-[orientation=horizontal]:w-auto" />
)}
{fn(dayMarkers[idx])}
</div>
);
}}
/>
</HoverCardContent>
</HoverCard>
);
},
);
function markerToBarEvent(m: StatusCalendarMarker): BarEvent {
return {
id: m.id,
name: m.name,
type: m.type,
from: m.from ?? null,
to: m.to ?? null,
isAggregated: m.isAggregated,
};
}
export interface StatusCalendarSkeletonProps {
/** Title shown in the header chrome; falls back to "Calendar" if omitted. */
title?: ReactNode;
className?: string;
}
/** Loading placeholder mirroring `<StatusCalendar>`'s chrome (header + 6×7 grid). */
export function StatusCalendarSkeleton({
title,
className,
}: StatusCalendarSkeletonProps) {
const labels = useStatusBlocksLabels();
return (
<div
data-slot="status-calendar-skeleton"
className={cn(
"bg-card text-card-foreground flex flex-col rounded-lg border",
className,
)}
>
<header className="flex items-center gap-3 px-4 py-3">
<div className="text-sm font-medium">
{title ?? labels.calendarTitle}
</div>
<div className="ml-auto flex items-center gap-1">
<Skeleton className="size-7 rounded-md" />
<Skeleton className="h-4 w-20" />
<Skeleton className="size-7 rounded-md" />
</div>
</header>
<div className="border-t">
<div className="flex w-full border-b">
{Array.from({ length: 7 }).map((_, i) => (
<div
key={`weekday-${i}`}
className="flex flex-1 justify-center border-r py-1.5 last:border-r-0"
>
<Skeleton className="h-3 w-4" />
</div>
))}
</div>
{Array.from({ length: 6 }).map((_, row) => (
<div
key={`row-${row}`}
className="flex w-full border-b last:border-b-0"
>
{Array.from({ length: 7 }).map((_, col) => (
<div
key={`cell-${row}-${col}`}
className="flex h-12 flex-1 items-center justify-center border-r last:border-r-0"
>
<Skeleton className="h-4 w-6" />
</div>
))}
</div>
))}
</div>
</div>
);
}