From bb927cff4a73b1509d25227b8349615663e0dc4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 18:02:14 +0000 Subject: [PATCH 1/6] feat(calendar): add per-drive and per-user calendar system Add a comprehensive calendar system to PageSpace with the following features: Database Schema: - calendarEvents table with support for drive-scoped and personal events - eventAttendees table for tracking invitations and RSVP status - Recurrence rules support (DAILY, WEEKLY, MONTHLY, YEARLY) - Event visibility levels (DRIVE, ATTENDEES_ONLY, PRIVATE) API Routes: - GET/POST /api/calendar/events - list and create events - GET/PATCH/DELETE /api/calendar/events/[eventId] - event CRUD - GET/POST/PATCH/DELETE /api/calendar/events/[eventId]/attendees - attendee management UI Components: - CalendarView with Month, Week, Day, and Agenda views - EventModal for creating and editing events - Task overlay integration (tasks appear muted behind events) - Color-coded events with multiple category options - Responsive design (agenda view on mobile) Page Routes: - /dashboard/calendar - personal calendar (aggregates all events) - /dashboard/[driveId]/calendar - drive-specific calendar Real-time Updates: - Socket.IO broadcasts for calendar events - Broadcasts to drive channel and individual attendee channels https://claude.ai/code/session_014UwUdTDf7hSk3nuC1rTnBU --- .../events/[eventId]/attendees/route.ts | 372 ++++++++++++++++ .../api/calendar/events/[eventId]/route.ts | 339 +++++++++++++++ apps/web/src/app/api/calendar/events/route.ts | 346 +++++++++++++++ .../app/dashboard/[driveId]/calendar/page.tsx | 77 ++++ apps/web/src/app/dashboard/calendar/page.tsx | 36 ++ .../src/components/calendar/AgendaView.tsx | 294 +++++++++++++ .../src/components/calendar/CalendarView.tsx | 349 +++++++++++++++ apps/web/src/components/calendar/DayView.tsx | 326 ++++++++++++++ .../src/components/calendar/EventModal.tsx | 407 ++++++++++++++++++ .../web/src/components/calendar/MonthView.tsx | 227 ++++++++++ apps/web/src/components/calendar/WeekView.tsx | 308 +++++++++++++ .../src/components/calendar/calendar-types.ts | 210 +++++++++ apps/web/src/components/calendar/index.ts | 8 + .../components/calendar/useCalendarData.ts | 226 ++++++++++ apps/web/src/lib/websocket/calendar-events.ts | 129 ++++++ apps/web/src/lib/websocket/index.ts | 1 + packages/db/src/schema.ts | 3 + packages/db/src/schema/calendar.ts | 155 +++++++ 18 files changed, 3813 insertions(+) create mode 100644 apps/web/src/app/api/calendar/events/[eventId]/attendees/route.ts create mode 100644 apps/web/src/app/api/calendar/events/[eventId]/route.ts create mode 100644 apps/web/src/app/api/calendar/events/route.ts create mode 100644 apps/web/src/app/dashboard/[driveId]/calendar/page.tsx create mode 100644 apps/web/src/app/dashboard/calendar/page.tsx create mode 100644 apps/web/src/components/calendar/AgendaView.tsx create mode 100644 apps/web/src/components/calendar/CalendarView.tsx create mode 100644 apps/web/src/components/calendar/DayView.tsx create mode 100644 apps/web/src/components/calendar/EventModal.tsx create mode 100644 apps/web/src/components/calendar/MonthView.tsx create mode 100644 apps/web/src/components/calendar/WeekView.tsx create mode 100644 apps/web/src/components/calendar/calendar-types.ts create mode 100644 apps/web/src/components/calendar/index.ts create mode 100644 apps/web/src/components/calendar/useCalendarData.ts create mode 100644 apps/web/src/lib/websocket/calendar-events.ts create mode 100644 packages/db/src/schema/calendar.ts diff --git a/apps/web/src/app/api/calendar/events/[eventId]/attendees/route.ts b/apps/web/src/app/api/calendar/events/[eventId]/attendees/route.ts new file mode 100644 index 000000000..77397e3a0 --- /dev/null +++ b/apps/web/src/app/api/calendar/events/[eventId]/attendees/route.ts @@ -0,0 +1,372 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + db, + calendarEvents, + eventAttendees, + eq, + and, +} from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { broadcastCalendarEvent } from '@/lib/websocket/calendar-events'; + +const AUTH_OPTIONS_READ = { allow: ['session', 'mcp'] as const, requireCSRF: false }; +const AUTH_OPTIONS_WRITE = { allow: ['session', 'mcp'] as const, requireCSRF: true }; + +// Schema for adding attendees +const addAttendeesSchema = z.object({ + userIds: z.array(z.string()).min(1), + isOptional: z.boolean().default(false), +}); + +// Schema for updating RSVP status +const updateRsvpSchema = z.object({ + status: z.enum(['PENDING', 'ACCEPTED', 'DECLINED', 'TENTATIVE']), + responseNote: z.string().max(500).nullable().optional(), +}); + +/** + * GET /api/calendar/events/[eventId]/attendees + * + * Get all attendees for an event + */ +export async function GET( + request: Request, + context: { params: Promise<{ eventId: string }> } +) { + const { eventId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_READ); + if (isAuthError(auth)) { + return auth.error; + } + + const _userId = auth.userId; + + try { + // Verify event exists and user has access + const event = await db.query.calendarEvents.findFirst({ + where: and( + eq(calendarEvents.id, eventId), + eq(calendarEvents.isTrashed, false) + ), + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + // Get attendees with user info + const attendees = await db.query.eventAttendees.findMany({ + where: eq(eventAttendees.eventId, eventId), + with: { + user: { + columns: { id: true, name: true, email: true, image: true }, + }, + }, + }); + + return NextResponse.json({ attendees }); + } catch (error) { + loggers.api.error('Error fetching event attendees:', error as Error); + return NextResponse.json( + { error: 'Failed to fetch event attendees' }, + { status: 500 } + ); + } +} + +/** + * POST /api/calendar/events/[eventId]/attendees + * + * Add attendees to an event (only event creator can do this) + */ +export async function POST( + request: Request, + context: { params: Promise<{ eventId: string }> } +) { + const { eventId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + + try { + // Verify event exists + const event = await db.query.calendarEvents.findFirst({ + where: and( + eq(calendarEvents.id, eventId), + eq(calendarEvents.isTrashed, false) + ), + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + // Only creator can add attendees + if (event.createdById !== userId) { + return NextResponse.json( + { error: 'Only the event creator can add attendees' }, + { status: 403 } + ); + } + + const body = await request.json(); + const parseResult = addAttendeesSchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parseResult.error.issues }, + { status: 400 } + ); + } + + const { userIds, isOptional } = parseResult.data; + + // Get existing attendees to avoid duplicates + const existingAttendees = await db + .select({ userId: eventAttendees.userId }) + .from(eventAttendees) + .where(eq(eventAttendees.eventId, eventId)); + + const existingUserIds = new Set(existingAttendees.map(a => a.userId)); + const newUserIds = userIds.filter(id => !existingUserIds.has(id)); + + if (newUserIds.length === 0) { + return NextResponse.json( + { message: 'All users are already attendees' }, + { status: 200 } + ); + } + + // Add new attendees + await db.insert(eventAttendees).values( + newUserIds.map(attendeeId => ({ + eventId, + userId: attendeeId, + status: 'PENDING' as const, + isOrganizer: false, + isOptional, + })) + ); + + // Fetch updated attendees + const attendees = await db.query.eventAttendees.findMany({ + where: eq(eventAttendees.eventId, eventId), + with: { + user: { + columns: { id: true, name: true, email: true, image: true }, + }, + }, + }); + + // Broadcast to new attendees + await broadcastCalendarEvent({ + eventId, + driveId: event.driveId, + operation: 'updated', + userId, + attendeeIds: newUserIds, + }); + + return NextResponse.json({ attendees }); + } catch (error) { + loggers.api.error('Error adding event attendees:', error as Error); + return NextResponse.json( + { error: 'Failed to add event attendees' }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/calendar/events/[eventId]/attendees + * + * Update RSVP status for the current user + */ +export async function PATCH( + request: Request, + context: { params: Promise<{ eventId: string }> } +) { + const { eventId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + + try { + // Verify event exists + const event = await db.query.calendarEvents.findFirst({ + where: and( + eq(calendarEvents.id, eventId), + eq(calendarEvents.isTrashed, false) + ), + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + // Verify user is an attendee + const attendee = await db.query.eventAttendees.findFirst({ + where: and( + eq(eventAttendees.eventId, eventId), + eq(eventAttendees.userId, userId) + ), + }); + + if (!attendee) { + return NextResponse.json( + { error: 'You are not an attendee of this event' }, + { status: 403 } + ); + } + + const body = await request.json(); + const parseResult = updateRsvpSchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parseResult.error.issues }, + { status: 400 } + ); + } + + const { status, responseNote } = parseResult.data; + + // Update RSVP + const [updatedAttendee] = await db + .update(eventAttendees) + .set({ + status, + responseNote: responseNote ?? null, + respondedAt: new Date(), + }) + .where( + and( + eq(eventAttendees.eventId, eventId), + eq(eventAttendees.userId, userId) + ) + ) + .returning(); + + // Broadcast RSVP update to event creator and other attendees + const allAttendees = await db + .select({ userId: eventAttendees.userId }) + .from(eventAttendees) + .where(eq(eventAttendees.eventId, eventId)); + + await broadcastCalendarEvent({ + eventId, + driveId: event.driveId, + operation: 'rsvp_updated', + userId, + attendeeIds: allAttendees.map(a => a.userId), + }); + + return NextResponse.json(updatedAttendee); + } catch (error) { + loggers.api.error('Error updating RSVP:', error as Error); + return NextResponse.json( + { error: 'Failed to update RSVP' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/calendar/events/[eventId]/attendees + * + * Remove an attendee from the event (only creator can remove others, anyone can remove themselves) + */ +export async function DELETE( + request: Request, + context: { params: Promise<{ eventId: string }> } +) { + const { eventId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + const { searchParams } = new URL(request.url); + const targetUserId = searchParams.get('userId') || userId; + + try { + // Verify event exists + const event = await db.query.calendarEvents.findFirst({ + where: and( + eq(calendarEvents.id, eventId), + eq(calendarEvents.isTrashed, false) + ), + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + // Check permissions + // Users can remove themselves, only creator can remove others + if (targetUserId !== userId && event.createdById !== userId) { + return NextResponse.json( + { error: 'Only the event creator can remove other attendees' }, + { status: 403 } + ); + } + + // Cannot remove the organizer/creator + const targetAttendee = await db.query.eventAttendees.findFirst({ + where: and( + eq(eventAttendees.eventId, eventId), + eq(eventAttendees.userId, targetUserId) + ), + }); + + if (!targetAttendee) { + return NextResponse.json( + { error: 'User is not an attendee of this event' }, + { status: 404 } + ); + } + + if (targetAttendee.isOrganizer) { + return NextResponse.json( + { error: 'Cannot remove the event organizer' }, + { status: 400 } + ); + } + + // Remove attendee + await db + .delete(eventAttendees) + .where( + and( + eq(eventAttendees.eventId, eventId), + eq(eventAttendees.userId, targetUserId) + ) + ); + + // Broadcast removal + await broadcastCalendarEvent({ + eventId, + driveId: event.driveId, + operation: 'updated', + userId, + attendeeIds: [targetUserId], + }); + + return NextResponse.json({ success: true }); + } catch (error) { + loggers.api.error('Error removing event attendee:', error as Error); + return NextResponse.json( + { error: 'Failed to remove event attendee' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/calendar/events/[eventId]/route.ts b/apps/web/src/app/api/calendar/events/[eventId]/route.ts new file mode 100644 index 000000000..b68774d04 --- /dev/null +++ b/apps/web/src/app/api/calendar/events/[eventId]/route.ts @@ -0,0 +1,339 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + db, + calendarEvents, + eventAttendees, + eq, + and, +} from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { isUserDriveMember } from '@pagespace/lib'; +import { broadcastCalendarEvent } from '@/lib/websocket/calendar-events'; + +const AUTH_OPTIONS_READ = { allow: ['session', 'mcp'] as const, requireCSRF: false }; +const AUTH_OPTIONS_WRITE = { allow: ['session', 'mcp'] as const, requireCSRF: true }; + +// Schema for updating an event +const updateEventSchema = z.object({ + title: z.string().min(1).max(500).optional(), + description: z.string().max(10000).nullable().optional(), + location: z.string().max(1000).nullable().optional(), + startAt: z.coerce.date().optional(), + endAt: z.coerce.date().optional(), + allDay: z.boolean().optional(), + timezone: z.string().optional(), + recurrenceRule: z.object({ + frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']), + interval: z.number().int().min(1).default(1), + byDay: z.array(z.enum(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'])).optional(), + byMonthDay: z.array(z.number().int().min(1).max(31)).optional(), + byMonth: z.array(z.number().int().min(1).max(12)).optional(), + count: z.number().int().min(1).optional(), + until: z.string().optional(), + }).nullable().optional(), + visibility: z.enum(['DRIVE', 'ATTENDEES_ONLY', 'PRIVATE']).optional(), + color: z.string().optional(), + pageId: z.string().nullable().optional(), +}); + +/** + * Check if user can access an event + */ +async function canAccessEvent(userId: string, event: typeof calendarEvents.$inferSelect): Promise { + // Creator always has access + if (event.createdById === userId) { + return true; + } + + // Check if user is an attendee + const attendee = await db.query.eventAttendees.findFirst({ + where: and( + eq(eventAttendees.eventId, event.id), + eq(eventAttendees.userId, userId) + ), + }); + if (attendee) { + return true; + } + + // Check drive membership for drive events with DRIVE visibility + if (event.driveId && event.visibility === 'DRIVE') { + return isUserDriveMember(userId, event.driveId); + } + + return false; +} + +/** + * Check if user can edit an event (only creator or drive admin) + */ +async function canEditEvent(userId: string, event: typeof calendarEvents.$inferSelect): Promise { + // Only creator can edit for now + // TODO: Add drive admin check + return event.createdById === userId; +} + +/** + * GET /api/calendar/events/[eventId] + * + * Get a single calendar event by ID + */ +export async function GET( + request: Request, + context: { params: Promise<{ eventId: string }> } +) { + const { eventId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_READ); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + + try { + const event = await db.query.calendarEvents.findFirst({ + where: and( + eq(calendarEvents.id, eventId), + eq(calendarEvents.isTrashed, false) + ), + with: { + createdBy: { + columns: { id: true, name: true, image: true }, + }, + attendees: { + with: { + user: { + columns: { id: true, name: true, image: true }, + }, + }, + }, + page: { + columns: { id: true, title: true, type: true }, + }, + drive: { + columns: { id: true, name: true, slug: true }, + }, + }, + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + // Check access + const hasAccess = await canAccessEvent(userId, event); + if (!hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + return NextResponse.json(event); + } catch (error) { + loggers.api.error('Error fetching calendar event:', error as Error); + return NextResponse.json( + { error: 'Failed to fetch calendar event' }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/calendar/events/[eventId] + * + * Update a calendar event + */ +export async function PATCH( + request: Request, + context: { params: Promise<{ eventId: string }> } +) { + const { eventId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + + try { + // Get existing event + const event = await db.query.calendarEvents.findFirst({ + where: and( + eq(calendarEvents.id, eventId), + eq(calendarEvents.isTrashed, false) + ), + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + // Check edit permission + const canEdit = await canEditEvent(userId, event); + if (!canEdit) { + return NextResponse.json( + { error: 'Only the event creator can edit this event' }, + { status: 403 } + ); + } + + const body = await request.json(); + const parseResult = updateEventSchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parseResult.error.issues }, + { status: 400 } + ); + } + + const data = parseResult.data; + + // Validate dates if both are provided + const newStartAt = data.startAt ?? event.startAt; + const newEndAt = data.endAt ?? event.endAt; + if (newEndAt <= newStartAt) { + return NextResponse.json( + { error: 'End date must be after start date' }, + { status: 400 } + ); + } + + // Update the event + const [updatedEvent] = await db + .update(calendarEvents) + .set({ + title: data.title, + description: data.description, + location: data.location, + startAt: data.startAt, + endAt: data.endAt, + allDay: data.allDay, + timezone: data.timezone, + recurrenceRule: data.recurrenceRule, + visibility: data.visibility, + color: data.color, + pageId: data.pageId, + updatedAt: new Date(), + }) + .where(eq(calendarEvents.id, eventId)) + .returning(); + + // Fetch complete event with relations + const completeEvent = await db.query.calendarEvents.findFirst({ + where: eq(calendarEvents.id, eventId), + with: { + createdBy: { + columns: { id: true, name: true, image: true }, + }, + attendees: { + with: { + user: { + columns: { id: true, name: true, image: true }, + }, + }, + }, + page: { + columns: { id: true, title: true, type: true }, + }, + }, + }); + + // Get all attendee IDs for broadcasting + const attendees = await db + .select({ userId: eventAttendees.userId }) + .from(eventAttendees) + .where(eq(eventAttendees.eventId, eventId)); + + // Broadcast event update + await broadcastCalendarEvent({ + eventId, + driveId: updatedEvent.driveId, + operation: 'updated', + userId, + attendeeIds: attendees.map(a => a.userId), + }); + + return NextResponse.json(completeEvent); + } catch (error) { + loggers.api.error('Error updating calendar event:', error as Error); + return NextResponse.json( + { error: 'Failed to update calendar event' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/calendar/events/[eventId] + * + * Soft delete a calendar event (move to trash) + */ +export async function DELETE( + request: Request, + context: { params: Promise<{ eventId: string }> } +) { + const { eventId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + + try { + // Get existing event + const event = await db.query.calendarEvents.findFirst({ + where: and( + eq(calendarEvents.id, eventId), + eq(calendarEvents.isTrashed, false) + ), + }); + + if (!event) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + // Check edit permission + const canEdit = await canEditEvent(userId, event); + if (!canEdit) { + return NextResponse.json( + { error: 'Only the event creator can delete this event' }, + { status: 403 } + ); + } + + // Get all attendee IDs before deletion for broadcasting + const attendees = await db + .select({ userId: eventAttendees.userId }) + .from(eventAttendees) + .where(eq(eventAttendees.eventId, eventId)); + + // Soft delete the event + await db + .update(calendarEvents) + .set({ + isTrashed: true, + trashedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(calendarEvents.id, eventId)); + + // Broadcast event deletion + await broadcastCalendarEvent({ + eventId, + driveId: event.driveId, + operation: 'deleted', + userId, + attendeeIds: attendees.map(a => a.userId), + }); + + return NextResponse.json({ success: true }); + } catch (error) { + loggers.api.error('Error deleting calendar event:', error as Error); + return NextResponse.json( + { error: 'Failed to delete calendar event' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/calendar/events/route.ts b/apps/web/src/app/api/calendar/events/route.ts new file mode 100644 index 000000000..47a8ea8e3 --- /dev/null +++ b/apps/web/src/app/api/calendar/events/route.ts @@ -0,0 +1,346 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + db, + calendarEvents, + eventAttendees, + eq, + and, + or, + gte, + lte, + inArray, + isNull, + desc, +} from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { isUserDriveMember, getDriveIdsForUser } from '@pagespace/lib'; +import { broadcastCalendarEvent } from '@/lib/websocket/calendar-events'; + +const AUTH_OPTIONS_READ = { allow: ['session', 'mcp'] as const, requireCSRF: false }; +const AUTH_OPTIONS_WRITE = { allow: ['session', 'mcp'] as const, requireCSRF: true }; + +// Query parameters for listing events +const listQuerySchema = z.object({ + context: z.enum(['user', 'drive']).default('user'), + driveId: z.string().optional(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + includePersonal: z.coerce.boolean().default(true), +}); + +// Schema for creating an event +const createEventSchema = z.object({ + driveId: z.string().nullable().optional(), + pageId: z.string().nullable().optional(), + title: z.string().min(1).max(500), + description: z.string().max(10000).nullable().optional(), + location: z.string().max(1000).nullable().optional(), + startAt: z.coerce.date(), + endAt: z.coerce.date(), + allDay: z.boolean().default(false), + timezone: z.string().default('UTC'), + recurrenceRule: z.object({ + frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']), + interval: z.number().int().min(1).default(1), + byDay: z.array(z.enum(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'])).optional(), + byMonthDay: z.array(z.number().int().min(1).max(31)).optional(), + byMonth: z.array(z.number().int().min(1).max(12)).optional(), + count: z.number().int().min(1).optional(), + until: z.string().optional(), + }).nullable().optional(), + visibility: z.enum(['DRIVE', 'ATTENDEES_ONLY', 'PRIVATE']).default('DRIVE'), + color: z.string().default('default'), + attendeeIds: z.array(z.string()).optional(), +}); + +/** + * GET /api/calendar/events + * + * Fetch calendar events based on context: + * - user: All events the user can see (personal + attending + drive events) + * - drive: Events in a specific drive + */ +export async function GET(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_READ); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + const { searchParams } = new URL(request.url); + + try { + const parseResult = listQuerySchema.safeParse({ + context: searchParams.get('context') ?? 'user', + driveId: searchParams.get('driveId') ?? undefined, + startDate: searchParams.get('startDate'), + endDate: searchParams.get('endDate'), + includePersonal: searchParams.get('includePersonal') ?? 'true', + }); + + if (!parseResult.success) { + return NextResponse.json( + { error: parseResult.error.issues.map(i => i.message).join('. ') }, + { status: 400 } + ); + } + + const params = parseResult.data; + + if (params.context === 'drive') { + if (!params.driveId) { + return NextResponse.json( + { error: 'driveId is required for drive context' }, + { status: 400 } + ); + } + + const canView = await isUserDriveMember(userId, params.driveId); + if (!canView) { + return NextResponse.json( + { error: 'Unauthorized - you do not have access to this drive' }, + { status: 403 } + ); + } + + // Fetch drive events within date range + const events = await db.query.calendarEvents.findMany({ + where: and( + eq(calendarEvents.driveId, params.driveId), + eq(calendarEvents.isTrashed, false), + lte(calendarEvents.startAt, params.endDate), + gte(calendarEvents.endAt, params.startDate) + ), + with: { + createdBy: { + columns: { id: true, name: true, image: true }, + }, + attendees: { + with: { + user: { + columns: { id: true, name: true, image: true }, + }, + }, + }, + page: { + columns: { id: true, title: true, type: true }, + }, + }, + orderBy: [desc(calendarEvents.startAt)], + }); + + return NextResponse.json({ events }); + } + + // User context: aggregate events from all sources + const driveIds = await getDriveIdsForUser(userId); + + // Build conditions for user's visible events: + // 1. Personal events (driveId is null, created by user) + // 2. Events in accessible drives (with DRIVE visibility) + // 3. Events where user is an attendee + const conditions = []; + + // Personal events + if (params.includePersonal) { + conditions.push( + and( + isNull(calendarEvents.driveId), + eq(calendarEvents.createdById, userId) + ) + ); + } + + // Drive events + if (driveIds.length > 0) { + conditions.push( + and( + inArray(calendarEvents.driveId, driveIds), + eq(calendarEvents.visibility, 'DRIVE') + ) + ); + } + + // Get event IDs where user is an attendee + const attendeeEvents = await db + .select({ eventId: eventAttendees.eventId }) + .from(eventAttendees) + .where(eq(eventAttendees.userId, userId)); + + const attendeeEventIds = attendeeEvents.map(e => e.eventId); + if (attendeeEventIds.length > 0) { + conditions.push(inArray(calendarEvents.id, attendeeEventIds)); + } + + if (conditions.length === 0) { + return NextResponse.json({ events: [] }); + } + + const events = await db.query.calendarEvents.findMany({ + where: and( + or(...conditions), + eq(calendarEvents.isTrashed, false), + lte(calendarEvents.startAt, params.endDate), + gte(calendarEvents.endAt, params.startDate) + ), + with: { + createdBy: { + columns: { id: true, name: true, image: true }, + }, + attendees: { + with: { + user: { + columns: { id: true, name: true, image: true }, + }, + }, + }, + page: { + columns: { id: true, title: true, type: true }, + }, + drive: { + columns: { id: true, name: true, slug: true }, + }, + }, + orderBy: [desc(calendarEvents.startAt)], + }); + + return NextResponse.json({ events }); + } catch (error) { + loggers.api.error('Error fetching calendar events:', error as Error); + return NextResponse.json( + { error: 'Failed to fetch calendar events' }, + { status: 500 } + ); + } +} + +/** + * POST /api/calendar/events + * + * Create a new calendar event + */ +export async function POST(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_WRITE); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + + try { + const body = await request.json(); + const parseResult = createEventSchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parseResult.error.issues }, + { status: 400 } + ); + } + + const data = parseResult.data; + + // Validate drive access if driveId is provided + if (data.driveId) { + const canAccess = await isUserDriveMember(userId, data.driveId); + if (!canAccess) { + return NextResponse.json( + { error: 'Unauthorized - you do not have access to this drive' }, + { status: 403 } + ); + } + } + + // Validate end date is after start date + if (data.endAt <= data.startAt) { + return NextResponse.json( + { error: 'End date must be after start date' }, + { status: 400 } + ); + } + + // Create the event + const [event] = await db + .insert(calendarEvents) + .values({ + driveId: data.driveId ?? null, + createdById: userId, + pageId: data.pageId ?? null, + title: data.title, + description: data.description ?? null, + location: data.location ?? null, + startAt: data.startAt, + endAt: data.endAt, + allDay: data.allDay, + timezone: data.timezone, + recurrenceRule: data.recurrenceRule ?? null, + visibility: data.visibility, + color: data.color, + updatedAt: new Date(), + }) + .returning(); + + // Add creator as organizer attendee + await db.insert(eventAttendees).values({ + eventId: event.id, + userId: userId, + status: 'ACCEPTED', + isOrganizer: true, + respondedAt: new Date(), + }); + + // Add other attendees if provided + if (data.attendeeIds && data.attendeeIds.length > 0) { + const otherAttendees = data.attendeeIds.filter(id => id !== userId); + if (otherAttendees.length > 0) { + await db.insert(eventAttendees).values( + otherAttendees.map(attendeeId => ({ + eventId: event.id, + userId: attendeeId, + status: 'PENDING' as const, + isOrganizer: false, + })) + ); + } + } + + // Fetch the complete event with relations + const completeEvent = await db.query.calendarEvents.findFirst({ + where: eq(calendarEvents.id, event.id), + with: { + createdBy: { + columns: { id: true, name: true, image: true }, + }, + attendees: { + with: { + user: { + columns: { id: true, name: true, image: true }, + }, + }, + }, + page: { + columns: { id: true, title: true, type: true }, + }, + }, + }); + + // Broadcast event creation + await broadcastCalendarEvent({ + eventId: event.id, + driveId: data.driveId ?? null, + operation: 'created', + userId, + attendeeIds: [userId, ...(data.attendeeIds ?? [])], + }); + + return NextResponse.json(completeEvent, { status: 201 }); + } catch (error) { + loggers.api.error('Error creating calendar event:', error as Error); + return NextResponse.json( + { error: 'Failed to create calendar event' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/dashboard/[driveId]/calendar/page.tsx b/apps/web/src/app/dashboard/[driveId]/calendar/page.tsx new file mode 100644 index 000000000..71b5a55af --- /dev/null +++ b/apps/web/src/app/dashboard/[driveId]/calendar/page.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useEffect, Suspense } from 'react'; +import { useParams } from 'next/navigation'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useDriveStore } from '@/hooks/useDrive'; +import { CalendarView } from '@/components/calendar'; + +function DriveCalendarContent() { + const params = useParams(); + const driveId = params.driveId as string; + const { drives, isLoading, fetchDrives } = useDriveStore(); + + useEffect(() => { + fetchDrives(); + }, [fetchDrives]); + + const drive = drives.find(d => d.id === driveId); + + if (isLoading) { + return ( +
+
+
+ +
+ + +
+
+ +
+
+ ); + } + + if (!drive) { + return ( +
+

Drive not found

+
+ ); + } + + return ( +
+ +
+ ); +} + +export default function DriveCalendarPage() { + return ( + +
+
+ +
+ + +
+
+ +
+ + } + > + +
+ ); +} diff --git a/apps/web/src/app/dashboard/calendar/page.tsx b/apps/web/src/app/dashboard/calendar/page.tsx new file mode 100644 index 000000000..9031e3e98 --- /dev/null +++ b/apps/web/src/app/dashboard/calendar/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Suspense } from 'react'; +import { CalendarView } from '@/components/calendar'; +import { Skeleton } from '@/components/ui/skeleton'; + +function CalendarPageContent() { + return ( +
+ +
+ ); +} + +export default function CalendarPage() { + return ( + +
+
+ +
+ + +
+
+ +
+ + } + > + +
+ ); +} diff --git a/apps/web/src/components/calendar/AgendaView.tsx b/apps/web/src/components/calendar/AgendaView.tsx new file mode 100644 index 000000000..8da185baa --- /dev/null +++ b/apps/web/src/components/calendar/AgendaView.tsx @@ -0,0 +1,294 @@ +'use client'; + +import { useMemo } from 'react'; +import { + startOfMonth, + endOfMonth, + eachDayOfInterval, + format, + isToday as checkIsToday, + isTomorrow, + isYesterday, +} from 'date-fns'; +import { cn } from '@/lib/utils'; +import { MapPin, Clock, Users, ExternalLink } from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { + CalendarEvent, + CalendarHandlers, + TaskWithDueDate, + getEventsForDay, + getTasksForDay, + getEventColors, + TASK_OVERLAY_STYLE, + ATTENDEE_STATUS_CONFIG, +} from './calendar-types'; + +interface AgendaViewProps { + currentDate: Date; + events: CalendarEvent[]; + tasks: TaskWithDueDate[]; + handlers: CalendarHandlers; +} + +export function AgendaView({ currentDate, events, tasks, handlers }: AgendaViewProps) { + // Get all days in the current month + const monthDays = useMemo(() => { + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + return eachDayOfInterval({ start: monthStart, end: monthEnd }); + }, [currentDate]); + + // Group events and tasks by day + const dayGroups = useMemo(() => { + return monthDays + .map((day) => ({ + date: day, + events: getEventsForDay(events, day), + tasks: getTasksForDay(tasks, day), + })) + .filter((group) => group.events.length > 0 || group.tasks.length > 0); + }, [monthDays, events, tasks]); + + // Format relative date + const formatRelativeDate = (date: Date) => { + if (checkIsToday(date)) return 'Today'; + if (isTomorrow(date)) return 'Tomorrow'; + if (isYesterday(date)) return 'Yesterday'; + return format(date, 'EEEE, MMMM d'); + }; + + if (dayGroups.length === 0) { + return ( +
+
No events this month
+
+ Click "New Event" to add one +
+
+ ); + } + + return ( +
+
+ {dayGroups.map((group) => { + const isTodayDate = checkIsToday(group.date); + + return ( +
+ {/* Day header */} +
+ +
+ + {/* Events for this day */} +
+ {/* Events first (take visual precedence) */} + {group.events.map((event) => ( + handlers.onEventClick(event)} + /> + ))} + + {/* Tasks */} + {group.tasks.map((task) => ( + handlers.onTaskClick?.(task)} + /> + ))} +
+
+ ); + })} +
+
+ ); +} + +// Event card component +function EventCard({ + event, + onClick, +}: { + event: CalendarEvent; + onClick: () => void; +}) { + const colors = getEventColors(event.color); + const isAllDay = event.allDay; + const startTime = format(new Date(event.startAt), 'h:mm a'); + const endTime = format(new Date(event.endAt), 'h:mm a'); + + return ( + + ); +} + +// Task card component (muted styling) +function TaskCard({ + task, + onClick, +}: { + task: TaskWithDueDate; + onClick: () => void; +}) { + const isCompleted = task.status === 'completed'; + const priorityColors = { + low: 'text-muted-foreground', + medium: 'text-amber-600', + high: 'text-red-600', + }; + + return ( + + ); +} diff --git a/apps/web/src/components/calendar/CalendarView.tsx b/apps/web/src/components/calendar/CalendarView.tsx new file mode 100644 index 000000000..ecde490d6 --- /dev/null +++ b/apps/web/src/components/calendar/CalendarView.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { format, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays } from 'date-fns'; +import { ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, List, LayoutGrid, Clock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Toggle } from '@/components/ui/toggle'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { useCalendarData } from './useCalendarData'; +import { MonthView } from './MonthView'; +import { WeekView } from './WeekView'; +import { DayView } from './DayView'; +import { AgendaView } from './AgendaView'; +import { EventModal } from './EventModal'; +import { + CalendarViewMode, + CalendarEvent, + CalendarHandlers, +} from './calendar-types'; + +interface CalendarViewProps { + context: 'user' | 'drive'; + driveId?: string; + driveName?: string; + className?: string; +} + +export function CalendarView({ context, driveId, driveName: _driveName, className }: CalendarViewProps) { + const [currentDate, setCurrentDate] = useState(new Date()); + const [viewMode, setViewMode] = useState('month'); + const [showTasks, setShowTasks] = useState(true); + const [selectedEvent, setSelectedEvent] = useState(null); + const [isEventModalOpen, setIsEventModalOpen] = useState(false); + const [newEventDefaults, setNewEventDefaults] = useState<{ + startAt: Date; + endAt: Date; + allDay: boolean; + } | null>(null); + + const { + events, + tasks, + isLoading, + error, + createEvent, + updateEvent, + deleteEvent, + refresh: _refresh, + } = useCalendarData({ + context, + driveId, + currentDate, + includePersonal: context === 'user', + includeTasks: showTasks, + }); + + // Navigation handlers + const handlePrevious = useCallback(() => { + switch (viewMode) { + case 'month': + setCurrentDate((d) => subMonths(d, 1)); + break; + case 'week': + setCurrentDate((d) => subWeeks(d, 1)); + break; + case 'day': + setCurrentDate((d) => subDays(d, 1)); + break; + case 'agenda': + setCurrentDate((d) => subMonths(d, 1)); + break; + } + }, [viewMode]); + + const handleNext = useCallback(() => { + switch (viewMode) { + case 'month': + setCurrentDate((d) => addMonths(d, 1)); + break; + case 'week': + setCurrentDate((d) => addWeeks(d, 1)); + break; + case 'day': + setCurrentDate((d) => addDays(d, 1)); + break; + case 'agenda': + setCurrentDate((d) => addMonths(d, 1)); + break; + } + }, [viewMode]); + + const handleToday = useCallback(() => { + setCurrentDate(new Date()); + }, []); + + // Calendar handlers + const handlers: CalendarHandlers = { + onEventClick: (event) => { + setSelectedEvent(event); + setNewEventDefaults(null); + setIsEventModalOpen(true); + }, + onEventCreate: (start, end, allDay = false) => { + setSelectedEvent(null); + setNewEventDefaults({ startAt: start, endAt: end, allDay }); + setIsEventModalOpen(true); + }, + onEventUpdate: async (eventId, updates) => { + await updateEvent(eventId, updates); + }, + onEventDelete: async (eventId) => { + await deleteEvent(eventId); + setIsEventModalOpen(false); + }, + onTaskClick: (task) => { + // Navigate to task list page + window.location.href = `/dashboard/${task.driveId}/${task.taskListPageId}`; + }, + onDateChange: setCurrentDate, + onViewChange: setViewMode, + }; + + // Handle event modal save + const handleEventSave = async (eventData: { + title: string; + description?: string; + location?: string; + startAt: Date; + endAt: Date; + allDay: boolean; + color?: string; + attendeeIds?: string[]; + }) => { + if (selectedEvent) { + await updateEvent(selectedEvent.id, eventData); + } else { + await createEvent(eventData); + } + setIsEventModalOpen(false); + }; + + // Get header title based on view mode + const getHeaderTitle = () => { + switch (viewMode) { + case 'month': + return format(currentDate, 'MMMM yyyy'); + case 'week': + return format(currentDate, "'Week of' MMM d, yyyy"); + case 'day': + return format(currentDate, 'EEEE, MMMM d, yyyy'); + case 'agenda': + return format(currentDate, 'MMMM yyyy'); + } + }; + + if (error) { + return ( +
+ Failed to load calendar +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ {/* Navigation */} +
+ + + +
+ + {/* Title */} +

{getHeaderTitle()}

+
+ +
+ {/* View mode selector */} +
+ + + + +
+ + {/* Mobile view selector */} + + + {/* Tasks toggle */} + + Tasks + + + {/* New event button */} + +
+
+ + {/* Calendar content */} +
+ {isLoading ? ( +
+
+
+ ) : ( + <> + {viewMode === 'month' && ( + + )} + {viewMode === 'week' && ( + + )} + {viewMode === 'day' && ( + + )} + {viewMode === 'agenda' && ( + + )} + + )} +
+ + {/* Event modal */} + setIsEventModalOpen(false)} + event={selectedEvent} + defaultValues={newEventDefaults} + onSave={handleEventSave} + onDelete={selectedEvent ? () => handlers.onEventDelete(selectedEvent.id) : undefined} + driveId={driveId} + context={context} + /> +
+ ); +} diff --git a/apps/web/src/components/calendar/DayView.tsx b/apps/web/src/components/calendar/DayView.tsx new file mode 100644 index 000000000..de8dc4e98 --- /dev/null +++ b/apps/web/src/components/calendar/DayView.tsx @@ -0,0 +1,326 @@ +'use client'; + +import { useMemo, useRef } from 'react'; +import { + format, + differenceInMinutes, + setHours, + setMinutes, +} from 'date-fns'; +import { cn } from '@/lib/utils'; +import { MapPin, Users } from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + CalendarEvent, + CalendarHandlers, + TaskWithDueDate, + getEventsForDay, + getTasksForDay, + isToday, + getEventColors, + TASK_OVERLAY_STYLE, +} from './calendar-types'; + +interface DayViewProps { + currentDate: Date; + events: CalendarEvent[]; + tasks: TaskWithDueDate[]; + handlers: CalendarHandlers; +} + +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const HOUR_HEIGHT = 64; // Taller for day view + +export function DayView({ currentDate, events, tasks, handlers }: DayViewProps) { + const containerRef = useRef(null); + + // Get events and tasks for the current day + const dayEvents = useMemo(() => getEventsForDay(events, currentDate), [events, currentDate]); + const dayTasks = useMemo(() => getTasksForDay(tasks, currentDate), [tasks, currentDate]); + + // Separate all-day events from timed events + const { allDayEvents, timedEvents } = useMemo(() => { + const allDay: CalendarEvent[] = []; + const timed: CalendarEvent[] = []; + + dayEvents.forEach((event) => { + if (event.allDay) { + allDay.push(event); + } else { + timed.push(event); + } + }); + + return { allDayEvents: allDay, timedEvents: timed }; + }, [dayEvents]); + + // Handle click on time slot + const handleTimeSlotClick = (hour: number) => { + const start = setMinutes(setHours(new Date(currentDate), hour), 0); + const end = setMinutes(setHours(new Date(currentDate), hour + 1), 0); + handlers.onEventCreate(start, end); + }; + + // Calculate event position and height + const getEventStyle = (event: CalendarEvent) => { + const start = new Date(event.startAt); + const end = new Date(event.endAt); + + // Clamp to current day + const dayStart = setMinutes(setHours(new Date(currentDate), 0), 0); + const dayEnd = setMinutes(setHours(new Date(currentDate), 23), 59); + + const effectiveStart = start < dayStart ? dayStart : start; + const effectiveEnd = end > dayEnd ? dayEnd : end; + + const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes(); + const durationMinutes = differenceInMinutes(effectiveEnd, effectiveStart); + + const top = (startMinutes / 60) * HOUR_HEIGHT; + const height = Math.max((durationMinutes / 60) * HOUR_HEIGHT, 40); // Min 40px height + + return { top, height }; + }; + + const isTodayDate = isToday(currentDate); + + return ( +
+ {/* Day header */} +
+
+
+ {format(currentDate, 'd')} +
+
+
{format(currentDate, 'EEEE')}
+
+ {format(currentDate, 'MMMM yyyy')} +
+
+
+ + {/* All-day events and tasks */} + {(allDayEvents.length > 0 || dayTasks.length > 0) && ( +
+
+ All Day +
+ {allDayEvents.map((event) => ( + handlers.onEventClick(event)} + /> + ))} + {dayTasks.map((task) => ( + handlers.onTaskClick?.(task)} + /> + ))} +
+ )} +
+ + {/* Time grid */} +
+
+ {/* Time gutter */} +
+ {HOURS.map((hour) => ( +
+ + {format(setHours(new Date(), hour), 'h a')} + +
+ ))} +
+ + {/* Events column */} +
+ {/* Hour slots */} + {HOURS.map((hour) => ( +
handleTimeSlotClick(hour)} + /> + ))} + + {/* Current time indicator */} + {isTodayDate && } + + {/* Timed events */} + {timedEvents.map((event) => { + const { top, height } = getEventStyle(event); + + return ( + handlers.onEventClick(event)} + /> + ); + })} +
+
+
+
+ ); +} + +// Current time indicator +function CurrentTimeIndicator() { + const now = new Date(); + const minutes = now.getHours() * 60 + now.getMinutes(); + const top = (minutes / 60) * HOUR_HEIGHT; + + return ( +
+
+
+
+
+
+ ); +} + +// Timed event card (expanded) +function TimedEventCard({ + event, + style, + onClick, +}: { + event: CalendarEvent; + style: { top: number; height: number }; + onClick: () => void; +}) { + const colors = getEventColors(event.color); + const showDetails = style.height > 60; + + return ( + + ); +} + +// All-day event card +function AllDayEventCard({ + event, + onClick, +}: { + event: CalendarEvent; + onClick: () => void; +}) { + const colors = getEventColors(event.color); + + return ( + + ); +} + +// Task card +function TaskCard({ + task, + onClick, +}: { + task: TaskWithDueDate; + onClick: () => void; +}) { + const isCompleted = task.status === 'completed'; + + return ( + + ); +} diff --git a/apps/web/src/components/calendar/EventModal.tsx b/apps/web/src/components/calendar/EventModal.tsx new file mode 100644 index 000000000..5f0fdcc34 --- /dev/null +++ b/apps/web/src/components/calendar/EventModal.tsx @@ -0,0 +1,407 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { format } from 'date-fns'; +import { CalendarIcon, Clock, MapPin, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { CalendarEvent, EVENT_COLORS } from './calendar-types'; + +interface EventModalProps { + isOpen: boolean; + onClose: () => void; + event: CalendarEvent | null; + defaultValues: { + startAt: Date; + endAt: Date; + allDay: boolean; + } | null; + onSave: (eventData: { + title: string; + description?: string; + location?: string; + startAt: Date; + endAt: Date; + allDay: boolean; + color?: string; + attendeeIds?: string[]; + }) => Promise; + onDelete?: () => Promise; + driveId?: string; + context: 'user' | 'drive'; +} + +const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => { + const hours = Math.floor(i / 2); + const minutes = i % 2 === 0 ? '00' : '30'; + const period = hours < 12 ? 'AM' : 'PM'; + const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + return { + value: `${hours.toString().padStart(2, '0')}:${minutes}`, + label: `${displayHours}:${minutes} ${period}`, + }; +}); + +const COLOR_OPTIONS = Object.keys(EVENT_COLORS) as (keyof typeof EVENT_COLORS)[]; + +export function EventModal({ + isOpen, + onClose, + event, + defaultValues, + onSave, + onDelete, + driveId: _driveId, + context: _context, +}: EventModalProps) { + const isEditing = !!event; + + // Form state + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [location, setLocation] = useState(''); + const [startDate, setStartDate] = useState(new Date()); + const [startTime, setStartTime] = useState('09:00'); + const [endDate, setEndDate] = useState(new Date()); + const [endTime, setEndTime] = useState('10:00'); + const [allDay, setAllDay] = useState(false); + const [color, setColor] = useState('default'); + const [isSaving, setIsSaving] = useState(false); + + // Initialize form when modal opens + useEffect(() => { + if (isOpen) { + if (event) { + // Editing existing event + setTitle(event.title); + setDescription(event.description ?? ''); + setLocation(event.location ?? ''); + setAllDay(event.allDay); + setColor(event.color as keyof typeof EVENT_COLORS); + + const start = new Date(event.startAt); + const end = new Date(event.endAt); + setStartDate(start); + setEndDate(end); + setStartTime(format(start, 'HH:mm')); + setEndTime(format(end, 'HH:mm')); + } else if (defaultValues) { + // Creating new event with defaults + setTitle(''); + setDescription(''); + setLocation(''); + setAllDay(defaultValues.allDay); + setColor('default'); + setStartDate(defaultValues.startAt); + setEndDate(defaultValues.endAt); + setStartTime(format(defaultValues.startAt, 'HH:mm')); + setEndTime(format(defaultValues.endAt, 'HH:mm')); + } else { + // Creating new event without defaults + const now = new Date(); + const start = new Date(now); + start.setMinutes(0, 0, 0); + start.setHours(start.getHours() + 1); + const end = new Date(start); + end.setHours(end.getHours() + 1); + + setTitle(''); + setDescription(''); + setLocation(''); + setAllDay(false); + setColor('default'); + setStartDate(start); + setEndDate(end); + setStartTime(format(start, 'HH:mm')); + setEndTime(format(end, 'HH:mm')); + } + } + }, [isOpen, event, defaultValues]); + + // Build datetime from date and time + const buildDateTime = (date: Date, time: string): Date => { + const [hours, minutes] = time.split(':').map(Number); + const result = new Date(date); + result.setHours(hours, minutes, 0, 0); + return result; + }; + + // Handle save + const handleSave = async () => { + if (!title.trim()) { + toast.error('Please enter a title'); + return; + } + + const startAt = allDay + ? new Date(startDate.setHours(0, 0, 0, 0)) + : buildDateTime(startDate, startTime); + const endAt = allDay + ? new Date(endDate.setHours(23, 59, 59, 999)) + : buildDateTime(endDate, endTime); + + if (endAt <= startAt) { + toast.error('End time must be after start time'); + return; + } + + setIsSaving(true); + try { + await onSave({ + title: title.trim(), + description: description.trim() || undefined, + location: location.trim() || undefined, + startAt, + endAt, + allDay, + color, + }); + toast.success(isEditing ? 'Event updated' : 'Event created'); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to save event' + ); + } finally { + setIsSaving(false); + } + }; + + // Handle delete + const handleDelete = async () => { + if (!onDelete) return; + + setIsSaving(true); + try { + await onDelete(); + toast.success('Event deleted'); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to delete event' + ); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + {isEditing ? 'Edit Event' : 'New Event'} + + +
+ {/* Title */} +
+ + setTitle(e.target.value)} + autoFocus + /> +
+ + {/* All-day toggle */} +
+ + +
+ + {/* Start date/time */} +
+
+ + + + + + + date && setStartDate(date)} + /> + + +
+ + {!allDay && ( +
+ + +
+ )} +
+ + {/* End date/time */} +
+
+ + + + + + + date && setEndDate(date)} + /> + + +
+ + {!allDay && ( +
+ + +
+ )} +
+ + {/* Location */} +
+ +
+ + setLocation(e.target.value)} + className="pl-9" + /> +
+
+ + {/* Color */} +
+ +
+ {COLOR_OPTIONS.map((colorKey) => { + const colorConfig = EVENT_COLORS[colorKey]; + return ( +
+
+ + {/* Description */} +
+ +