diff --git a/apps/docs/src/lib/blocks-registry.ts b/apps/docs/src/lib/blocks-registry.ts index 993a4ae0..9d0101bd 100644 --- a/apps/docs/src/lib/blocks-registry.ts +++ b/apps/docs/src/lib/blocks-registry.ts @@ -1508,15 +1508,22 @@ export default function Page() { description: "Monthly calendar view with event markers and day selection.", category: "data", sourcePath: "data/CalendarView/CalendarView.tsx", - code: `import { CalendarView } from "@launchapp/design-system/blocks/data"; + code: `import { CalendarView, type CalendarEvent } from "@launchapp/design-system/blocks/data"; -const events = [ - { id: "1", date: "2024-01-15", title: "Team standup", color: "blue" }, - { id: "2", date: "2024-01-20", title: "Product launch", color: "green" }, +const events: CalendarEvent[] = [ + { id: "1", title: "Team standup", date: new Date(), allDay: true }, + { id: "2", title: "Design review", date: new Date(), color: "secondary" }, ]; export default function Page() { - return console.log("selected", date)} />; + return ( + console.log("created", event)} + onEventReschedule={(event) => console.log("rescheduled", event)} + /> + ); }`, }, { diff --git a/registry.json b/registry.json index 78ba0016..86ed65b1 100644 --- a/registry.json +++ b/registry.json @@ -2507,8 +2507,9 @@ } ], "dependencies": [ - "date-fns", - "react-day-picker" + "@dnd-kit/core", + "@dnd-kit/utilities", + "date-fns" ], "devDependencies": [] }, diff --git a/src/blocks/data/CalendarView/CalendarView.stories.tsx b/src/blocks/data/CalendarView/CalendarView.stories.tsx index 8fadf498..76fb4ed7 100644 --- a/src/blocks/data/CalendarView/CalendarView.stories.tsx +++ b/src/blocks/data/CalendarView/CalendarView.stories.tsx @@ -1,28 +1,30 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { addDays, startOfToday } from "date-fns"; -import { CalendarView, type CalendarEvent } from "./CalendarView"; +import * as React from "react"; +import { addDays, addHours, setHours, setMinutes, startOfToday } from "date-fns"; +import { CalendarView, type CalendarEvent, type CalendarViewMode } from "./CalendarView"; const today = startOfToday(); const sampleEvents: CalendarEvent[] = [ { id: "1", - title: "Product Launch Meeting", - date: today, + title: "Product launch meeting", + date: setMinutes(setHours(today, 9), 0), + endDate: setMinutes(setHours(today, 10), 0), description: "Final review of launch checklist and go/no-go decision", color: "primary", - allDay: true, }, { id: "2", - title: "Design Review", - date: addDays(today, 1), + title: "Design review", + date: setMinutes(setHours(addDays(today, 1), 11), 0), + endDate: addHours(setMinutes(setHours(addDays(today, 1), 11), 0), 1), description: "Review new dashboard designs with the team", color: "secondary", }, { id: "3", - title: "Sprint Planning", + title: "Sprint planning", date: addDays(today, 2), description: "Plan next two-week sprint", color: "default", @@ -31,14 +33,15 @@ const sampleEvents: CalendarEvent[] = [ { id: "4", title: "1:1 with Sarah", - date: addDays(today, 3), + date: setMinutes(setHours(addDays(today, 3), 14), 30), + endDate: setMinutes(setHours(addDays(today, 3), 15), 0), description: "Weekly check-in", color: "outline", }, { id: "5", title: "Bug triage", - date: addDays(today, 5), + date: setMinutes(setHours(addDays(today, 5), 16), 0), description: "Review critical bugs from last release", color: "destructive", }, @@ -78,7 +81,10 @@ export default function Page() { console.log(event)} + onEventCreate={(event) => console.log("created", event)} + onEventReschedule={(event) => console.log("rescheduled", event)} /> ); }`, @@ -92,27 +98,62 @@ type Story = StoryObj; export const Default: Story = { render: () => ( -
+
), }; -export const WithSelectedDay: Story = { +export const WeekView: Story = { render: () => ( -
- console.log("Selected:", date)} - /> +
+ +
+ ), +}; + +export const DayView: Story = { + render: () => ( +
+
), }; +export const Interactive: Story = { + render: function InteractiveCalendarView() { + const [events, setEvents] = React.useState(sampleEvents); + const [view, setView] = React.useState("week"); + + return ( +
+ console.log("Selected:", date)} + onEventCreate={(event) => + setEvents((current) => [ + ...current, + { + ...event, + id: `story-event-${current.length + 1}`, + }, + ]) + } + onEventReschedule={(event) => + setEvents((current) => current.map((item) => (item.id === event.id ? event : item))) + } + /> +
+ ); + }, +}; + export const Empty: Story = { render: () => ( -
+
), @@ -120,11 +161,11 @@ export const Empty: Story = { export const WithCustomTitle: Story = { render: () => ( -
+
), @@ -133,7 +174,7 @@ export const WithCustomTitle: Story = { export const EventListHidden: Story = { name: "Without Event List", render: () => ( -
+
), @@ -182,7 +223,7 @@ export const ManyEvents: Story = { }, ]; return ( -
+
); @@ -198,7 +239,7 @@ export const DarkMode: Story = { ), ], render: () => ( -
+
), @@ -209,7 +250,7 @@ export const Mobile: Story = { viewport: { defaultViewport: "mobile1" }, }, render: () => ( -
+
), @@ -220,7 +261,7 @@ export const Tablet: Story = { viewport: { defaultViewport: "tablet" }, }, render: () => ( -
+
), diff --git a/src/blocks/data/CalendarView/CalendarView.tsx b/src/blocks/data/CalendarView/CalendarView.tsx index 32e5cb8a..0ae85dfd 100644 --- a/src/blocks/data/CalendarView/CalendarView.tsx +++ b/src/blocks/data/CalendarView/CalendarView.tsx @@ -1,322 +1,937 @@ import * as React from "react"; import { + DndContext, + type DragEndEvent, + DragOverlay, + KeyboardSensor, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + addDays, + addMonths, + differenceInMilliseconds, + eachDayOfInterval, + endOfMonth, + endOfWeek, format, - isSameMonth, isSameDay, - addMonths, - subMonths, + isSameMonth, isToday, + isValid, + setHours, + setMinutes, + startOfDay, + startOfMonth, + startOfWeek, + subMonths, } from "date-fns"; -import type { CalendarDay } from "react-day-picker"; -import { Calendar } from "@/components/Calendar"; -import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/Card"; +import { CSS } from "@dnd-kit/utilities"; +import { Badge } from "@/components/Badge"; import { Button } from "@/components/Button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/Card"; +import { + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogRoot, + DialogTitle, +} from "@/components/Dialog"; +import { Input } from "@/components/Input"; +import { Label } from "@/components/Label"; import { ScrollArea } from "@/components/ScrollArea"; +import { Textarea } from "@/components/Textarea"; import { cn } from "@/lib/utils"; +export type CalendarViewMode = "month" | "week" | "day"; + export interface CalendarEvent { id: string; title: string; - date: Date; - endDate?: Date; + date: Date | string; + endDate?: Date | string; description?: string; color?: "default" | "primary" | "secondary" | "destructive" | "outline"; allDay?: boolean; } +export interface CalendarEventCreateInput { + title: string; + date: Date; + endDate?: Date; + description?: string; + color: NonNullable; + allDay: boolean; +} + export interface CalendarViewProps extends React.HTMLAttributes { events?: CalendarEvent[]; selectedDate?: Date; onDateSelect?: (date: Date) => void; onEventClick?: (event: CalendarEvent) => void; + onEventCreate?: (event: CalendarEventCreateInput) => void; + onEventReschedule?: (event: CalendarEvent) => void; title?: string; description?: string; initialMonth?: Date; onMonthChange?: (month: Date) => void; showEventList?: boolean; maxEventsPerDay?: number; + view?: CalendarViewMode; + defaultView?: CalendarViewMode; + onViewChange?: (view: CalendarViewMode) => void; + showCreateButton?: boolean; } +const CALENDAR_START_HOUR = 0; +const CALENDAR_HOUR_COUNT = 24; +const CALENDAR_BUSINESS_HOUR = 8; +const hourSlots = Array.from({ length: CALENDAR_HOUR_COUNT }, (_, i) => CALENDAR_START_HOUR + i); + const eventColorMap: Record, string> = { - default: "bg-primary text-primary-foreground", - primary: "bg-primary text-primary-foreground", - secondary: "bg-secondary text-secondary-foreground", - destructive: "bg-destructive text-destructive-foreground", - outline: "bg-transparent border border-border text-foreground", + default: "border-border bg-muted text-foreground", + primary: "border-primary bg-primary text-primary-foreground", + secondary: "border-secondary bg-secondary text-secondary-foreground", + destructive: "border-destructive bg-destructive text-destructive-foreground", + outline: "border-border bg-background text-foreground", }; +const eventAccentMap: Record, string> = { + default: "bg-muted-foreground", + primary: "bg-primary", + secondary: "bg-secondary", + destructive: "bg-destructive", + outline: "bg-border", +}; + +function toDate(value: Date | string) { + if (value instanceof Date) return value; + // Append local midnight to date-only strings so they parse in local time, + // not UTC midnight (which shifts the date backward for UTC− timezones). + const normalized = + typeof value === "string" && !value.includes("T") ? `${value}T00:00:00` : value; + return new Date(normalized); +} + +function toDateInputValue(date: Date) { + return format(date, "yyyy-MM-dd"); +} + +function toTimeInputValue(date: Date) { + return format(date, "HH:mm"); +} + +function fromDateAndTime(dateValue: string, timeValue: string) { + const [year, month, day] = dateValue.split("-").map(Number); + const [hours, minutes] = timeValue.split(":").map(Number); + return new Date(year, month - 1, day, hours, minutes); +} + +function getEventStart(event: CalendarEvent) { + return toDate(event.date); +} + +function getEventEnd(event: CalendarEvent) { + return event.endDate ? toDate(event.endDate) : undefined; +} + +function eventDuration(event: CalendarEvent) { + const start = getEventStart(event); + const end = getEventEnd(event); + return end ? Math.max(differenceInMilliseconds(end, start), 0) : 0; +} + +function rescheduleEvent(event: CalendarEvent, nextStart: Date) { + const duration = eventDuration(event); + return { + ...event, + date: nextStart, + endDate: duration > 0 ? new Date(nextStart.getTime() + duration) : undefined, + }; +} + +function getCalendarDays(month: Date) { + return eachDayOfInterval({ + start: startOfWeek(startOfMonth(month)), + end: endOfWeek(endOfMonth(month)), + }); +} + +function getWeekDays(date: Date) { + return eachDayOfInterval({ + start: startOfWeek(date), + end: endOfWeek(date), + }); +} + +function normalizeEvents(events: CalendarEvent[]) { + return events.map((event) => ({ + ...event, + date: getEventStart(event), + endDate: getEventEnd(event), + })); +} + +function sortEvents(events: CalendarEvent[]) { + return [...events].sort((a, b) => getEventStart(a).getTime() - getEventStart(b).getTime()); +} + +function sameDayEvents(events: CalendarEvent[], date: Date) { + return sortEvents(events.filter((event) => isSameDay(getEventStart(event), date))); +} + +function formatEventTime(event: CalendarEvent) { + if (event.allDay) return "All day"; + const start = getEventStart(event); + const end = getEventEnd(event); + return end ? `${format(start, "h:mm a")} - ${format(end, "h:mm a")}` : format(start, "h:mm a"); +} + +function calendarEventId(id: string) { + return `calendar-event:${id}`; +} + +function dayDropId(date: Date) { + return `calendar-day:${toDateInputValue(date)}`; +} + +function timeDropId(date: Date, hour: number) { + return `calendar-time:${toDateInputValue(date)}:${hour}`; +} + +function createEventId() { + return `event-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function CalendarEventCard({ + event, + onClick, + compact = false, + isDragging = false, +}: { + event: CalendarEvent; + onClick?: (event: CalendarEvent) => void; + compact?: boolean; + isDragging?: boolean; +}) { + const { attributes, listeners, setNodeRef, transform, isDragging: isDragSource } = useDraggable({ + id: calendarEventId(event.id), + }); + const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined; + + return ( + + ); +} + +function MonthDayCell({ + date, + currentMonth, + selectedDate, + events, + maxEvents, + onSelectDate, + onCreate, + onEventClick, +}: { + date: Date; + currentMonth: Date; + selectedDate?: Date; + events: CalendarEvent[]; + maxEvents: number; + onSelectDate: (date: Date) => void; + onCreate: (date: Date) => void; + onEventClick?: (event: CalendarEvent) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: dayDropId(date) }); + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + + return ( +
+
+ + +
+
+ {events.slice(0, maxEvents).map((event) => ( + + ))} + {events.length > maxEvents && ( + + )} +
+
+ ); +} + +function TimeSlot({ + date, + hour, + children, +}: { + date: Date; + hour: number; + children: React.ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ id: timeDropId(date, hour) }); + + return ( +
+ {children} +
+ ); +} + +function CalendarCreateDialog({ + open, + selectedDate, + onOpenChange, + onCreate, +}: { + open: boolean; + selectedDate: Date; + onOpenChange: (open: boolean) => void; + onCreate: (event: CalendarEventCreateInput) => void; +}) { + const [title, setTitle] = React.useState(""); + const [dateValue, setDateValue] = React.useState(toDateInputValue(selectedDate)); + const [startTime, setStartTime] = React.useState(toTimeInputValue(setMinutes(setHours(selectedDate, 9), 0))); + const [endTime, setEndTime] = React.useState(toTimeInputValue(setMinutes(setHours(selectedDate, 10), 0))); + const [description, setDescription] = React.useState(""); + const [allDay, setAllDay] = React.useState(false); + const [color, setColor] = React.useState>("primary"); + const [timeError, setTimeError] = React.useState(null); + + React.useEffect(() => { + if (!open) return; + setTitle(""); + setDateValue(toDateInputValue(selectedDate)); + setStartTime(toTimeInputValue(setMinutes(setHours(selectedDate, 9), 0))); + setEndTime(toTimeInputValue(setMinutes(setHours(selectedDate, 10), 0))); + setDescription(""); + setAllDay(false); + setColor("primary"); + setTimeError(null); + }, [open, selectedDate]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = title.trim(); + if (!trimmedTitle) return; + + if (!allDay) { + const startDate = fromDateAndTime(dateValue, startTime); + const endDate = fromDateAndTime(dateValue, endTime); + + if (!isValid(startDate) || !isValid(endDate)) { + setTimeError("Please enter a valid date and time."); + return; + } + if (endDate <= startDate) { + setTimeError("End time must be after start time."); + return; + } + } + + setTimeError(null); + onCreate({ + title: trimmedTitle, + date: allDay ? startOfDay(fromDateAndTime(dateValue, "00:00")) : fromDateAndTime(dateValue, startTime), + endDate: allDay ? undefined : fromDateAndTime(dateValue, endTime), + description: description.trim() || undefined, + color, + allDay, + }); + onOpenChange(false); + }; + + return ( + + + + Create event + Schedule a CRM activity or admin milestone. + +
+
+ + setTitle(event.target.value)} + placeholder="Discovery call" + /> +
+
+
+ + setDateValue(event.target.value)} + /> +
+
+ + setStartTime(event.target.value)} + /> +
+
+ + setEndTime(event.target.value)} + /> +
+ +
+ {timeError && ( +

+ {timeError} +

+ )} +
+ + +
+
+ +