diff --git a/package.json b/package.json index c70e2e1..4909a1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.40.0", + "version": "0.41.1", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/calendar-heatmap/CalendarHeatmap.stories.tsx b/src/components/ui/calendar-heatmap/CalendarHeatmap.stories.tsx new file mode 100644 index 0000000..28b5fb0 --- /dev/null +++ b/src/components/ui/calendar-heatmap/CalendarHeatmap.stories.tsx @@ -0,0 +1,364 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import type { ReactElement } from "react"; + +import { + CalendarHeatmap, + calendarHeatmapColorIds, + calendarHeatmapSizeIds, + type CalendarHeatmapColorId, + type CalendarHeatmapValue, +} from "./calendar-heatmap"; + +/** + * Deterministic pseudo-random generator so stories render identically across + * reloads — Storybook snapshots and visual review need stable output. + */ +function mulberry32(seed: number): () => number { + let a: number = seed >>> 0; + return (): number => { + a = (a + 0x6d2b79f5) >>> 0; + let t: number = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +interface SyntheticDatasetOptions { + readonly start: string; + readonly end: string; + readonly seed: number; + /** 0..1 chance any given day has activity. */ + readonly density?: number; + /** Max possible count for an active day. */ + readonly maxCount?: number; + /** Optional weekday weighting (Sun..Sat). */ + readonly weekdayWeights?: ReadonlyArray; +} + +function buildSyntheticDataset({ + start, + end, + seed, + density = 0.55, + maxCount = 12, + weekdayWeights, +}: SyntheticDatasetOptions): ReadonlyArray { + const rng = mulberry32(seed); + const startMs: number = Date.UTC( + Number(start.slice(0, 4)), + Number(start.slice(5, 7)) - 1, + Number(start.slice(8, 10)), + ); + const endMs: number = Date.UTC( + Number(end.slice(0, 4)), + Number(end.slice(5, 7)) - 1, + Number(end.slice(8, 10)), + ); + const out: CalendarHeatmapValue[] = []; + for (let t = startMs; t <= endMs; t += 86_400_000) { + const day = new Date(t); + const dow: number = day.getUTCDay(); + const weight: number = weekdayWeights?.[dow] ?? 1; + if (rng() > density * weight) continue; + const count: number = 1 + Math.floor(rng() * maxCount); + out.push({ + date: day.toISOString().slice(0, 10), + count, + }); + } + return out; +} + +const ONE_YEAR_START = "2025-05-12"; +const ONE_YEAR_END = "2026-05-11"; + +const oneYearOfActivity: ReadonlyArray = + buildSyntheticDataset({ + start: ONE_YEAR_START, + end: ONE_YEAR_END, + seed: 42, + density: 0.65, + maxCount: 18, + // Weekday-heavy schedule, lighter on weekends. + weekdayWeights: [0.4, 1.0, 1.2, 1.2, 1.1, 1.0, 0.5], + }); + +const meta = { + title: "Components/CalendarHeatmap", + component: CalendarHeatmap, + parameters: { + layout: "padded", + docs: { + description: { + component: + "A GitHub-style calendar heatmap for visualising daily activity " + + "across a date range. Useful for contribution graphs, audit log " + + "frequency, API usage, login density, and similar time-series " + + "metrics where a single number per day is meaningful. Cell colour " + + "intensity scales across five levels — by default, thresholds are " + + "auto-derived from the dataset's quartiles.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + color: { + options: calendarHeatmapColorIds, + control: { type: "select" }, + }, + size: { + options: calendarHeatmapSizeIds, + control: { type: "radio" }, + }, + weekStart: { + options: [0, 1], + control: { type: "inline-radio" }, + }, + showWeekdayLabels: { control: { type: "boolean" } }, + showMonthLabels: { control: { type: "boolean" } }, + showLegend: { control: { type: "boolean" } }, + label: { control: { type: "text" } }, + }, + args: { + startDate: ONE_YEAR_START, + endDate: ONE_YEAR_END, + values: oneYearOfActivity, + color: "default", + size: "md", + weekStart: 0, + showWeekdayLabels: true, + showMonthLabels: true, + showLegend: true, + label: "Schema activity over the last year", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Primary: Story = { + args: { color: "primary" }, +}; + +export const Positive: Story = { + args: { color: "positive", label: "Successful deploys" }, +}; + +export const Warning: Story = { + args: { + color: "warning", + label: "Elevated API latency days", + values: buildSyntheticDataset({ + start: ONE_YEAR_START, + end: ONE_YEAR_END, + seed: 7, + density: 0.3, + maxCount: 9, + }), + }, +}; + +export const Destructive: Story = { + args: { + color: "destructive", + label: "Failed authentications per day", + values: buildSyntheticDataset({ + start: ONE_YEAR_START, + end: ONE_YEAR_END, + seed: 99, + density: 0.25, + maxCount: 14, + }), + }, +}; + +export const Small: Story = { + args: { size: "sm" }, +}; + +export const Large: Story = { + args: { size: "lg", color: "primary" }, +}; + +export const MondayWeekStart: Story = { + args: { weekStart: 1 }, +}; + +export const NoLabels: Story = { + args: { + showWeekdayLabels: false, + showMonthLabels: false, + showLegend: false, + }, +}; + +export const ShortRange: Story = { + args: { + startDate: "2026-02-01", + endDate: "2026-04-30", + color: "primary", + label: "Q1 2026 deployments", + values: buildSyntheticDataset({ + start: "2026-02-01", + end: "2026-04-30", + seed: 13, + density: 0.7, + maxCount: 10, + }), + }, +}; + +export const Sparse: Story = { + args: { + color: "positive", + label: "Schema migrations", + values: buildSyntheticDataset({ + start: ONE_YEAR_START, + end: ONE_YEAR_END, + seed: 5, + density: 0.06, + maxCount: 4, + }), + }, +}; + +export const WithCustomThresholds: Story = { + args: { + color: "primary", + label: "Pull requests opened", + thresholds: [1, 3, 6, 10], + legendExtra: ( + Thresholds: 1 / 3 / 6 / 10 + ) as unknown as never, + }, +}; + +export const WithCustomTooltip: Story = { + args: { + color: "primary", + label: "API requests per day", + tooltipText: (date, count): string => { + const iso: string = date.toISOString().slice(0, 10); + const formattedCount: string = (count * 1_000).toLocaleString(); + return count > 0 + ? `${iso} — ${formattedCount} API requests` + : `${iso} — quiet day`; + }, + }, +}; + +const COLORS_FOR_MATRIX: ReadonlyArray<{ + id: CalendarHeatmapColorId; + caption: string; +}> = [ + { id: "default", caption: "default (brand blue)" }, + { id: "primary", caption: "primary" }, + { id: "positive", caption: "positive" }, + { id: "warning", caption: "warning" }, + { id: "destructive", caption: "destructive" }, +]; + +function ColorMatrix(): ReactElement { + return ( +
+ {COLORS_FOR_MATRIX.map(({ id, caption }) => ( +
+

+ {caption} +

+ +
+ ))} +
+ ); +} + +export const ColorVariantMatrix: StoryObj = { + render: (): ReactElement => , + parameters: { layout: "padded" }, +}; + +function SizeMatrix(): ReactElement { + return ( +
+ {(["sm", "md", "lg"] as const).map((size) => ( +
+

+ size = {size} +

+ +
+ ))} +
+ ); +} + +export const SizeMatrixStory: StoryObj = { + name: "Size Matrix", + render: (): ReactElement => , + parameters: { layout: "padded" }, +}; + +function DashboardComposition(): ReactElement { + return ( +
+
+
+

Schema activity

+

+ Daily schema mutations across all vaults — last 12 months. +

+
+
+

+ {oneYearOfActivity + .reduce((sum, v) => sum + v.count, 0) + .toLocaleString()} +

+

total events

+
+
+ + {ONE_YEAR_START} → {ONE_YEAR_END} + + } + /> +
+ ); +} + +export const InsideCard: StoryObj = { + render: (): ReactElement => , + parameters: { layout: "padded" }, +}; + +export const Empty: Story = { + args: { + values: [], + color: "primary", + label: "No activity recorded", + }, +}; diff --git a/src/components/ui/calendar-heatmap/calendar-heatmap.tsx b/src/components/ui/calendar-heatmap/calendar-heatmap.tsx new file mode 100644 index 0000000..6d5683b --- /dev/null +++ b/src/components/ui/calendar-heatmap/calendar-heatmap.tsx @@ -0,0 +1,517 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import type { + HTMLAttributes, + ReactElement, + ReactNode, + Ref, +} from "react"; +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; + +export const calendarHeatmapColorIds = [ + "default", + "primary", + "positive", + "warning", + "destructive", +] as const satisfies string[]; +export type CalendarHeatmapColorId = (typeof calendarHeatmapColorIds)[number]; + +export const calendarHeatmapSizeIds = [ + "sm", + "md", + "lg", +] as const satisfies string[]; +export type CalendarHeatmapSizeId = (typeof calendarHeatmapSizeIds)[number]; + +export type CalendarHeatmapWeekStart = 0 | 1; + +export interface CalendarHeatmapValue { + /** The day this value applies to. May be a Date or an ISO `yyyy-mm-dd` string. */ + date: Date | string; + /** Numeric activity level for the day. */ + count: number; +} + +const SIZE_TO_DIMENSIONS: Record< + CalendarHeatmapSizeId, + { cell: number; gap: number; radius: number; fontSize: number } +> = { + sm: { cell: 9, gap: 2, radius: 1.5, fontSize: 9 }, + md: { cell: 12, gap: 3, radius: 2, fontSize: 10 }, + lg: { cell: 16, gap: 4, radius: 3, fontSize: 11 }, +}; + +const calendarHeatmapVariants = cva( + "inline-flex w-fit min-w-0 max-w-full flex-col gap-2 text-foreground", + { + variants: { + size: { + sm: "text-xs", + md: "text-xs", + lg: "text-sm", + } satisfies Record, + }, + defaultVariants: { + size: "md", + }, + }, +); + +type LevelClassMap = Readonly>; + +/** + * Per-color level → Tailwind class lookup. Classes are written in full so that + * Tailwind's content scanner picks them up — never build them via interpolation. + * Level 0 is the empty/no-activity cell. + */ +const COLOR_LEVEL_CLASSES: Record = { + default: { + 0: "bg-muted/40 dark:bg-muted/30", + 1: "bg-schemavaults-brand-blue/20", + 2: "bg-schemavaults-brand-blue/40", + 3: "bg-schemavaults-brand-blue/65", + 4: "bg-schemavaults-brand-blue", + }, + primary: { + 0: "bg-muted/40 dark:bg-muted/30", + 1: "bg-primary/20", + 2: "bg-primary/40", + 3: "bg-primary/65", + 4: "bg-primary", + }, + positive: { + 0: "bg-muted/40 dark:bg-muted/30", + 1: "bg-emerald-500/20 dark:bg-emerald-400/20", + 2: "bg-emerald-500/40 dark:bg-emerald-400/40", + 3: "bg-emerald-500/65 dark:bg-emerald-400/65", + 4: "bg-emerald-500 dark:bg-emerald-400", + }, + warning: { + 0: "bg-muted/40 dark:bg-muted/30", + 1: "bg-warning/20", + 2: "bg-warning/40", + 3: "bg-warning/65", + 4: "bg-warning", + }, + destructive: { + 0: "bg-muted/40 dark:bg-muted/30", + 1: "bg-destructive/20", + 2: "bg-destructive/40", + 3: "bg-destructive/65", + 4: "bg-destructive", + }, +}; + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function parseToUtcDay(input: Date | string): Date { + if (input instanceof Date) { + return new Date( + Date.UTC(input.getFullYear(), input.getMonth(), input.getDate()), + ); + } + if (ISO_DATE_RE.test(input)) { + const [yStr, mStr, dStr] = input.split("-") as [string, string, string]; + return new Date(Date.UTC(Number(yStr), Number(mStr) - 1, Number(dStr))); + } + const parsed = new Date(input); + return new Date( + Date.UTC( + parsed.getUTCFullYear(), + parsed.getUTCMonth(), + parsed.getUTCDate(), + ), + ); +} + +function toIsoDay(date: Date): string { + const y: string = String(date.getUTCFullYear()).padStart(4, "0"); + const m: string = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d: string = String(date.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function addUtcDays(date: Date, n: number): Date { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + n); + return next; +} + +function diffInDaysUtc(a: Date, b: Date): number { + const ms: number = a.getTime() - b.getTime(); + return Math.round(ms / 86_400_000); +} + +function startOfWeekUtc(date: Date, weekStart: CalendarHeatmapWeekStart): Date { + const dow: number = date.getUTCDay(); + const offset: number = (dow - weekStart + 7) % 7; + return addUtcDays(date, -offset); +} + +function defaultThresholds(values: ReadonlyArray): readonly number[] { + if (values.length === 0) return [1, 2, 3, 4] as const; + const positives: number[] = values.filter((v) => v > 0).sort((a, b) => a - b); + if (positives.length === 0) return [1, 2, 3, 4] as const; + const max: number = positives[positives.length - 1]!; + if (max <= 4) { + return [1, 2, 3, 4] as const; + } + // Quartile-based thresholds across positive values. + const q = (p: number): number => { + const idx: number = Math.min( + positives.length - 1, + Math.max(0, Math.floor(p * (positives.length - 1))), + ); + return Math.max(1, Math.ceil(positives[idx]!)); + }; + const t1: number = Math.max(1, q(0.25)); + const t2: number = Math.max(t1 + 1, q(0.5)); + const t3: number = Math.max(t2 + 1, q(0.75)); + const t4: number = Math.max(t3 + 1, max); + return [t1, t2, t3, t4] as const; +} + +function levelForCount( + count: number, + thresholds: ReadonlyArray, +): 0 | 1 | 2 | 3 | 4 { + if (count <= 0) return 0; + if (count >= thresholds[3]!) return 4; + if (count >= thresholds[2]!) return 3; + if (count >= thresholds[1]!) return 2; + if (count >= thresholds[0]!) return 1; + return 1; +} + +const MONTH_LABELS: ReadonlyArray = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const WEEKDAY_LABELS: ReadonlyArray = [ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", +]; + +function defaultTooltipText(date: Date, count: number): string { + const iso: string = toIsoDay(date); + if (count <= 0) return `${iso}: No activity`; + if (count === 1) return `${iso}: 1 contribution`; + return `${iso}: ${count.toLocaleString()} contributions`; +} + +export interface CalendarHeatmapProps + extends Omit, "color">, + VariantProps { + /** First day shown (inclusive). */ + startDate: Date | string; + /** Last day shown (inclusive). */ + endDate: Date | string; + /** Activity values keyed by date. Days not provided are treated as 0. */ + values?: ReadonlyArray; + /** Cell color theme. */ + color?: CalendarHeatmapColorId; + /** First day of the week (0=Sun, 1=Mon). Defaults to Sunday. */ + weekStart?: CalendarHeatmapWeekStart; + /** Show short weekday labels along the left edge. Defaults to true. */ + showWeekdayLabels?: boolean; + /** Show month labels above the grid. Defaults to true. */ + showMonthLabels?: boolean; + /** Show the "Less / More" intensity legend. Defaults to true. */ + showLegend?: boolean; + /** + * Custom thresholds for the four non-zero intensity levels (ascending). + * If omitted, thresholds are inferred from the dataset's quartiles. + */ + thresholds?: ReadonlyArray; + /** Accessible label describing what the heatmap represents. */ + label: string; + /** Override the per-cell tooltip text. */ + tooltipText?: (date: Date, count: number) => string; + /** + * Slot rendered to the right of the legend (e.g. a date range hint). + * Useful for composing footers. + */ + legendExtra?: ReactNode; + ref?: Ref; +} + +interface DayCell { + readonly date: Date; + readonly inRange: boolean; + readonly count: number; + readonly level: 0 | 1 | 2 | 3 | 4; +} + +interface WeekColumn { + readonly days: ReadonlyArray; + /** Month label to show above this column, or null. */ + readonly monthLabel: string | null; +} + +function CalendarHeatmap({ + startDate, + endDate, + values = [], + color = "default", + size = "md", + weekStart = 0, + showWeekdayLabels = true, + showMonthLabels = true, + showLegend = true, + thresholds: thresholdsProp, + label, + tooltipText = defaultTooltipText, + legendExtra, + className, + ref, + ...props +}: CalendarHeatmapProps): ReactElement { + const resolvedSize: CalendarHeatmapSizeId = size ?? "md"; + const dims = SIZE_TO_DIMENSIONS[resolvedSize]; + + const start: Date = parseToUtcDay(startDate); + const end: Date = parseToUtcDay(endDate); + const valueMap: Map = useMemo(() => { + const map = new Map(); + for (const v of values) { + const day: Date = parseToUtcDay(v.date); + map.set(toIsoDay(day), (map.get(toIsoDay(day)) ?? 0) + v.count); + } + return map; + }, [values]); + + const thresholds: ReadonlyArray = useMemo(() => { + if (thresholdsProp && thresholdsProp.length === 4) { + return [...thresholdsProp].sort((a, b) => a - b); + } + return defaultThresholds(Array.from(valueMap.values())); + }, [thresholdsProp, valueMap]); + + const weeks: ReadonlyArray = useMemo(() => { + if (end.getTime() < start.getTime()) return []; + const gridStart: Date = startOfWeekUtc(start, weekStart); + const gridEnd: Date = addUtcDays( + startOfWeekUtc(end, weekStart), + 6, + ); + const totalDays: number = diffInDaysUtc(gridEnd, gridStart) + 1; + const totalWeeks: number = Math.ceil(totalDays / 7); + + let lastMonthSeen: number = -1; + const out: WeekColumn[] = []; + for (let w = 0; w < totalWeeks; w += 1) { + const days: DayCell[] = []; + let firstInRangeMonth: number | null = null; + for (let d = 0; d < 7; d += 1) { + const cellDate: Date = addUtcDays(gridStart, w * 7 + d); + const inRange: boolean = + cellDate.getTime() >= start.getTime() && + cellDate.getTime() <= end.getTime(); + const iso: string = toIsoDay(cellDate); + const count: number = inRange ? (valueMap.get(iso) ?? 0) : 0; + const level: 0 | 1 | 2 | 3 | 4 = inRange + ? levelForCount(count, thresholds) + : 0; + days.push({ date: cellDate, inRange, count, level }); + if (inRange && firstInRangeMonth === null) { + firstInRangeMonth = cellDate.getUTCMonth(); + } + } + let monthLabel: string | null = null; + if (firstInRangeMonth !== null && firstInRangeMonth !== lastMonthSeen) { + monthLabel = MONTH_LABELS[firstInRangeMonth] ?? null; + lastMonthSeen = firstInRangeMonth; + } + out.push({ days, monthLabel }); + } + return out; + }, [start, end, weekStart, valueMap, thresholds]); + + const orderedWeekdayLabels: ReadonlyArray = useMemo(() => { + const out: string[] = []; + for (let i = 0; i < 7; i += 1) { + out.push(WEEKDAY_LABELS[(weekStart + i) % 7]!); + } + return out; + }, [weekStart]); + + const colorLevels: LevelClassMap = COLOR_LEVEL_CLASSES[color]; + + const cellPx: number = dims.cell; + const gapPx: number = dims.gap; + const radiusPx: number = dims.radius; + const monthRowHeight: number = showMonthLabels ? dims.fontSize + 6 : 0; + const weekdayLabelWidth: number = showWeekdayLabels ? 28 : 0; + + return ( +
+
+ {showWeekdayLabels ? ( + + ) : null} + +
+ {weeks.map((week, wIdx) => ( +
+ {showMonthLabels ? ( + + ) : null} + {week.days.map((day, dIdx) => { + if (!day.inRange) { + return ( + +
+ + {showLegend ? ( +
+ {legendExtra ? ( + {legendExtra} + ) : null} + Less +
+ {[0, 1, 2, 3, 4].map((lvl) => ( + + More +
+ ) : null} +
+ ); +} +CalendarHeatmap.displayName = "CalendarHeatmap"; + +export { CalendarHeatmap, calendarHeatmapVariants }; + +export default CalendarHeatmap; diff --git a/src/components/ui/calendar-heatmap/index.ts b/src/components/ui/calendar-heatmap/index.ts new file mode 100644 index 0000000..0017eb2 --- /dev/null +++ b/src/components/ui/calendar-heatmap/index.ts @@ -0,0 +1,15 @@ +export { + CalendarHeatmap, + CalendarHeatmap as default, + calendarHeatmapVariants, + calendarHeatmapColorIds, + calendarHeatmapSizeIds, +} from "./calendar-heatmap"; + +export type { + CalendarHeatmapProps, + CalendarHeatmapValue, + CalendarHeatmapColorId, + CalendarHeatmapSizeId, + CalendarHeatmapWeekStart, +} from "./calendar-heatmap"; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 73a3fb4..4a68ba9 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -218,3 +218,6 @@ export type * from "./aspect-ratio"; export * from "./color-swatch"; export type * from "./color-swatch"; + +export * from "./calendar-heatmap"; +export type * from "./calendar-heatmap";