Files
ProjectE/apps/web/src/routes/_app/calendar.tsx
T

364 lines
14 KiB
TypeScript
Raw Normal View History

import { useState, useMemo, useCallback, useEffect, useRef } from "react";
2026-08-01 02:00:24 +00:00
import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
2026-08-01 02:34:37 +00:00
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
2026-08-01 02:34:37 +00:00
import type { CalendarEvent } from "@/lib/types";
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
// react-big-calendar
import { Calendar, dateFnsLocalizer, Navigate, type View, type stringOrDate } from "react-big-calendar";
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
2026-08-01 02:34:37 +00:00
import "react-big-calendar/lib/css/react-big-calendar.css";
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
2026-08-01 02:34:37 +00:00
const localizer = dateFnsLocalizer({
startOfWeek,
getDay,
format,
locales: {},
});
// Mapped calendar event shape used by react-big-calendar (adds Date start/end accessors)
type CalendarViewEvent = CalendarEvent & { start: Date; end: Date };
const DragAndDropCalendar = withDragAndDrop<CalendarViewEvent>(Calendar);
2026-08-01 02:34:37 +00:00
// Color palette: tasks=accent, events by domain
const EVENT_COLORS: Record<string, string> = {
task: "var(--accent-hsl, 217 91% 60%)",
habit: "142 71% 45%",
project: "271 81% 56%",
note: "24 95% 53%",
default: "215 16% 47%",
};
function eventColor(event: CalendarEvent): string {
const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default;
return `hsl(${hue})`;
}
function eventBg(event: CalendarEvent): string {
const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default;
return `hsla(${hue}, 0.15)`;
}
// Custom event component
function EventComponent({ event }: { event: CalendarEvent }) {
return (
<div
className="rbc-event-content truncate px-1 py-0.5 text-xs"
style={{
borderLeft: `3px solid ${eventColor(event)}`,
backgroundColor: eventBg(event),
color: eventColor(event),
}}
>
{event.title}
</div>
);
}
// Custom toolbar
function CustomToolbar({ date, onNavigate, label }: any) {
2026-08-01 02:34:37 +00:00
const goToBack = () => onNavigate(Navigate.PREVIOUS);
const goToNext = () => onNavigate(Navigate.NEXT);
const goToToday = () => onNavigate(Navigate.TODAY);
return (
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous">
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={goToToday}>
Today
</Button>
<Button variant="outline" size="icon" onClick={goToNext} aria-label="Next">
<ChevronRight className="h-4 w-4" />
</Button>
<span className="ml-2 text-lg font-semibold">{label}</span>
</div>
</div>
);
}
function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) {
const queryClient = useQueryClient();
const [title, setTitle] = useState(event?.title || "");
const [startTime, setStartTime] = useState(event?.startTime ? event.startTime.slice(0, 16) : "");
const [endTime, setEndTime] = useState(event?.endTime ? event.endTime.slice(0, 16) : "");
const [allDay, setAllDay] = useState(event?.allDay || false);
const [color, setColor] = useState(event?.color || "#3b82f6");
2026-08-01 02:34:37 +00:00
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
toast.success("Event created");
onClose();
},
onError: (err) => toast.error(err.message || "Failed to create event"),
});
2026-08-01 02:34:37 +00:00
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
toast.success("Event updated");
onClose();
},
onError: (err) => toast.error(err.message || "Failed to update event"),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
const data: any = { title: title.trim(), allDay, color };
if (startTime) data.startTime = new Date(startTime).toISOString();
if (endTime) data.endTime = new Date(endTime).toISOString();
if (event) updateMutation.mutate(data);
else createMutation.mutate(data);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Event title" required />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="allDay" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="rounded" />
<Label htmlFor="allDay">All day</Label>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start">Start</Label>
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</div>
<div>
<Label htmlFor="end">End</Label>
<Input id="end" type={allDay ? "date" : "datetime-local"} value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
</div>
<div>
<Label htmlFor="color">Color</Label>
<Input id="color" type="color" value={color} onChange={(e) => setColor(e.target.value)} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit">{event ? "Update" : "Create"} Event</Button>
</div>
</form>
);
}
2026-08-01 02:00:24 +00:00
function CalendarPage() {
const queryClient = useQueryClient();
2026-08-01 02:34:37 +00:00
const [date, setDate] = useState(new Date());
const [view, setView] = useState<View>("month");
const [createOpen, setCreateOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [eventDetailOpen, setEventDetailOpen] = useState(false);
2026-08-01 02:34:37 +00:00
const [isMobile, setIsMobile] = useState(false);
useRealtime({ enabled: true });
2026-08-01 02:34:37 +00:00
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
const activeDomainId = useApiDomain();
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
["calendar-events", activeDomainId, date.toISOString()],
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const events = eventsData?.items || [];
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
setEventDetailOpen(false);
toast.success("Event deleted");
},
onError: (err) => toast.error(err.message || "Failed to delete event"),
});
2026-08-01 02:34:37 +00:00
// Map API events to react-big-calendar format
const calendarEvents = useMemo<CalendarViewEvent[]>(() => {
2026-08-01 02:34:37 +00:00
return events.map((evt) => ({
...evt,
start: parseISO(evt.startTime),
end: evt.endTime ? parseISO(evt.endTime) : addDays(parseISO(evt.startTime), 1),
}));
}, [events]);
2026-08-01 02:34:37 +00:00
const handleSelectEvent = useCallback((event: any) => {
setSelectedEvent(event as CalendarEvent);
setEventDetailOpen(true);
}, []);
const handleSelectSlot = useCallback(() => {
setCreateOpen(true);
}, []);
const handleEventDrop = useCallback(
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
2026-08-01 02:34:37 +00:00
api.patch(`/calendar/events/${event.id}`, {
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
2026-08-01 02:34:37 +00:00
}).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
});
},
[queryClient]
);
const handleEventResize = useCallback(
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
2026-08-01 02:34:37 +00:00
api.patch(`/calendar/events/${event.id}`, {
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
2026-08-01 02:34:37 +00:00
}).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
});
},
[queryClient]
);
const handleNavigate = useCallback((newDate: Date) => {
setDate(newDate);
}, []);
const handleViewChange = useCallback((newView: string) => {
setView(newView as View);
2026-08-01 02:34:37 +00:00
}, []);
2026-08-01 02:00:24 +00:00
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Calendar</h1>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button aria-label="New event"><Plus className="h-4 w-4 mr-2" />New Event</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Event</DialogTitle></DialogHeader>
<EventForm onClose={() => setCreateOpen(false)} />
</DialogContent>
</Dialog>
</div>
<div className="flex items-center justify-end gap-1 mb-2">
{["month", "week", "day", "agenda"].map((name) => (
<Button
key={name}
variant={view === name ? "default" : "outline"}
size="sm"
onClick={() => setView(name as View)}
className="capitalize"
>
{name}
</Button>
))}
</div>
{eventsLoading && events.length === 0 ? (
<LoadingState label="Loading events..." />
) : !eventsLoading && events.length === 0 ? (
<EmptyState
title="No events yet"
description="Create your first event to get started"
action={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-2" />New Event
</Button>
}
/>
) : (
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
<DragAndDropCalendar
key={view}
localizer={localizer}
events={calendarEvents}
startAccessor="start"
endAccessor="end"
date={date}
view={view}
onNavigate={handleNavigate}
onSelectEvent={handleSelectEvent}
onSelectSlot={handleSelectSlot}
onEventDrop={handleEventDrop}
onEventResize={handleEventResize}
selectable
popup
showMultiDayTimes
components={{
event: EventComponent,
toolbar: (props: any) => <CustomToolbar {...props} />,
}}
views={["month", "week", "work_week", "day", "agenda"]}
step={30}
timeslots={2}
style={{ height: isMobile ? 400 : 600 }}
/>
</div>
)}
{/* Event detail dialog */}
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedEvent?.title || "Event"}</DialogTitle>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4">
<div className="text-sm">
<p><strong>Start:</strong> {new Date(selectedEvent.startTime).toLocaleString()}</p>
{selectedEvent.endTime && <p><strong>End:</strong> {new Date(selectedEvent.endTime).toLocaleString()}</p>}
{selectedEvent.description && <p className="mt-2">{selectedEvent.description}</p>}
</div>
<EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Event</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Event</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete "{selectedEvent.title}"?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedEvent.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</DialogContent>
</Dialog>
2026-08-01 02:00:24 +00:00
</div>
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "/calendar",
component: CalendarPage,
});