From 4ec9cfd1ed70f50f7f68df030ef158300976bbff Mon Sep 17 00:00:00 2001 From: Maxim Kazantsev Date: Fri, 27 Feb 2026 22:58:18 -0800 Subject: [PATCH 1/5] feat: add day-calendar component for AI event scheduling visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only DayCalendar compound component that renders a single-day time grid with positioned event blocks. Designed for AI agents to visually confirm scheduled events to users. - Auto-computes visible time window from event times (±2 hour padding) - Current time indicator line when viewing today - Percentage-based event block positioning - DayCalendar, DayCalendarHeader, DayCalendarContent, DayCalendarEvent - CalendarEvent shared type (start, end, title, description) - Dark mode support via Tailwind CSS variables - Docs page and example (Team Standup 4–5pm) --- .../components/(chatbot)/day-calendar.mdx | 128 ++++++++ packages/elements/src/day-calendar.tsx | 284 ++++++++++++++++++ packages/examples/src/day-calendar.tsx | 28 ++ 3 files changed, 440 insertions(+) create mode 100644 apps/docs/content/components/(chatbot)/day-calendar.mdx create mode 100644 packages/elements/src/day-calendar.tsx create mode 100644 packages/examples/src/day-calendar.tsx diff --git a/apps/docs/content/components/(chatbot)/day-calendar.mdx b/apps/docs/content/components/(chatbot)/day-calendar.mdx new file mode 100644 index 00000000..347b9e90 --- /dev/null +++ b/apps/docs/content/components/(chatbot)/day-calendar.mdx @@ -0,0 +1,128 @@ +--- +title: Day Calendar +description: A read-only day view calendar for visualizing scheduled events on a time grid. Designed for AI agents that need to confirm event scheduling to users. +path: elements/components/day-calendar +--- + +The `DayCalendar` component renders a single-day time grid with positioned event blocks. It auto-focuses the visible window around events and shows the current time indicator when viewing today. + + + +## Installation + + + +## Features + +- Auto-computes visible time window based on event times (padded by 2 hours) +- Current time indicator line when viewing today's date +- Percentage-based event block positioning for accurate layout +- Dark mode support via Tailwind CSS variables +- Compound component pattern for flexible composition +- Fully typed with TypeScript +- No external date library — uses `Intl.DateTimeFormat` + +## Props + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `CalendarEvent` + + diff --git a/packages/elements/src/day-calendar.tsx b/packages/elements/src/day-calendar.tsx new file mode 100644 index 00000000..15695c14 --- /dev/null +++ b/packages/elements/src/day-calendar.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { CalendarIcon } from "lucide-react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { HTMLAttributes } from "react"; + +export interface CalendarEvent { + start: Date; + end: Date; + title: string; + description?: string; +} + +interface DayCalendarContextValue { + date: Date; + events: CalendarEvent[]; + startHour: number; + endHour: number; +} + +const DayCalendarContext = createContext(null); + +const useDayCalendar = () => { + const context = useContext(DayCalendarContext); + if (!context) { + throw new Error("DayCalendar components must be used within DayCalendar"); + } + return context; +}; + +const computeTimeWindow = ( + events: CalendarEvent[], + startHourOverride?: number, + endHourOverride?: number +): { startHour: number; endHour: number } => { + if (startHourOverride !== undefined && endHourOverride !== undefined) { + return { endHour: endHourOverride, startHour: startHourOverride }; + } + + if (events.length === 0) { + return { + endHour: endHourOverride ?? 17, + startHour: startHourOverride ?? 9, + }; + } + + const minStart = Math.min( + ...events.map((e) => e.start.getHours() + e.start.getMinutes() / 60) + ); + const maxEnd = Math.max( + ...events.map((e) => e.end.getHours() + e.end.getMinutes() / 60) + ); + + const startHour = startHourOverride ?? Math.max(0, Math.floor(minStart - 2)); + const endHour = endHourOverride ?? Math.min(24, Math.ceil(maxEnd + 2)); + + return { endHour, startHour }; +}; + +export type DayCalendarProps = HTMLAttributes & { + date: Date; + events: CalendarEvent[]; + startHour?: number; + endHour?: number; +}; + +export const DayCalendar = ({ + date, + events, + startHour: startHourProp, + endHour: endHourProp, + className, + children, + ...props +}: DayCalendarProps) => { + const { startHour, endHour } = computeTimeWindow( + events, + startHourProp, + endHourProp + ); + + const contextValue = useMemo( + () => ({ date, endHour, events, startHour }), + [date, endHour, events, startHour] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +export type DayCalendarHeaderProps = HTMLAttributes; + +export const DayCalendarHeader = ({ + className, + children, + ...props +}: DayCalendarHeaderProps) => { + const { date } = useDayCalendar(); + + const formatted = new Intl.DateTimeFormat("en-US", { + dateStyle: "full", + }).format(date); + + return ( +
+ + {children ?? {formatted}} +
+ ); +}; + +export type DayCalendarEventProps = HTMLAttributes & { + event: CalendarEvent; + startHour: number; + totalHours: number; +}; + +export const DayCalendarEvent = ({ + event, + startHour, + totalHours, + className, + ...props +}: DayCalendarEventProps) => { + const eventStartHour = event.start.getHours() + event.start.getMinutes() / 60; + const eventEndHour = event.end.getHours() + event.end.getMinutes() / 60; + + const topPercent = ((eventStartHour - startHour) / totalHours) * 100; + const heightPercent = ((eventEndHour - eventStartHour) / totalHours) * 100; + + const startFormatted = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }).format(event.start); + + const endFormatted = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }).format(event.end); + + return ( +
+

{event.title}

+

+ {startFormatted} – {endFormatted} +

+ {event.description && ( +

+ {event.description} +

+ )} +
+ ); +}; + +export type DayCalendarContentProps = HTMLAttributes; + +export const DayCalendarContent = ({ + className, + ...props +}: DayCalendarContentProps) => { + const { date, events, startHour, endHour } = useDayCalendar(); + const totalHours = endHour - startHour; + const hours = Array.from({ length: totalHours + 1 }, (_, i) => startHour + i); + + const now = new Date(); + const isToday = + now.getFullYear() === date.getFullYear() && + now.getMonth() === date.getMonth() && + now.getDate() === date.getDate(); + + const currentHour = now.getHours() + now.getMinutes() / 60; + const currentTimePercent = ((currentHour - startHour) / totalHours) * 100; + const showCurrentTime = + isToday && currentHour >= startHour && currentHour <= endHour; + + const currentTimeRef = useRef(null); + const [, forceUpdate] = useState(0); + + useEffect(() => { + if (!isToday) { + return; + } + const interval = setInterval(() => forceUpdate((n) => n + 1), 60_000); + return () => clearInterval(interval); + }, [isToday]); + + useEffect(() => { + currentTimeRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, []); + + return ( +
+
+ {hours.map((hour) => { + const topPercent = ((hour - startHour) / totalHours) * 100; + const label = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + hour12: true, + }).format(new Date(2000, 0, 1, hour)); + + return ( +
+ + {hour === startHour ? "" : label} + +
+
+ ); + })} + + {events.map((event) => ( + + ))} + + {showCurrentTime && ( +
+ +
+
+
+ )} +
+
+ ); +}; diff --git a/packages/examples/src/day-calendar.tsx b/packages/examples/src/day-calendar.tsx new file mode 100644 index 00000000..22af474f --- /dev/null +++ b/packages/examples/src/day-calendar.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { + DayCalendar, + DayCalendarContent, + DayCalendarHeader, +} from "@repo/elements/day-calendar"; +import type { CalendarEvent } from "@repo/elements/day-calendar"; + +const date = new Date("2025-02-27"); + +const events: CalendarEvent[] = [ + { + description: "Daily sync with the engineering team", + end: new Date("2025-02-27T17:00:00"), + start: new Date("2025-02-27T16:00:00"), + title: "Team Standup", + }, +]; + +const Example = () => ( + + + + +); + +export default Example; From 476e5d30fbc426f9286df3769b1f5873ec83d5db Mon Sep 17 00:00:00 2001 From: Maxim Kazantsev Date: Fri, 27 Feb 2026 23:02:21 -0800 Subject: [PATCH 2/5] test: add day-calendar component tests --- .../elements/__tests__/day-calendar.test.tsx | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/elements/__tests__/day-calendar.test.tsx diff --git a/packages/elements/__tests__/day-calendar.test.tsx b/packages/elements/__tests__/day-calendar.test.tsx new file mode 100644 index 00000000..d442acf2 --- /dev/null +++ b/packages/elements/__tests__/day-calendar.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from "@testing-library/react"; + +import { + DayCalendar, + DayCalendarContent, + DayCalendarEvent, + DayCalendarHeader, + type CalendarEvent, +} from "../src/day-calendar"; + +const date = new Date("2025-02-27T00:00:00"); + +const events: CalendarEvent[] = [ + { + description: "Daily sync", + end: new Date("2025-02-27T17:00:00"), + start: new Date("2025-02-27T16:00:00"), + title: "Team Standup", + }, +]; + +describe("dayCalendar", () => { + it("renders children", () => { + render( + + Content + + ); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + + it("throws error when sub-component used outside provider", () => { + const spy = vi.spyOn(console, "error").mockImplementation(vi.fn()); + + expect(() => render()).toThrow( + "DayCalendar components must be used within DayCalendar" + ); + + spy.mockRestore(); + }); +}); + +describe("dayCalendarHeader", () => { + it("renders formatted date", () => { + render( + + + + ); + + expect(screen.getByText("Thursday, February 27, 2025")).toBeInTheDocument(); + }); + + it("renders custom children instead of formatted date", () => { + render( + + Custom Header + + ); + + expect(screen.getByText("Custom Header")).toBeInTheDocument(); + expect( + screen.queryByText("Thursday, February 27, 2025") + ).not.toBeInTheDocument(); + }); +}); + +describe("dayCalendarContent", () => { + it("renders time grid", () => { + render( + + + + ); + + expect(screen.getByText("10 AM")).toBeInTheDocument(); + expect(screen.getByText("11 AM")).toBeInTheDocument(); + }); + + it("renders event titles", () => { + render( + + + + ); + + expect(screen.getByText("Team Standup")).toBeInTheDocument(); + }); + + it("renders event description", () => { + render( + + + + ); + + expect(screen.getByText("Daily sync")).toBeInTheDocument(); + }); + + it("defaults to 9am-5pm when no events", () => { + render( + + + + ); + + expect(screen.getByText("10 AM")).toBeInTheDocument(); + expect(screen.getByText("5 PM")).toBeInTheDocument(); + }); +}); + +describe("dayCalendarEvent", () => { + it("renders event title and time range", () => { + render( + + + + ); + + expect(screen.getByText("Team Standup")).toBeInTheDocument(); + expect(screen.getByText(/4:00 PM/)).toBeInTheDocument(); + expect(screen.getByText(/5:00 PM/)).toBeInTheDocument(); + }); + + it("renders description when provided", () => { + render( + + + + ); + + expect(screen.getByText("Daily sync")).toBeInTheDocument(); + }); + + it("omits description when not provided", () => { + const eventNoDesc: CalendarEvent = { + end: new Date("2025-02-27T17:00:00"), + start: new Date("2025-02-27T16:00:00"), + title: "No Desc Event", + }; + + render( + + + + ); + + expect(screen.getByText("No Desc Event")).toBeInTheDocument(); + }); +}); From a5facd862a0e9d74a0ffea35d0860c1227cbb510 Mon Sep 17 00:00:00 2001 From: Maxim Kazantsev Date: Fri, 27 Feb 2026 23:06:03 -0800 Subject: [PATCH 3/5] refactor: remove live-update interval from day-calendar --- packages/elements/src/day-calendar.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/elements/src/day-calendar.tsx b/packages/elements/src/day-calendar.tsx index 15695c14..ab5c0e9e 100644 --- a/packages/elements/src/day-calendar.tsx +++ b/packages/elements/src/day-calendar.tsx @@ -2,14 +2,7 @@ import { cn } from "@repo/shadcn-ui/lib/utils"; import { CalendarIcon } from "lucide-react"; -import { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { createContext, useContext, useEffect, useMemo, useRef } from "react"; import type { HTMLAttributes } from "react"; export interface CalendarEvent { @@ -213,15 +206,6 @@ export const DayCalendarContent = ({ isToday && currentHour >= startHour && currentHour <= endHour; const currentTimeRef = useRef(null); - const [, forceUpdate] = useState(0); - - useEffect(() => { - if (!isToday) { - return; - } - const interval = setInterval(() => forceUpdate((n) => n + 1), 60_000); - return () => clearInterval(interval); - }, [isToday]); useEffect(() => { currentTimeRef.current?.scrollIntoView({ From 03ba5e15872771c69ba2f4dc1b670cbb4e4969c5 Mon Sep 17 00:00:00 2001 From: Maxim Kazantsev Date: Fri, 27 Feb 2026 23:07:27 -0800 Subject: [PATCH 4/5] refactor: remove scrollIntoView from day-calendar content --- packages/elements/src/day-calendar.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/elements/src/day-calendar.tsx b/packages/elements/src/day-calendar.tsx index ab5c0e9e..193d4949 100644 --- a/packages/elements/src/day-calendar.tsx +++ b/packages/elements/src/day-calendar.tsx @@ -2,7 +2,7 @@ import { cn } from "@repo/shadcn-ui/lib/utils"; import { CalendarIcon } from "lucide-react"; -import { createContext, useContext, useEffect, useMemo, useRef } from "react"; +import { createContext, useContext, useMemo } from "react"; import type { HTMLAttributes } from "react"; export interface CalendarEvent { @@ -205,15 +205,6 @@ export const DayCalendarContent = ({ const showCurrentTime = isToday && currentHour >= startHour && currentHour <= endHour; - const currentTimeRef = useRef(null); - - useEffect(() => { - currentTimeRef.current?.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - }, []); - return (
From 2fcae59e093c6e69ffdd17b9c44bc0ca765d270f Mon Sep 17 00:00:00 2001 From: Maxim Kazantsev Date: Fri, 27 Feb 2026 23:23:04 -0800 Subject: [PATCH 5/5] fix: clamp events ending at midnight to end of visible day --- packages/elements/src/day-calendar.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/elements/src/day-calendar.tsx b/packages/elements/src/day-calendar.tsx index 193d4949..3bc29d82 100644 --- a/packages/elements/src/day-calendar.tsx +++ b/packages/elements/src/day-calendar.tsx @@ -49,7 +49,10 @@ const computeTimeWindow = ( ...events.map((e) => e.start.getHours() + e.start.getMinutes() / 60) ); const maxEnd = Math.max( - ...events.map((e) => e.end.getHours() + e.end.getMinutes() / 60) + ...events.map((e) => { + const h = e.end.getHours() + e.end.getMinutes() / 60; + return h === 0 && e.end > e.start ? 24 : h; + }) ); const startHour = startHourOverride ?? Math.max(0, Math.floor(minStart - 2)); @@ -143,7 +146,11 @@ export const DayCalendarEvent = ({ ...props }: DayCalendarEventProps) => { const eventStartHour = event.start.getHours() + event.start.getMinutes() / 60; - const eventEndHour = event.end.getHours() + event.end.getMinutes() / 60; + const rawEndHour = event.end.getHours() + event.end.getMinutes() / 60; + const eventEndHour = Math.min( + rawEndHour === 0 && event.end > event.start ? 24 : rawEndHour, + startHour + totalHours + ); const topPercent = ((eventStartHour - startHour) / totalHours) * 100; const heightPercent = ((eventEndHour - eventStartHour) / totalHours) * 100;