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/__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(); + }); +}); diff --git a/packages/elements/src/day-calendar.tsx b/packages/elements/src/day-calendar.tsx new file mode 100644 index 00000000..3bc29d82 --- /dev/null +++ b/packages/elements/src/day-calendar.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { CalendarIcon } from "lucide-react"; +import { createContext, useContext, useMemo } 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) => { + 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)); + 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 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; + + 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; + + 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;